Textf

pub package License: MIT style: very good analysis tests coverage AI Skills

Website • Quickstart • Playground

Inline Markdown-like formatting for Flutter — as drop-in replacements for Text and TextEditingController. Zero dependencies. Bold, italic, code, URL Link, highlights, super²/subscript₂.


⚠️ Upgrading from 1.x? Textf 2.0 no longer reads your app's Theme. Links now default to a fixed #1A73E8 blue, and code chips to a faint tint of the text color, in every app: Material, material_ui, Cupertino or a bare WidgetsApp. To keep using your theme's colors, pass them to TextfOptions once in MaterialApp.builder. See Theming.

⚠️ Upgrading from 1.1.x? Version 1.2.0 introduces strict flanking rules for formatting markers. Markers with surrounding whitespace — such as * spaced * — no longer trigger formatting. Update these to *not-spaced*. See Flanking Rules for details.


Two Drop-in Replacements

Textf — Formatted display text

Replace Text with Textf and your strings render with bold, italic, code, highlights, links, and more.

Textf('Hello **Flutter**. Build for ==any screen==!');

Textf widget screenshot

TextfEditingController — Live formatting in text fields

Replace TextEditingController with TextfEditingController to render formatting live in TextField as the user types — no extra widgets needed.

final controller = TextfEditingController();
TextField(controller: controller);

TextfEditingController screenshot


Quick Start

1. Add the dependency:

flutter pub add textf

2. Import the package:

import 'package:textf/textf.dart';

3. Use it — that's it:

import 'package:flutter/material.dart';
import 'package:textf/textf.dart';

class MyWidget extends StatelessWidget {
  final _controller = TextfEditingController();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Drop-in for Text
        Textf(
          '**Bold**, *italic*, `code`, and [links](https://flutter.cn)',
          style: TextStyle(fontSize: 16),
        ),

        // Drop-in for TextEditingController
        TextField(controller: _controller),
      ],
    );
  }
}

Both widgets share the same formatting syntax and can be configured together with TextfOptions.


Limitations

