Ashlar UI Ashlar UI ashlarui

Markdown

Formatted text, drawn as it streams in: headings, lists, tables, quotes, links, and code with a copy button.

Markdown, as it looks in an app Markdown, as it looks in an app, dark
// Markdown, as an answer arrives: what the page's picture shows and what runs live.
import 'package:flutter/widgets.dart';

import 'ui/markdown.dart';

const _answer = """
## Moving a table to its own schema

You can do it **without downtime**, in three steps. The one to watch is the *second*:
writes go to both tables until the copy catches up.

1. Make the new table and copy the rows across.
2. Write to both, then backfill what changed.
3. Read from the new one, and drop the old.

```sql
alter table orders set schema billing;
```

| Step | Locks | Time on 2M rows |
|---|---|---:|
| Copy | none | 4 min |
| Switch | brief | 1 s |

> Take a backup first: `pg_dump -t orders`.

- [x] Backup taken
- [ ] Copy started""";

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 560,
    child: Markdown(_answer, streaming: true, onLink: (_) {}),
  );
}

Add it

In your Flutter project:

$ ashlar add markdown

It writes theme.dart, palette.dart, group.dart, pressable.dart and markdown.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable and ashlar_icons, which come with it.

API

Read from the source above: each parameter with its type, its default, and what it is for.

MarkdownSizeenum

How large markdown is drawn.

ValueWhat it is for
chatFor messages: body text small, headings a step or two above it.
document For a page: body text at its normal size, headings as a document has them.

Markdownclass

Markdown, drawn.

Parameter Type Default What it is for
datapositional String The text, in markdown.
streaming bool false Still arriving: a caret blinks at its end.
size MarkdownSize MarkdownSize.chat
onLink ValueChanged<String>? Called with a link's address when it is tapped; links are drawn as text without it.
codeBuilder Widget Function(BuildContext context, String code, String? language)? Draws a code block your way (to colour its words, say); null draws it plainly.

MdBlockclass

A block of markdown: what parseMarkdown reads text into.

MdParagraphclass

Parameter Type Default What it is for
textpositional String

MdHeadingclass

Parameter Type Default What it is for
levelpositional int
textpositional String

MdCodeclass

Parameter Type Default What it is for
codepositional String
languagepositional String?
open bool false Not yet closed: more is coming.

MdQuoteclass

Parameter Type Default What it is for
childrenpositional List<MdBlock>

MdListclass

Parameter Type Default What it is for
itemspositional List<MdItem>
orderedboolfalse
startint1

MdItemclass

One point of a list: its blocks, and whether its box is ticked, when it has one.

Parameter Type Default What it is for
childrenpositional List<MdBlock>
checkedbool?

MdTableclass

Parameter Type Default What it is for
headpositional List<String>
rowspositional List<List<String>>
alignpositional List<int> Each column's alignment: -1 start, 0 centre, 1 end.

MdRuleclass

MdImageclass

Parameter Type Default What it is for
urlpositional String
altpositional String

parseMarkdownfunction

Reads text into blocks. What is not closed at the end is taken as closed there.

Parameter Type Default What it is for
textpositional String

MdSpanclass

A run of inline text with its marks.

Parameter Type Default What it is for
textpositional String
boldboolfalse
italicboolfalse
strikeboolfalse
codeboolfalse
linkString?

parseInlinefunction

Reads a paragraph's text into marked runs. A mark not closed is taken to run to the end, as when the text is still arriving.

Parameter Type Default What it is for
textpositional String

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/markdown.dart 980 lines
// Ashlar UI: Markdown. Copied into your app by `ashlar add markdown`; it is yours to change.
//
// Formatted text as models and people write it: headings, paragraphs, bold, italic and
// struck words, links, lists (nested, and with boxes to tick), quotes, tables, rules,
// pictures, and code, inline and in blocks with their language and a copy button. Read
// here, with no package. It draws text that is still arriving as well as text that is
// whole: a code block or a ** not yet closed is drawn as if it were, so nothing jumps when
// the rest comes, and [Markdown.streaming] shows a caret at the end.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'pressable.dart';
import 'theme.dart';

/// How large markdown is drawn.
enum MarkdownSize {
  /// For messages: body text small, headings a step or two above it.
  chat,

  /// For a page: body text at its normal size, headings as a document has them.
  document,
}

