import 'package:flutter/material.dart'; class AnsiTextParser { // Matches CSI (Control Sequence Introducer) sequences: ESC [ ... FinalByte // And OSC (Operating System Command) sequences: ESC ] ... BEL(\x07) or ST(\x1B\) static final RegExp _ansiRegex = RegExp(r'\x1B\[[0-9;?]*[ -/]*[@-~]|\x1B\].*?(\x07|\x1B\\)'); static String strip(String text) { // Remove ANSI codes var clean = text.replaceAll(_ansiRegex, ''); // Remove control chars (same regex as in parse) clean = clean.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), ''); return clean; } TextSpan parse(String text) { final List spans = []; final matches = _ansiRegex.allMatches(text); int currentIndex = 0; TextStyle currentStyle = const TextStyle(color: Colors.white, fontFamily: 'monospace'); for (final match in matches) { if (match.start > currentIndex) { final plainText = text.substring(currentIndex, match.start); // Clean up common non-printable control chars from the plain text segment // \x07 (Bell), \x08 (Backspace - simple strip, handling logic is complex for stream) final cleanText = plainText.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), ''); if (cleanText.isNotEmpty) { spans.add(TextSpan( text: cleanText, style: currentStyle, )); } } final String sequence = match.group(0)!; // We only process SGR (Select Graphic Rendition) which ends in 'm' // All other sequences (cursor movement, clear screen, etc.) are stripped. if (sequence.endsWith('m')) { currentStyle = _parseSgrSequence(sequence, currentStyle); } currentIndex = match.end; } if (currentIndex < text.length) { final plainText = text.substring(currentIndex); final cleanText = plainText.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), ''); if (cleanText.isNotEmpty) { spans.add(TextSpan( text: cleanText, style: currentStyle, )); } } return TextSpan(children: spans); } TextStyle _parseSgrSequence(String sequence, TextStyle currentStyle) { // Remove \x1B[ and m final content = sequence.substring(2, sequence.length - 1); final parts = content.split(';'); TextStyle newStyle = currentStyle; if (parts.isEmpty || (parts.length == 1 && parts.first.isEmpty)) { // Empty usually means reset 0 return const TextStyle(color: Colors.white, fontFamily: 'monospace'); } for (final part in parts) { final int? value = int.tryParse(part); if (value == null) continue; if (value == 0) { newStyle = const TextStyle(color: Colors.white, fontFamily: 'monospace'); } else if (value == 1) { newStyle = newStyle.copyWith(fontWeight: FontWeight.bold); } else if (value >= 30 && value <= 37) { newStyle = newStyle.copyWith(color: _getAnsiColor(value)); } else if (value >= 90 && value <= 97) { newStyle = newStyle.copyWith(color: _getAnsiColor(value)); } // Add background colors or other styles if needed } return newStyle; } Color _getAnsiColor(int code) { switch (code) { case 30: return Colors.black; case 31: return Colors.red; case 32: return Colors.green; case 33: return Colors.yellow; case 34: return Colors.blue; case 35: return Colors.purple; case 36: return Colors.cyan; case 37: return Colors.white; case 90: return Colors.grey; case 91: return Colors.redAccent; case 92: return Colors.greenAccent; case 93: return Colors.yellowAccent; case 94: return Colors.blueAccent; case 95: return Colors.purpleAccent; case 96: return Colors.cyanAccent; case 97: return Colors.white; default: return Colors.white; } } }