Limitation Reason
Inline-first ATX headings (#–######) are the one block-level exception — no lists, quotes, tables, or images
Max 2 nesting levels **bold _italic_** works, deeper nesting renders as plain text
Selection across links Links use WidgetSpan, so selection can't span across them (Flutter limitation)
Widget placeholders {key} placeholders render as literal text in TextfEditingController

CommonMark Feature Support Status

Textf is inline-first and engineered to run securely inside a TextEditingController. This imposes strict architectural rules:

  1. Single-pass, O(N) parsing: No AST, no look-behind, and no multi-pass resolution.
  2. The 1:1 Invariant: Every source character maps to exactly one cursor slot.
  3. TextSpan-only layout: No composed widgets (like Column, Container, or Padding), relying entirely on Flutter's Text.rich engine.

Because of these constraints, Textf does not implement all of CommonMark. Features that require multi-pass parsing (Reference Links) or box-model layouts (Lists with hanging indents) are strictly excluded by design.

Legend:

  • ✅ Supported — Fully implemented and tested.
  • 🚧 Coming in 2.0 — Architecturally viable and currently on the roadmap.
  • ❌ Out of Scope — Conflicts with single-pass parsing or TextSpan layout constraints.

Inline Elements (Core)

These elements map directly to Flutter's TextSpan and are the primary focus of Textf.

Feature Syntax / Example Status Notes
Textual Content Plain text ✅
Emphasis *italic* or _italic_ ✅
Strong Emphasis **bold** or __bold__ ✅
Code Spans `inline code` ✅
Inline Links [text](https://url.com) ✅ Supports nested formatting inside link text
Backslash Escapes \*literal asterisks\* ✅ Supported for all marker characters
Images ![alt](url.png) ❌ Use {key} widget placeholders instead
Reference Links [text][label] ❌ Requires multi-pass definition lookup
Autolinks <https://url.com> ❌ Standard inline links [text](url) preferred
Raw HTML <strong>text</strong> ❌ Not suitable for Flutter rendering

Textf Extensions (GFM & Custom Inlines)

Textf extends standard Markdown with highly requested inline features used in modern chat and document apps.

Feature Syntax / Example Status Notes
Strikethrough ~~strike~~ ✅
Underline ++underline++ ✅
Highlight ==highlight== ✅
Superscript ^super^ ✅
Subscript ~sub~ ✅
Widget Placeholders {key} ✅ Renders embedded WidgetSpans seamlessly

Block Elements

Block elements are tightly restricted. Only elements that are forward-parsable on a single line are supported.

Feature Syntax / Example Status Notes
ATX Headings # H1 to ###### H6 ✅ Up to 3 leading spaces, respects trailing #
Thematic Breaks ---, ***, or ___ ✅ Renders as a full-width divider; customizable via TextfOptions.thematicBreakBuilder
Blockquotes > quote text 🚧 Will be visually stylized (e.g., italics + color) rather than using a left-margin border
Lists (Unordered) - item or * item ❌ TextSpan does not support hanging indents
Lists (Ordered) 1. item ❌ TextSpan does not support hanging indents
Fenced Code Blocks ```\ncode\n``` ❌ TextSpan backgrounds cannot be padded
Indented Code Blocks code (4 spaces) ❌ Whitespace tracking is ambiguous in edit mode
Setext Headings Heading\n=== ❌ Requires look-behind / retroactive restyling
Tables ` A B

When to Use Textf

Textf is inline-first: inline formatting plus ATX headings as the one block-level exception. It is not a full Markdown renderer.

✅ Great for:

  • Chat messages and comment sections
  • UI labels, captions, and tooltips
  • Internationalized strings with inline emphasis
  • User-generated content with simple formatting
  • Performance-critical lists with many text widgets

❌ Not designed for:

  • Full Markdown documents with lists, tables, or blockquotes
  • HTML rendering
  • Block-level structure beyond ATX headings

Formatting Markers

Both Textf and TextfEditingController use the same syntax:

Format Syntax Alternate Result
Bold **bold** __bold__ bold
Italic *italic* _italic_ italic
Bold + Italic ***bold italic*** ___bold italic___ both
Strikethrough ~~strike~~ strikethrough
Underline ++underline++
Highlight ==highlight== highlight
Inline code `code` code
Superscript ^super^ E = mc²
Subscript ~sub~ H₂O
Link [label](url) Flutter
Placeholder {key} (inserted widget)

Flanking Rules

Formatting markers follow CommonMark-style flanking rules. Openers must not be followed by whitespace, and closers must not be preceded by whitespace:

*italic*    ✅    * italic *   ❌
**bold**    ✅    ** bold **   ❌

This prevents accidental formatting of bullet points (* Item) and math expressions (2 * 3).

Nesting

Up to 2 levels of nesting are supported. A third level renders as plain text — it never crashes or corrupts the surrounding output.

Textf('**Bold with _italic_ inside.**')   // ✅ two levels — works
Textf('**_`three levels`_**')             // ⚠️ third level renders as literal `three levels`

Malformed or Unclosed Markers

Textf is forgiving. If a marker has no matching closer, it renders as plain text — it never crashes, and the rest of the string continues to format normally.

Textf('**unclosed and *italic*')
// renders: **unclosed and italic  (italic still applies correctly)

Escaping

Use a backslash to render any marker literally. You can escape formatting markers as well as placeholders:

Textf(r'\**not bold\** and \{not_a_placeholder}')
// renders: **not bold** and {not_a_placeholder}

Textf Widget

A drop-in replacement for Flutter's Text widget. All Text parameters are supported identically — style, textAlign, maxLines, overflow, textScaler, locale, textDirection, strutStyle, semanticsLabel, and more.

Basic Usage

Textf(
  '**Bold**, *italic*, ~~strike~~, ++underline++, ==highlight==, '
  '`code`, ^super^, ~sub~, [link](https://flutter.cn)',
  style: TextStyle(fontSize: 16),
  textAlign: TextAlign.center,
  maxLines: 3,
  overflow: TextOverflow.ellipsis,
)

String Extensions

The .textf() extension lets you write formatting inline wherever you'd naturally write a string — useful in widget trees, i18n, and ARB-based localization:

// Directly in a widget tree
'**Status:** All systems operational'.textf()

// With style parameters
'Hello, **$username**!'.textf(style: TextStyle(fontSize: 18))

// From a localized string
AppLocalizations.of(context).welcomeMessage.textf()

All Textf constructor parameters are available on .textf().

To extract clean, plain text from a formatted string (e.g., for search, analytics, or Semantics labels), use .stripFormatting():

'**Hello** [Flutter](.)!'.stripFormatting() // Returns: "Hello Flutter!"

Widget Placeholders

Embed arbitrary Flutter widgets inline using {key} syntax:

Textf(
  'Made with {heart} using {flutter}',
  placeholders: {
    'heart': WidgetSpan(child: Icon(Icons.favorite, color: Colors.red)),
    'flutter': WidgetSpan(child: FlutterLogo(size: 16)),
  },
)

Keys must be alphanumeric or underscores. Placeholders are not substituted in TextfEditingController — they render as literal {key} text there.

Links are rendered as tappable WidgetSpan elements. Handle taps by wrapping with TextfOptions (see TextfOptions for full configuration):

TextfOptions(
  onLinkTap: (url, displayText) {
    // Open in browser, push a route, or handle internally
    debugPrint('Tapped: $url');
  },
  child: Textf('Visit [Flutter](https://flutter.cn)'),
)

Note: Because links are WidgetSpan elements, text selection cannot span across them. This is a Flutter platform limitation, not a Textf bug.

SelectionArea Support

SelectionArea(
  child: Textf('Select **this** formatted text!'),
)

Performance

Textf caches parsed span trees using an LRU cache. Re-renders skip re-parsing when the text, the effective root style (the ambient DefaultTextStyle merged with style), the text scaler and TextfOptions are unchanged — important for animated lists or chat feeds with many items. The cache invalidates automatically on changes. Textf doesn't read the theme, so a theme change re-parses only if it changes the ambient text style (for example its color in dark mode).

To free memory in low-memory situations:

Textf.clearCache();

TextfEditingController

A drop-in replacement for TextEditingController. Attach it to any TextField or TextFormField to render live formatting as the user types. The underlying text is always plain — the controller adds visual styling on top without affecting the stored value. Supports full IME (Input Method Editor) composing for seamless text entry in all languages.

Limitations

Before building with this controller, be aware of the following constraints:

  • Widget placeholders ({key}) render as literal text — no widget substitution in editable fields
  • Links display the full [text](url) syntax while editing — styled, but not tappable
  • Cross-line markers never pair across newlines — a marker on line 1 cannot accidentally format content on line 2

Basic Usage

final controller = TextfEditingController();

TextField(controller: controller)

For headings in editable fields, disable forced strut height so bigger spans can own their line height:

final style = Theme.of(context).textTheme.bodyLarge!;

TextField(
  controller: controller,
  style: style,
  strutStyle: StrutStyle.fromTextStyle(style, forceStrutHeight: false),
)

With initial content:

TextfEditingController(text: 'Hello **bold**')

Marker Visibility

MarkerVisibility controls how formatting markers appear while the user edits.

MarkerVisibility.always (default) — markers are always visible with dimmed styling. Predictable cursor behavior, works well on all platforms.

MarkerVisibility.whenActive — markers hide instantly when the cursor leaves the formatted span, giving a cleaner live-preview effect. During non-collapsed selection (e.g. drag-select on mobile), all markers hide automatically to prevent layout jumps that would shift selection handles.

TextfEditingController(markerVisibility: MarkerVisibility.whenActive)

Change the mode at runtime and the field re-renders immediately:

controller.markerVisibility = MarkerVisibility.always;

Large Text Protection

When text exceeds maxLiveFormattingLength characters, formatting is automatically disabled and the field renders as plain text. This prevents UI freezes on very long inputs.

TextfEditingController(maxLiveFormattingLength: 2500) // default: 5000

Custom Styles

Wrap the TextField with TextfOptions to control how formatted spans appear:

TextfOptions(
  boldStyle: TextStyle(fontWeight: FontWeight.w900, color: Colors.deepOrange),
  codeStyle: TextStyle(fontFamily: 'monospace', color: Colors.pink),
  child: TextField(
    controller: TextfEditingController(),
    decoration: InputDecoration(labelText: 'Formatted input'),
  ),
)

TextfOptions

TextfOptions is an InheritedWidget that configures all descendant Textf widgets and TextfEditingController instances. Place it once near the top of a screen — or at app level — to apply consistent formatting throughout.

TextfOptions(
  boldStyle: TextStyle(fontWeight: FontWeight.w900, color: Colors.deepOrange),
  codeStyle: TextStyle(fontFamily: 'monospace', color: Colors.pink),
  onLinkTap: (url, _) => debugPrint('Link tapped: $url'),
  child: YourWidget(),
)

TextfOptions screenshot

Style Options

Property Applies to
boldStyle **bold** / __bold__
italicStyle *italic* / _italic_
boldItalicStyle ***bold italic***
strikethroughStyle ~~strike~~
underlineStyle ++underline++
highlightStyle ==highlight==
codeStyle `code`
superscriptStyle ^super^
subscriptStyle ~sub~
linkStyle Links — normal state
linkHoverStyle Links — hover state
h1Style–h6Style # – ###### headings

A style option replaces the built-in style for its marker (for example, a boldStyle without a fontWeight is not bold). Heading and script styles are the exception: they merge onto the built-in heading size and weight or the script size, so h1Style: TextStyle(color: …) keeps the heading size. To change only a color and keep the built-in typography (the link underline, the monospace code font), use a color option instead.

Property Type / Description
onLinkTap (String url, String displayText) → void
onLinkHover (String url, String displayText, {required bool isHovering}) → void
linkMouseCursor MouseCursor — shown over links (default: SystemMouseCursors.click)
linkAlignment PlaceholderAlignment — vertical alignment of link spans (default: baseline)

Script Options

Property Description Default
scriptFontSizeFactor Font size multiplier for super/subscripts 0.6
superscriptBaselineFactor Vertical offset factor for superscripts -0.4
subscriptBaselineFactor Vertical offset factor for subscripts 0.2

How Inheritance Works

TextfOptions uses two different strategies depending on the property type.

Style properties merge down the tree. A parent's color and a child's font weight both apply — neither is discarded. This mirrors how TextStyle.merge works across DefaultTextStyle in Flutter, and means you can define broad styles at a high level and refine them locally without losing the parent context.

TextfOptions(
  boldStyle: TextStyle(color: Colors.red),              // parent: red color
  child: TextfOptions(
    boldStyle: TextStyle(fontWeight: FontWeight.w900),  // child: heavy weight
    child: Textf('**Red AND heavy**'),                  // both apply ✅
  ),
)

Callback, cursor and color properties use nearest-ancestor-wins. The closest TextfOptions in the tree that sets the property takes effect. This prevents double-firing when options are nested — only one handler should respond to a tap.

TextfOptions(
  onLinkTap: (url, _) => debugPrint('root handler'),
  child: TextfOptions(
    onLinkTap: (url, _) => debugPrint('inner handler'), // this one wins
    child: Textf('[tap me](https://example.com)'),
  ),
)

Theming

Textf is design-system-neutral: it never reads Theme, CupertinoTheme or any other design-system theme. Its built-in colors come from the text they render in, so Textf looks and behaves the same under SDK Material, material_ui, Cupertino, cupertino_ui, a bare WidgetsApp or your own design system. The package imports only Flutter's core layers (widgets, painting, gestures, foundation).

Built-in Defaults

Every default derives from the effective root style, which Textf computes exactly like Text computes its effective text style: the ambient DefaultTextStyle merged with Textf.style (or Textf.style alone when its inherit is false), made bold when MediaQuery.boldTextOf is set. Headings and super/subscripts scale from its font size.

Element Default
Link Fixed #1A73E8, underlined in the same color
Code background The text color at 5% opacity on light surfaces, 15% on dark surfaces
Code text The surrounding text color (so code inside a link is link-blue), monospace font
Highlight Translucent yellow: #FFEB3B at 50% on light surfaces, #FBC02D at 40% on dark ones
Thematic break A 1px full-width rule in the text color at 20% opacity
Editing markers The field's text color (black if it has none) at 40% opacity, in TextfEditingController

Textf never sees the background. It assumes a dark surface when the text color is light, and a light surface otherwise. On mid-tone or gradient backgrounds, where that guess can be wrong, set codeBackgroundColor and highlightColor explicitly.

The link blue does not depend on the surface at all. It has a contrast of 4.51:1 on white and ≈ 4.16:1 on a dark #121212 surface, and links are always underlined, so they never rely on color alone. If your dark surfaces need a 4.5:1 link color, set linkColor.

Color Options

Color options tint a built-in default and keep everything else about it, such as the link underline or the monospace font stack. That is the difference from the matching style option, which replaces the default completely.

Property Colors Overridden by
linkColor Link text and its underline linkStyle
codeBackgroundColor The background behind `code` codeStyle
highlightColor The background behind ==highlight== highlightStyle
thematicBreakColor The default --- rule thematicBreakBuilder
TextfOptions(
  linkColor: Color(0xFF00796B),
  codeBackgroundColor: Color(0x1A00796B),
  child: Textf('A [brand-colored](https://example.com) link and `code`.'),
)

Colors are applied verbatim, alpha included. Like callbacks, a color option is taken from the nearest TextfOptions that sets it.

Resolution Precedence

For each formatted segment, the first of these that applies wins:

  1. Style option (linkStyle, codeStyle, highlightStyle, …): replaces the built-in style and is merged onto the surrounding text style. Heading and script style options merge onto their built-in sizes instead.
  2. Color option (linkColor, codeBackgroundColor, highlightColor): the built-in style, in that color.
  3. Neutral default: derived from the effective root style, as in the table above.
  4. Relative default: the typographic fallbacks, such as bold weight, italic, and script and heading size factors.

For thematic breaks the order is: thematicBreakBuilder, then thematicBreakColor, then the text color at 20% opacity.

Using Your App's Theme Colors

To give Textf your theme's colors, as 1.x did automatically, pass them down as color options once, in MaterialApp.builder:

import 'package:material_ui/material_ui.dart';
import 'package:textf/textf.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorSchemeSeed: Colors.teal),
      darkTheme: ThemeData(colorSchemeSeed: Colors.teal, brightness: Brightness.dark),
      // `builder` runs below the theme, so the colors follow scheme and light/dark changes.
      builder: (context, child) {
        final theme = Theme.of(context);
        return TextfOptions(
          linkColor: theme.colorScheme.primary,
          codeBackgroundColor: theme.colorScheme.surfaceContainer,
          thematicBreakColor: theme.dividerColor,
          child: child ?? const SizedBox.shrink(),
        );
      },
      home: const Scaffold(
        body: Center(
          child: Textf('A [link](https://example.com), `code` and a ==highlight==.'),
        ),
      ),
    );
  }
}

Using material_ui? Import Theme from package:material_ui/material_ui.dart in that file, not from package:flutter/material.dart. The two libraries define different Theme classes. A lookup through the wrong one silently returns ThemeData.fallback() instead of your theme (flutter#192920). Textf itself is immune because it never looks up a theme.

Verified recipes for each environment, with tests, live in the repository:

Coming from 1.x? The one remaining difference is code text: 1.x drew it in colorScheme.onSurfaceVariant, and 2.0 always uses the surrounding text color. To restore it, set a full codeStyle instead of codeBackgroundColor (a style option replaces the whole default, so include the font family and background).


Accessibility

  • Text Scaling — Respects MediaQuery.textScalerOf(context) and system font scaling settings
  • Bold Text — Honors the system bold-text setting (MediaQuery.boldTextOf), like Text
  • Link Contrast — Default links are underlined and use #1A73E8 (4.51:1 on white); see Theming
  • Screen Readers — Links are wrapped in Semantics(link: true) for TalkBack and VoiceOver
  • RTL Support — Bidirectional text and RTL languages work correctly throughout

Comparison

Feature Textf Full Markdown Packages
Bundle size Tiny Large
Dependencies Zero Multiple
Parse complexity O(N) Often O(N²) or worse
API familiarity Identical to Text Custom widgets
Live editing ✅ Rarely
Block elements Headings + rules Full
Best for Inline + headings + rules Document rendering

API Reference

Full documentation on pub.flutter-io.cn.

AI Agent Skill

Textf ships with an AI agent skill. Once you have textf as a dependency, run:

dart pub global activate skills
skills get

This installs Textf's skill into your project, giving AI coding agents (Claude Code, Cursor, Cline, and others) full knowledge of the API, formatting syntax, and best practices — enabling accurate, idiomatic suggestions without needing to read the docs.


Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT License — see LICENSE for details.


About the name: Textf is inspired by C's printf (print formatted). Textf (Text formatted) brings the same idea to Flutter — simple, efficient, and unsurprising.

Libraries

textf
A lightweight text widget library for simple inline Markdown-like formatting.