/// Markdown, drawn.
///
/// ```dart
/// Markdown(answer, streaming: !done, onLink: (url) => launch(url))
/// ```
class Markdown extends StatelessWidget {
  const Markdown(
    this.data, {
    super.key,
    this.streaming = false,
    this.size = MarkdownSize.chat,
    this.onLink,
    this.codeBuilder,
  });

  /// The text, in markdown.
  final String data;

  /// Still arriving: a caret blinks at its end.
  final bool streaming;
  final MarkdownSize size;

  /// Called with a link's address when it is tapped; links are drawn as text without it.
  final ValueChanged<String>? onLink;

  /// Draws a code block your way (to colour its words, say); null draws it plainly.
  final Widget Function(BuildContext context, String code, String? language)?
  codeBuilder;

  @override
  Widget build(BuildContext context) {
    final blocks = parseMarkdown(data);
    final out = <Widget>[];
    for (final (i, b) in blocks.indexed) {
      if (i > 0) out.add(SizedBox(height: _gap(b)));
      out.add(
        _BlockView(
          block: b,
          md: this,
          caret: streaming && i == blocks.length - 1,
        ),
      );
    }
    if (streaming && blocks.isEmpty) out.add(const _Caret());
    return Semantics(
      container: true,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: out,
      ),
    );
  }

  double _gap(MdBlock b) => switch (b) {
    MdHeading() => size == MarkdownSize.chat ? 16 : 28,
    _ => size == MarkdownSize.chat ? 10 : 16,
  };
}

// ---------------------------------------------------------------------------------------
// Reading markdown
// ---------------------------------------------------------------------------------------

/// A block of markdown: what [parseMarkdown] reads text into.
sealed class MdBlock {
  const MdBlock();
}

class MdParagraph extends MdBlock {
  const MdParagraph(this.text);
  final String text;
}

class MdHeading extends MdBlock {
  const MdHeading(this.level, this.text);
  final int level;
  final String text;
}

class MdCode extends MdBlock {
  const MdCode(this.code, this.language, {this.open = false});
  final String code;
  final String? language;

  /// Not yet closed: more is coming.
  final bool open;
}

class MdQuote extends MdBlock {
  const MdQuote(this.children);
  final List<MdBlock> children;
}

class MdList extends MdBlock {
  const MdList(this.items, {this.ordered = false, this.start = 1});
  final List<MdItem> items;
  final bool ordered;
  final int start;
}

/// One point of a list: its blocks, and whether its box is ticked, when it has one.
class MdItem {
  const MdItem(this.children, {this.checked});
  final List<MdBlock> children;
  final bool? checked;
}

class MdTable extends MdBlock {
  const MdTable(this.head, this.rows, this.align);
  final List<String> head;
  final List<List<String>> rows;

  /// Each column's alignment: -1 start, 0 centre, 1 end.
  final List<int> align;
}

class MdRule extends MdBlock {
  const MdRule();
}

class MdImage extends MdBlock {
  const MdImage(this.url, this.alt);
  final String url, alt;
}

final _fence = RegExp(r'^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)');
final _heading = RegExp(r'^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$');
final _rule = RegExp(r'^ {0,3}([-*_])(\s*\1){2,}\s*$');
final _bullet = RegExp(r'^( *)([-*+])\s+(.*)$');
final _numbered = RegExp(r'^( *)(\d{1,9})[.)]\s+(.*)$');
final _image = RegExp(r'^\s*!\[([^\]]*)\]\(([^)\s]+)[^)]*\)\s*$');
final _tableSep = RegExp(r'^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$');

/// Reads [text] into blocks. What is not closed at the end is taken as closed there.
List<MdBlock> parseMarkdown(String text) =>
    _blocks(text.replaceAll('\r\n', '\n').split('\n'));

