Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Add InlineSpan.updateAttributes #154017

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

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

LongCatIsLooong
Copy link
Contributor

@LongCatIsLooong LongCatIsLooong commented Aug 23, 2024

Use cases in the framework:

  • Service integrations in the framework such as
    List<TextSpan> _buildSubtreesWithComposingRegion(
    List<SuggestionSpan>? spellCheckSuggestions,
    TextEditingValue value,
    TextStyle? style,
    TextStyle misspelledStyle,
    bool composingWithinCurrentTextRange) {
    final List<TextSpan> textSpanTreeChildren = <TextSpan>[];
    int textPointer = 0;
    int currentSpanPointer = 0;
    int endIndex;
    SuggestionSpan currentSpan;
    final String text = value.text;
    final TextRange composingRegion = value.composing;
    final TextStyle composingTextStyle =
    style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
    const TextStyle(decoration: TextDecoration.underline);
    final TextStyle misspelledJointStyle =
    style?.merge(misspelledStyle) ?? misspelledStyle;
    bool textPointerWithinComposingRegion = false;
    bool currentSpanIsComposingRegion = false;
    // Add text interwoven with any misspelled words to the tree.
    if (spellCheckSuggestions != null) {
    while (textPointer < text.length &&
    currentSpanPointer < spellCheckSuggestions.length) {
    currentSpan = spellCheckSuggestions[currentSpanPointer];
    if (currentSpan.range.start > textPointer) {
    endIndex = currentSpan.range.start < text.length
    ? currentSpan.range.start
    : text.length;
    textPointerWithinComposingRegion =
    composingRegion.start >= textPointer &&
    composingRegion.end <= endIndex &&
    !composingWithinCurrentTextRange;
    if (textPointerWithinComposingRegion) {
    _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer,
    composingRegion, style, composingTextStyle);
    textSpanTreeChildren.add(
    TextSpan(
    style: style,
    text: text.substring(composingRegion.end, endIndex),
    )
    );
    } else {
    textSpanTreeChildren.add(
    TextSpan(
    style: style,
    text: text.substring(textPointer, endIndex),
    )
    );
    }
    textPointer = endIndex;
    } else {
    endIndex =
    currentSpan.range.end < text.length ? currentSpan.range.end : text.length;
    currentSpanIsComposingRegion = textPointer >= composingRegion.start &&
    endIndex <= composingRegion.end &&
    !composingWithinCurrentTextRange;
    textSpanTreeChildren.add(
    TextSpan(
    style: currentSpanIsComposingRegion
    ? composingTextStyle
    : misspelledJointStyle,
    text: text.substring(currentSpan.range.start, endIndex),
    )
    );
    textPointer = endIndex;
    currentSpanPointer++;
    }
    }
    }
    // Add any remaining text to the tree if applicable.
    if (textPointer < text.length) {
    if (textPointer < composingRegion.start &&
    !composingWithinCurrentTextRange) {
    _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer,
    composingRegion, style, composingTextStyle);
    if (composingRegion.end != text.length) {
    textSpanTreeChildren.add(
    TextSpan(
    style: style,
    text: text.substring(composingRegion.end, text.length),
    )
    );
    }
    } else {
    textSpanTreeChildren.add(
    TextSpan(
    style: style, text: text.substring(textPointer, text.length),
    )
    );
    }
    }
    return textSpanTreeChildren;
    }
    , and Support iOS 18 formatting menu #150068. Since TextEditingController.buildTextSpan returns a TextSpan, it's difficult to update the returned span to add new styles without a update method.
  • maybe: Linkify

Use cases outside the framework

  • Updating an existing InlineSpan with new styles, especially when you only have the InlineSpan representation.

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels Aug 23, 2024
/// `newAttributes` to the given [TextRange] within this span.
///
/// The `offset` parameter is used by the implementation to track the offsets
/// from the start of the root span to sub-spans , it should be set to null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: strange spacing around the ,

expect(const Right<int, bool>(true).toString(), 'Right<int, bool>(true)');
});