List<MdBlock> _blocks(List<String> lines) {
  final out = <MdBlock>[];
  var i = 0;
  bool blank(String l) => l.trim().isEmpty;
  bool starts(String l) =>
      _fence.hasMatch(l) ||
      _heading.hasMatch(l) ||
      _rule.hasMatch(l) ||
      l.trimLeft().startsWith('>') ||
      _bullet.hasMatch(l) ||
      _numbered.hasMatch(l);
  while (i < lines.length) {
    final l = lines[i];
    if (blank(l)) {
      i++;
      continue;
    }
    if (_fence.firstMatch(l) case final m?) {
      final mark = m[1]!;
      final lang = m[2]!.isEmpty ? null : m[2];
      final code = <String>[];
      i++;
      var closed = false;
      while (i < lines.length) {
        if (lines[i].trimLeft().startsWith(mark[0] * mark.length) &&
            lines[i].trim().replaceAll(mark[0], '').isEmpty) {
          closed = true;
          i++;
          break;
        }
        code.add(lines[i]);
        i++;
      }
      out.add(MdCode(code.join('\n'), lang, open: !closed));
      continue;
    }
    if (_heading.firstMatch(l) case final m?) {
      out.add(MdHeading(m[1]!.length, m[2]!));
      i++;
      continue;
    }
    if (_rule.hasMatch(l)) {
      out.add(const MdRule());
      i++;
      continue;
    }
    if (_image.firstMatch(l) case final m?) {
      out.add(MdImage(m[2]!, m[1]!));
      i++;
      continue;
    }
    if (l.trimLeft().startsWith('>')) {
      final inner = <String>[];
      while (i < lines.length && lines[i].trimLeft().startsWith('>')) {
        inner.add(
          lines[i].trimLeft().substring(1).replaceFirst(RegExp('^ '), ''),
        );
        i++;
      }
      out.add(MdQuote(_blocks(inner)));
      continue;
    }
    if (_bullet.hasMatch(l) || _numbered.hasMatch(l)) {
      final ordered = !_bullet.hasMatch(l);
      final first = (ordered ? _numbered : _bullet).firstMatch(l)!;
      final indent = first[1]!.length;
      final items = <List<String>>[];
      while (i < lines.length) {
        final line = lines[i];
        final m = (ordered ? _numbered : _bullet).firstMatch(line);
        if (m != null && m[1]!.length == indent) {
          items.add([m[3]!]);
          i++;
          continue;
        }
        // A line indented under the item, or a blank line inside the list, belongs to it.
        if (blank(line)) {
          final next = i + 1 < lines.length ? lines[i + 1] : '';
          if (next.startsWith(' ' * (indent + 2)) ||
              ((ordered ? _numbered : _bullet).firstMatch(next)?[1]?.length ==
                  indent)) {
            items.last.add('');
            i++;
            continue;
          }
          break;
        }
        if (line.startsWith(' ' * (indent + 1)) ||
            (!starts(line) && items.isNotEmpty)) {
          items.last.add(
            line.length > indent + 2 && line.startsWith(' ' * (indent + 2))
                ? line.substring(indent + 2)
                : line.trimLeft(),
          );
          i++;
          continue;
        }
        break;
      }
      out.add(
        MdList(
          [
            for (final it in items)
              () {
                var head = it.first;
                bool? checked;
                final box = RegExp(r'^\[( |x|X)\]\s+').firstMatch(head);
                if (box != null) {
                  checked = box[1] != ' ';
                  head = head.substring(box.end);
                }
                return MdItem(_blocks([head, ...it.skip(1)]), checked: checked);
              }(),
          ],
          ordered: ordered,
          start: ordered ? int.parse(first[2]!) : 1,
        ),
      );
      continue;
    }
    if (l.contains('|') &&
        i + 1 < lines.length &&
        _tableSep.hasMatch(lines[i + 1])) {
      List<String> cells(String row) {
        var r = row.trim();
        if (r.startsWith('|')) r = r.substring(1);
        if (r.endsWith('|') && !r.endsWith(r'\|')) {
          r = r.substring(0, r.length - 1);
        }
        return [
          for (final c in r.split(RegExp(r'(?<!\\)\|')))
            c.trim().replaceAll(r'\|', '|'),
        ];
      }

      final head = cells(l);
      final align = [
        for (final c in cells(lines[i + 1]))
          c.startsWith(':') && c.endsWith(':')
              ? 0
              : c.endsWith(':')
              ? 1
              : -1,
      ];
      i += 2;
      final rows = <List<String>>[];
      while (i < lines.length && !blank(lines[i]) && lines[i].contains('|')) {
        rows.add(cells(lines[i]));
        i++;
      }
      out.add(MdTable(head, rows, align));
      continue;
    }
    // A paragraph: lines until a blank or the start of something else.
    final para = <String>[l];
    i++;
    while (i < lines.length && !blank(lines[i]) && !starts(lines[i])) {
      para.add(lines[i]);
      i++;
    }
    out.add(MdParagraph(para.join('\n')));
  }
  return out;
}

/// A run of inline text with its marks.
class MdSpan {
  const MdSpan(
    this.text, {
    this.bold = false,
    this.italic = false,
    this.strike = false,
    this.code = false,
    this.link,
  });

  final String text;
  final bool bold, italic, strike, code;
  final String? link;

  MdSpan mark({bool? bold, bool? italic, bool? strike, String? link}) => MdSpan(
    text,
    bold: bold ?? this.bold,
    italic: italic ?? this.italic,
    strike: strike ?? this.strike,
    code: code,
    link: link ?? this.link,
  );
}

const _emphasis = ['**', '__', '~~', '*', '_'];
final _word = RegExp(r'\w');
final _autolink = RegExp(r'^<?(https?://[^\s<>]*[^\s<>.,;:!?)\]])>?');

/// Reads a paragraph's [text] into marked runs. A mark not closed is taken to run to the
/// end, as when the text is still arriving.
List<MdSpan> parseInline(String text) {
  final out = <MdSpan>[];
  final plain = StringBuffer();
  void flush() {
    if (plain.isNotEmpty) {
      out.add(MdSpan(plain.toString()));
      plain.clear();
    }
  }

  var i = 0;
  while (i < text.length) {
    final ch = text[i];
    final rest = text.substring(i);
    // An escaped character is itself.
    if (ch == r'\' &&
        i + 1 < text.length &&
        r'\`*_{}[]()#+-.!|~<>'.contains(text[i + 1])) {
      plain.write(text[i + 1]);
      i += 2;
      continue;
    }
    // Two spaces or a backslash at a line's end: a line break; else a newline is a space.
    if (ch == '\n') {
      final s = plain.toString();
      if (s.endsWith('  ') || s.endsWith(r'\')) {
        plain
          ..clear()
          ..write(s.replaceFirst(RegExp(r'( {2,}|\\)$'), ''))
          ..write('\n');
      } else {
        plain.write(' ');
      }
      i++;
      continue;
    }
    if (ch == '`') {
      final ticks = RegExp('^`+').firstMatch(rest)![0]!;
      final end = text.indexOf(ticks, i + ticks.length);
      flush();
      final inner = end < 0
          ? text.substring(i + ticks.length)
          : text.substring(i + ticks.length, end);
      out.add(MdSpan(inner.trim().isEmpty ? inner : inner.trim(), code: true));
      i = end < 0 ? text.length : end + ticks.length;
      continue;
    }
    if (ch == '[') {
      final m = RegExp(
        r'^\[((?:[^\[\]]|\[[^\]]*\])*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)',
      ).firstMatch(rest);
      if (m != null) {
        flush();
        out.addAll([for (final s in parseInline(m[1]!)) s.mark(link: m[2])]);
        i += m[0]!.length;
        continue;
      }
    }
    if (ch == '!' && rest.startsWith('![')) {
      final m = RegExp(r'^!\[([^\]]*)\]\(([^)\s]*)[^)]*\)').firstMatch(rest);
      if (m != null) {
        flush();
        out.add(MdSpan(m[1]!.isEmpty ? m[2]! : m[1]!, link: m[2]));
        i += m[0]!.length;
        continue;
      }
    }
    if ((ch == 'h' || ch == '<') && (i == 0 || !_word.hasMatch(text[i - 1]))) {
      final m = _autolink.firstMatch(rest);
      if (m != null) {
        flush();
        out.add(MdSpan(m[1]!, link: m[1]));
        i += m[0]!.length;
        continue;
      }
    }
    // Emphasis: ** and __ bold, * and _ italic, ~~ struck. _ inside a word is a letter.
    final mark = _emphasis.firstWhere(rest.startsWith, orElse: () => '');
    final after = i + mark.length;
    if (mark.isNotEmpty &&
        after < text.length &&
        text[after] != ' ' &&
        !(mark[0] == '_' && i > 0 && _word.hasMatch(text[i - 1]))) {
      var end = text.indexOf(mark, after);
      // A single * must not close on the first of a **.
      while (end > 0 &&
          mark.length == 1 &&
          end + 1 < text.length &&
          text[end + 1] == mark) {
        end = text.indexOf(mark, end + 2);
      }
      flush();
      final inner = end < 0
          ? text.substring(after)
          : text.substring(after, end);
      out.addAll([
        for (final s in parseInline(inner))
          s.mark(
            bold: mark.length == 2 && mark != '~~' ? true : null,
            italic: mark.length == 1 ? true : null,
            strike: mark == '~~' ? true : null,
          ),
      ]);
      i = end < 0 ? text.length : end + mark.length;
      continue;
    }
    plain.write(ch);
    i++;
  }
  flush();
  return out;
}

// ---------------------------------------------------------------------------------------
// Drawing it
// ---------------------------------------------------------------------------------------

class _Styles {
  _Styles(BuildContext context, MarkdownSize size)
    : t = AshlarTheme.of(context),
      chat = size == MarkdownSize.chat,
      quoted = context.dependOnInheritedWidgetOfExactType<_Quoted>() != null;

  final AshlarThemeData t;
  final bool chat;

  /// Inside a quote: drawn quieter.
  final bool quoted;

  TextStyle get body => (chat ? t.text.sm : t.text.base).copyWith(
    color: quoted ? t.colors.mutedForeground : t.colors.foreground,
    height: chat ? 22 / 14 : 28 / 16,
  );

  TextStyle heading(int level) {
    final sizes = chat
        ? <double>[20, 18, 16, 15, 14, 14]
        : <double>[30, 24, 20, 18, 16, 16];
    return body.copyWith(
      fontSize: sizes[level - 1],
      height: 1.3,
      fontWeight: FontWeight.w600,
      letterSpacing: level <= 2 ? -.4 : -.2,
    );
  }

  TextStyle get mono => body.copyWith(
    fontFamily: 'monospace',
    fontFamilyFallback: const ['Menlo', 'Consolas', 'Roboto Mono', 'monospace'],
    fontSize: (body.fontSize ?? 14) * .92,
  );
}

class _BlockView extends StatelessWidget {
  const _BlockView({required this.block, required this.md, this.caret = false});

  final MdBlock block;
  final Markdown md;
  final bool caret;

  @override
  Widget build(BuildContext context) {
    final s = _Styles(context, md.size);
    final c = s.t.colors;
    switch (block) {
      case MdParagraph(:final text):
        return _Inline(text: text, style: s.body, md: md, caret: caret);
      case MdHeading(:final level, :final text):
        return Semantics(
          container: true,
          header: true,
          child: _Inline(
            text: text,
            style: s.heading(level),
            md: md,
            caret: caret,
          ),
        );
      case MdCode(:final code, :final language):
        final drawn =
            md.codeBuilder?.call(context, code, language) ??
            _CodeBlock(code: code, language: language, style: s.mono);
        return caret
            ? Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [drawn, const SizedBox(height: 6), const _Caret()],
              )
            : drawn;
      case MdQuote(:final children):
        return Container(
          padding: const EdgeInsetsDirectional.only(start: 14),
          decoration: BoxDecoration(
            border: BorderDirectional(
              start: BorderSide(color: c.border, width: 3),
            ),
          ),
          child: _Quoted(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                for (final (i, b) in children.indexed) ...[
                  if (i > 0) const SizedBox(height: 8),
                  _BlockView(
                    block: b,
                    md: md,
                    caret: caret && i == children.length - 1,
                  ),
                ],
              ],
            ),
          ),
        );
      case MdList(:final items, :final ordered, :final start):
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            for (final (i, item) in items.indexed)
              Padding(
                padding: EdgeInsets.only(top: i == 0 ? 0 : 4),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    SizedBox(
                      width: item.checked != null ? 24 : (ordered ? 26 : 18),
                      child: item.checked != null
                          ? Padding(
                              padding: EdgeInsets.only(
                                top:
                                    (s.body.fontSize! * s.body.height! - 14) /
                                    2,
                              ),
                              child: Align(
                                alignment: AlignmentDirectional.topStart,
                                child: _Tick(checked: item.checked!),
                              ),
                            )
                          : Text(
                              ordered ? '${start + i}.' : '•',
                              style: s.body.copyWith(color: c.mutedForeground),
                            ),
                    ),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: [
                          for (final (k, b) in item.children.indexed) ...[
                            if (k > 0) const SizedBox(height: 6),
                            _BlockView(
                              block: b,
                              md: md,
                              caret:
                                  caret &&
                                  i == items.length - 1 &&
                                  k == item.children.length - 1,
                            ),
                          ],
                        ],
                      ),
                    ),
                  ],
                ),
              ),
          ],
        );
      case MdTable(:final head, :final rows, :final align):
        TextAlign alignOf(int k) => switch (k < align.length ? align[k] : -1) {
          0 => TextAlign.center,
          1 => TextAlign.end,
          _ => TextAlign.start,
        };
        Widget cell(String text, int k, {bool header = false}) => Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          child: _Inline(
            text: text,
            style: header
                ? s.body.copyWith(fontWeight: FontWeight.w600)
                : s.body,
            md: md,
            align: alignOf(k),
          ),
        );
        final width = [
          head.length,
          ...rows.map((r) => r.length),
        ].reduce((a, b) => a > b ? a : b);
        return SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: Container(
            decoration: BoxDecoration(
              border: Border.all(color: c.border),
              borderRadius: BorderRadius.circular(s.t.radiusMd),
            ),
            child: Table(
              defaultColumnWidth: const IntrinsicColumnWidth(),
              border: TableBorder(
                horizontalInside: BorderSide(color: c.border),
              ),
              children: [
                TableRow(
                  decoration: BoxDecoration(color: c.muted),
                  children: [
                    for (var k = 0; k < width; k++)
                      cell(k < head.length ? head[k] : '', k, header: true),
                  ],
                ),
                for (final r in rows)
                  TableRow(
                    children: [
                      for (var k = 0; k < width; k++)
                        cell(k < r.length ? r[k] : '', k),
                    ],
                  ),
              ],
            ),
          ),
        );
      case MdRule():
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 4),
          child: Container(height: 1, color: c.border),
        );
      case MdImage(:final url, :final alt):
        return Semantics(
          image: true,
          label: alt,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(s.t.radiusMd),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 320),
              child: Image.network(
                url,
                fit: BoxFit.contain,
                alignment: AlignmentDirectional.centerStart,
                errorBuilder: (context, _, _) =>
                    Text(alt, style: s.body.copyWith(color: c.mutedForeground)),
              ),
            ),
          ),
        );
    }
  }
}

/// A paragraph's runs as one piece of text, links tappable, the caret at its end.
class _Inline extends StatefulWidget {
  const _Inline({
    required this.text,
    required this.style,
    required this.md,
    this.caret = false,
    this.align = TextAlign.start,
  });

  final String text;
  final TextStyle style;
  final Markdown md;
  final bool caret;
  final TextAlign align;

  @override
  State<_Inline> createState() => _InlineState();
}

class _InlineState extends State<_Inline> {
  final _taps = <TapGestureRecognizer>[];

  @override
  void dispose() {
    for (final r in _taps) {
      r.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    for (final r in _taps) {
      r.dispose();
    }
    _taps.clear();
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final s = _Styles(context, widget.md.size);
    final spans = <InlineSpan>[
      for (final run in parseInline(widget.text))
        if (run.code)
          TextSpan(
            text: ' ${run.text} ',
            style: s.mono.copyWith(
              fontSize: (widget.style.fontSize ?? 14) * .9,
              backgroundColor: c.muted,
              fontWeight: FontWeight.w500,
            ),
          )
        else
          TextSpan(
            text: run.text,
            style: TextStyle(
              fontWeight: run.bold ? FontWeight.w600 : null,
              fontStyle: run.italic ? FontStyle.italic : null,
              decoration: TextDecoration.combine([
                if (run.strike) TextDecoration.lineThrough,
                if (run.link != null) TextDecoration.underline,
              ]),
              decorationColor: run.link != null
                  ? c.primary.withValues(alpha: .5)
                  : null,
              color: run.link != null ? c.primary : null,
            ),
            recognizer: run.link != null && widget.md.onLink != null
                ? (TapGestureRecognizer()
                        ..onTap = () => widget.md.onLink!(run.link!))
                      .also(_taps.add)
                : null,
            mouseCursor: run.link != null && widget.md.onLink != null
                ? SystemMouseCursors.click
                : null,
          ),
      if (widget.caret)
        const WidgetSpan(
          alignment: PlaceholderAlignment.middle,
          child: _Caret(),
        ),
    ];
    return Text.rich(
      TextSpan(style: widget.style, children: spans),
      textAlign: widget.align,
    );
  }
}

extension<T> on T {
  T also(void Function(T) f) {
    f(this);
    return this;
  }
}

/// A box a task list ticks.
class _Tick extends StatelessWidget {
  const _Tick({required this.checked});

  final bool checked;

  @override
  Widget build(BuildContext context) {
    final c = AshlarTheme.of(context).colors;
    return Semantics(
      checked: checked,
      child: Container(
        width: 14,
        height: 14,
        decoration: BoxDecoration(
          color: checked ? c.primary : c.background,
          borderRadius: BorderRadius.circular(3),
          border: Border.all(color: checked ? c.primary : c.input),
        ),
        child: checked
            ? AshlarIcon(
                AshlarIconsRoundedStroke.check,
                size: 11,
                color: c.primaryForeground,
              )
            : null,
      ),
    );
  }
}

/// A block of code: its language and a copy button above, the code scrolling along.
class _CodeBlock extends StatefulWidget {
  const _CodeBlock({
    required this.code,
    required this.language,
    required this.style,
  });

  final String code;
  final String? language;
  final TextStyle style;

  @override
  State<_CodeBlock> createState() => _CodeBlockState();
}

class _CodeBlockState extends State<_CodeBlock> {
  bool _copied = false;

  Future<void> _copy() async {
    await Clipboard.setData(ClipboardData(text: widget.code));
    if (!mounted) return;
    setState(() => _copied = true);
    await Future<void>.delayed(const Duration(seconds: 2));
    if (mounted) setState(() => _copied = false);
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Container(
      decoration: BoxDecoration(
        color: c.muted.withValues(alpha: .6),
        borderRadius: BorderRadius.circular(t.radiusLg),
        border: Border.all(color: c.border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Padding(
            padding: const EdgeInsetsDirectional.fromSTEB(14, 6, 6, 0),
            child: Row(
              children: [
                Expanded(
                  child: Text(
                    widget.language ?? '',
                    style: t.text.xs.copyWith(color: c.mutedForeground),
                  ),
                ),
                Pressable(
                  onPressed: _copy,
                  semanticLabel: _copied ? 'Copied' : 'Copy the code',
                  builder: (context, state) => AnimatedContainer(
                    duration: t.duration,
                    padding: const EdgeInsets.all(6),
                    decoration: BoxDecoration(
                      color: state.hovered
                          ? c.accent
                          : c.accent.withValues(alpha: 0),
                      borderRadius: BorderRadius.circular(t.radiusSm),
                      border: Border.all(
                        color: state.focused
                            ? c.ring
                            : c.ring.withValues(alpha: 0),
                      ),
                    ),
                    child: AshlarIcon(
                      _copied
                          ? AshlarIconsRoundedStroke.check
                          : AshlarIconsRoundedStroke.copy,
                      size: 14,
                      color: c.mutedForeground,
                    ),
                  ),
                ),
              ],
            ),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
            child: Text(
              widget.code,
              style: widget.style.copyWith(height: 1.55),
              textDirection: TextDirection.ltr,
            ),
          ),
        ],
      ),
    );
  }
}

/// The caret at the end of text still arriving.
class _Caret extends StatefulWidget {
  const _Caret();

  @override
  State<_Caret> createState() => _CaretState();
}

class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin {
  late final _blink = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1000),
  );

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
      _blink.value = 1;
    } else if (!_blink.isAnimating) {
      _blink.repeat(reverse: true);
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final c = AshlarTheme.of(context).colors;
    return ExcludeSemantics(
      child: FadeTransition(
        opacity: _blink,
        child: Container(
          width: 7,
          height: 14,
          margin: const EdgeInsetsDirectional.only(start: 2),
          decoration: BoxDecoration(
            color: c.foreground,
            borderRadius: BorderRadius.circular(1),
          ),
        ),
      ),
    );
  }
}

/// Marks what is inside a quote, which is drawn quieter.
class _Quoted extends InheritedWidget {
  const _Quoted({required super.child});

  @override
  bool updateShouldNotify(_Quoted oldWidget) => false;
}