test('Equality', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about testing Right? And maybe include some cases that are not equal?

///
/// The `offset` parameter is used by the implementation to track the offsets
/// from the start of the root span to sub-spans , it should be set to null
/// (the default) if you are not implementing an `InlineSpan` subclass.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should I do if I am implementing an InlineSpan subclass? Is there somewhere a reader could learn more about this parameter? This doc is very ominous about it...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can hide the parameter from the userfacing API by introducing another method that just calls this one with offset set to Accumulator(), and that's what other methods do, I guess I'll follow the convention.

@@ -364,6 +379,15 @@ abstract class InlineSpan extends DiagnosticableTree {
@protected
int? codeUnitAtVisitor(int index, Accumulator offset);

/// Creates a new [InlineSpan] from this [InlineSpan] by applying
/// `newAttributes` to the given [TextRange] within this span.
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document: How do I create an InlineSpan that removes an attribute?


/// An attribute which overwrites an [InlineSpan]'s [TextStyle.foreground] and
/// [TextStyle.color] if set to non-null.
final Either<ui.Color, ui.Paint>? foreground;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code to call this is kinda odd:

InlineSpanAttributes(
  foreground: Left(Colors.green),
);

As a reader of that, it is not clear what the "Left" means here. Why do we need to invent something new here instead of using the same pattern that e.g. TextStyle is using for [TextStyle.foreground] and [TextStyle.color]?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also applies to the other Either properties below.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right is also kinda awkwardly overloaded. Here, it means an alternative representation (Paint instead of Color). For recognizer it means remove the property.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to make it statically impossible to set both color and foreground. I can either typedef Foreground = Left or use an assert (if the InlineSpanAttributes construction happens in a const context then I think the assert will also be evaluated at compile time, so I guess that's static-ish?), WDYT?

/// from the start of the root span to sub-spans , it should be set to null
/// (the default) if you are not implementing an `InlineSpan` subclass.
@useResult
InlineSpan updateAttributes(covariant InlineSpanAttributes newAttributes, TextRange textRange, { Accumulator? offset });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, "update" indicates an in-place mutation, not that a new instance is returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the best I could come up with unfortunately. Any suggestions?

On the other hand I think TextStyle.merge or TextStyle.apply indicate mutation too (without context). The InlineSpan class has to be immutable since equality is important for the class, so I guess in that context the caller should assume all InlineSpan operations are non-mutating.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBC: To my eye "apply" can equally well describe something that mutates, or something that returns its result instead. After all it's a generic term used for function calls, and functions can be either mutating or non-mutating.

Similarly I read "merge" as describing how the result relates to the inputs, but leaving open whether something gets mutated into the result state or instead everything's immutable and the result gets returned.

But "update" on the other hand specifically sounds to me like it's talking about changing something, mutating it.

So I'd read "applyAttributes" or "mergeAttributes" as leaving unstated whether the span gets mutated, which is OK because either the method's doc or a good understanding of InlineSpan in general resolves the uncertainty; but "updateAttributes" as misleading because it seems to specifically say something that isn't actually what's intended.

Comment on lines 566 to 567
? List<TextSpan>.filled(1, child1)
: (List<TextSpan>.filled(2, child1)..[1] = child2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: indentation (increase by 1 space)

addTearDown(recognizer.dispose);
final int length = simpleSpan.toPlainText().length;
final TextSpan withRecognizer = simpleSpan.updateAttributes(
InlineSpanAttributes(recognizer: Left(recognizer)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As stated elsewhere, the Left in there feels out of place. When reading this code, it is unclear what this means and if you click through on Left it gives you a very generic description about Left that isn't helpful. In fact, I don't even know what docs to point people to if they want to understand what this Left instantiation here does.

Comment on lines 542 to 545
final TextSpan partiallyDeleted = withRecognizer.updateAttributes(
const InlineSpanAttributes(recognizer: InlineSpanAttributes.remove),
const TextRange(start: 5, end: 6),
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remove API is kinda awkward. Could we split up updateAttributes into addAttribute and removeAttribute to make this more straight-forward?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside of that is we'll need two separate methods and two separate attribute sets (one for update and one for removal) for basically the same thing on InlineSpan. Maybe introducing sentinel values for removal is the way?

@goderbauer
Copy link
Member

Could you add a little more context (e.g. to the PR description or via a linked issue) what you envision this new updateAttributes API to be used for? Are we going to use this in the framework? What will we implement with this? Are regular flutter apps supposed to use it? Why would they do that?

@LongCatIsLooong LongCatIsLooong force-pushed the add-inlineSpan-updateAttributes branch 2 times, most recently from 4033d59 to 31f07cb Compare August 27, 2024 22:35
@LongCatIsLooong
Copy link
Contributor Author

Removed the Either type and switched to using a sentinel value (I wish Either was part of dart:core so we can have syntax sugar that allows us to avoid the explicit use of the Left constructor, so we can fix the "copyWith can't set a property to null" problem, among other things).

@LongCatIsLooong LongCatIsLooong force-pushed the add-inlineSpan-updateAttributes branch from 31f07cb to bde30bc Compare August 27, 2024 22:47
@@ -50,7 +50,7 @@ export 'src/painting/image_decoder.dart';
export 'src/painting/image_provider.dart';
export 'src/painting/image_resolution.dart';
export 'src/painting/image_stream.dart';
export 'src/painting/inline_span.dart';
export 'src/painting/inline_span.dart' hide RemoveInlineSpanAttribute;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still prefer hiding the type here since its only inhabitant is a sentinel value that only supports the identical operation.

///
/// All attributes are nullable, and default to `null`. Attributes set to `null`
/// in an [InlineSpanAttributes] will not be updated.
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably document how attributes can be removed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still not sure I understand the philosophy behind why some attributes are removable and others are not. You can create a fresh TextStyle without setting a font family (which I think in practice means it's gonna be inferred from e.g. a sounding DefaultTextStyle (although I am not sure right now at what level that happens)). Why would you never want to remove the fontFamily from an existing TextStyle using this API?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that situation is uncommon (you probably have a better feeling than I for that) we should still document what's possible and what isn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the fontFamily

Ah that sounds like a legit use case. There's no way to use the "default" font family unless it's set to null.

it's gonna be inferred from e.g. a sounding DefaultTextStyle

It happens in the Text widget I think. But yeah to support removing fontSize (such that the text uses the ambient DefaultTextStyle fontSize) we would need a different sentinel value.

&& lineThrough == null
&& decorationColor == null
&& decorationStyle == null
&& decorationThickness == null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Could InlineSpanAttributes in its constructor just take a TextStyle instead of all its individual properties?

/// the span of this [InlineSpan]. The implementation is responsible for
/// advancing the offset by this [InlineSpan]'s length in UTF16 code units,
/// such that the `offset` points to the end (enclusive) of this [InlineSpan]
/// when this method returns.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe leave a note that you'd typically override this method, but call updateAttributes?

InlineSpan updateAttributesAtOffset(covariant InlineSpanAttributes newAttributes, TextRange textRange, Accumulator offset);

/// Creates a new [InlineSpan] from this [InlineSpan] by applying
/// `newAttributes` to the given [TextRange] within this span.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, maybe leave a comment that subclasses should override updateAttributesAtOffset instead of this one?

///
/// See also:
///
/// * [InlineSpan.updateAttributesAtOffset], which takes an [InlineSpanAttributes], and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and elsewhere in the docs: Should the docs link to updateAttributes instead?

/// replaces existing recognizers (if any) within the given range with the
/// specified recognizer.
///
/// When this value is set to [remove], the [InlineSpan.updateAttributesAtOffset]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering: Could we use null to indicate "remove" by using remove (under another name) as the default value for this property? That default value could maybe even have a private type so we don't need to export RemoveInlineSpanAttribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the sentinel values aren't exposed, whether an attribute should be updated would become a compile-time decision?
E.g.:

final Color? maybeColor = ...;
if (maybeColor == null) {
  updateAttributes(.... other attributes);
} else { 
  updateAttributes(.... other attributes, color: maybeColor);
}

It will quickly become unscaleable configuration matrix I think, not great for APIs that want to wrap this API.

/// [TextSpan.onEnter] can not be set from a non-null value to null using this
/// attribute. Considering setting [TextSpan.onEnter] to a function that does
/// nothing if you want to unset the callback from a [TextSpan].
final PointerEnterEventListener? onEnter;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That default value could also work for these, reducing the number of remove options from 3 (unremovable, removable with remove, removable by setting to a no-op) to 2 (unremovable, use null to indicate remove).

@chunhtai chunhtai self-requested a review October 1, 2024 22:15
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the updating function mostly looks good to me

}
}

final class _PoorMansBottomType with DiagnosticableTreeMixin implements GestureRecognizer {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really needs a better name....

/// [InlineSpan.updateAttributes] does not mutate the target
/// [InlineSpan]. Rather, when [recognizer] is set to [remove], that method
/// returns a new [InlineSpan] with [recognizer] set to null.
static const RemoveInlineSpanAttribute remove = _PoorMansBottomType._();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can avoid this by doing something like this

InlineSpanAttributes(
  GestureRecognizer? this.recognizer = _somePrivateDefaultValue
)
if (recognizer == _somePrivateDefaultValue) // no change to recognizer
else // replace whatever value that passed in

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and should update the doc says passing in null to recognizer will remove recognizer

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like it is also used in text_span.dart, but I think it is still better to ask people to use remove property to clean up the field.

Also have we consider merging these span libraries into one file? it feels unecessary to separate them and expose a bunch of internal method...

&& lineThrough == null
&& decorationColor == null
&& decorationStyle == null
&& decorationThickness == null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@LongCatIsLooong LongCatIsLooong marked this pull request as draft October 16, 2024 20:10
@LongCatIsLooong
Copy link
Contributor Author

Still evaluating some alternatives as I would like to expose more lower level text layout apis, and at the same time some hacks in the PR made the API less appealing (e.g., the removal problem).

@Piinks
Copy link
Contributor

Piinks commented Feb 26, 2025

(Triage) Confirmed with @LongCatIsLooong this is still on their radar

@Piinks
Copy link
Contributor

Piinks commented Apr 30, 2025

Hello from triage again! Is this still something you'd like to work on?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants