Initial commit: Accessible SSH Terminal

This commit is contained in:
2025-12-22 01:02:44 -06:00
commit 7a967ef759
133 changed files with 6629 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:accessible_terminal/state/terminal_provider.dart';
import 'package:accessible_terminal/ui/views/connection_view.dart';
import 'package:accessible_terminal/ui/views/terminal_view.dart';
import 'package:accessible_terminal/ui/views/tablet_screen.dart';
class TerminalScreen extends ConsumerWidget {
const TerminalScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isConnected = ref.watch(connectionStateProvider);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 600) {
// Tablet Layout
return const TabletScreen();
}
// Mobile Layout
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isConnected ? const TerminalView() : const ConnectionView(),
);
},
);
}
}

View File

@@ -0,0 +1,490 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart'; // Ensure SemanticsService is available
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:accessible_terminal/state/terminal_provider.dart';
import 'package:accessible_terminal/state/saved_connections_provider.dart';
import 'package:accessible_terminal/models/saved_connection.dart';
class ConnectionView extends ConsumerStatefulWidget {
const ConnectionView({super.key});
@override
ConsumerState<ConnectionView> createState() => _ConnectionViewState();
}
class _ConnectionViewState extends ConsumerState<ConnectionView> {
// Mode: List or Form
bool _showForm = false;
@override
Widget build(BuildContext context) {
// Listen for global connection errors (e.g. lost connection)
ref.listen<String?>(connectionErrorProvider, (previous, next) {
if (next != null) {
_showErrorDialog(next);
ref.read(connectionErrorProvider.notifier).state = null;
}
});
// Check for existing errors on build (e.g. after navigating back)
WidgetsBinding.instance.addPostFrameCallback((_) {
final error = ref.read(connectionErrorProvider);
if (error != null) {
_showErrorDialog(error);
ref.read(connectionErrorProvider.notifier).state = null;
}
});
if (_showForm) {
return NewConnectionForm(
onCancel: () => setState(() => _showForm = false),
);
}
return SavedConnectionsList(
onCreateNew: () => setState(() => _showForm = true),
);
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Connection Error'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
}
class SavedConnectionsList extends ConsumerWidget {
final VoidCallback onCreateNew;
const SavedConnectionsList({super.key, required this.onCreateNew});
void _showPasswordDialog(BuildContext context, WidgetRef ref, SavedConnection connection) {
final passController = TextEditingController();
final hasKey = connection.privateKey != null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(hasKey
? 'Unlock Key for ${connection.username}@${connection.host}'
: 'Connect to ${connection.username}@${connection.host}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasKey) const Text('Using saved private key.', style: TextStyle(fontStyle: FontStyle.italic)),
const SizedBox(height: 8),
TextField(
controller: passController,
autofocus: true,
obscureText: true,
decoration: InputDecoration(
labelText: hasKey ? 'Passphrase (leave empty if none)' : 'Password',
border: const OutlineInputBorder(),
),
onSubmitted: (_) {
Navigator.pop(context); // Close dialog
_connect(context, ref, connection, passController.text);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_connect(context, ref, connection, passController.text);
},
child: const Text('Connect'),
),
],
),
);
}
Future<void> _connect(BuildContext context, WidgetRef ref, SavedConnection connection, String password) async {
try {
await ref.read(terminalOutputProvider.notifier).connect(
connection.host,
connection.port,
connection.username,
password: password.isNotEmpty ? password : null,
privateKey: connection.privateKey,
);
} catch (e) {
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Connection Failed'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedConnections = ref.watch(savedConnectionsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Connections')),
floatingActionButton: FloatingActionButton(
onPressed: onCreateNew,
tooltip: 'New Connection',
child: const Icon(Icons.add),
),
body: savedConnections.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('No saved connections.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onCreateNew,
child: const Text('Create New Connection'),
),
],
),
)
: ListView.builder(
itemCount: savedConnections.length,
itemBuilder: (context, index) {
final connection = savedConnections[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text('${connection.username}@${connection.host}'),
subtitle: Text('Port: ${connection.port}'),
onTap: () => _showPasswordDialog(context, ref, connection),
trailing: IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete connection',
onPressed: () {
ref.read(savedConnectionsProvider.notifier).removeConnection(connection.id);
},
),
),
);
},
),
);
}
}
class NewConnectionForm extends ConsumerStatefulWidget {
final VoidCallback onCancel;
const NewConnectionForm({super.key, required this.onCancel});
@override
ConsumerState<NewConnectionForm> createState() => _NewConnectionFormState();
}
class _NewConnectionFormState extends ConsumerState<NewConnectionForm> {
final _hostController = TextEditingController(text: '');
final _portController = TextEditingController(text: '22');
final _userController = TextEditingController();
final _passController = TextEditingController();
String? _privateKeyContent;
String? _privateKeyName;
late FocusNode _hostFocus;
late FocusNode _portFocus;
late FocusNode _userFocus;
late FocusNode _passFocus;
late FocusNode _keyFocus;
late FocusNode _connectFocus;
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _saveConnection = true; // Default to save
@override
void initState() {
super.initState();
_hostFocus = FocusNode();
_portFocus = FocusNode();
_userFocus = FocusNode();
_passFocus = FocusNode();
_keyFocus = FocusNode();
_connectFocus = FocusNode();
}
@override
void dispose() {
_hostController.dispose();
_portController.dispose();
_userController.dispose();
_passController.dispose();
_hostFocus.dispose();
_portFocus.dispose();
_userFocus.dispose();
_passFocus.dispose();
_keyFocus.dispose();
_connectFocus.dispose();
super.dispose();
}
Future<void> _pickKeyFile() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.any, // SSH keys often have no extension or .pem/.key
);
if (result != null) {
final file = result.files.single;
if (file.path != null) {
final content = await File(file.path!).readAsString();
setState(() {
_privateKeyContent = content;
_privateKeyName = file.name;
});
SemanticsService.announce('Key selected: ${file.name}', TextDirection.ltr);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error reading key: $e')),
);
}
}
}
Future<void> _connect() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final port = int.tryParse(_portController.text) ?? 22;
try {
// 1. Save if requested
if (_saveConnection) {
await ref.read(savedConnectionsProvider.notifier).addConnection(
host: _hostController.text,
port: port,
username: _userController.text,
privateKey: _privateKeyContent,
);
}
// 2. Connect
await ref.read(terminalOutputProvider.notifier).connect(
_hostController.text,
port,
_userController.text,
password: _passController.text.isNotEmpty ? _passController.text : null,
privateKey: _privateKeyContent,
);
} catch (e) {
if (mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Connection Failed'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
_hostFocus.requestFocus();
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('New Connection'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: widget.onCancel,
tooltip: 'Back to list',
),
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MergeSemantics(
child: TextFormField(
controller: _hostController,
focusNode: _hostFocus,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Host',
hintText: 'Enter hostname or IP',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.computer),
),
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _portFocus.requestFocus(),
),
),
const SizedBox(height: 16),
MergeSemantics(
child: TextFormField(
controller: _portController,
focusNode: _portFocus,
decoration: const InputDecoration(
labelText: 'Port',
hintText: '22',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.settings_ethernet),
),
keyboardType: TextInputType.number,
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _userFocus.requestFocus(),
),
),
const SizedBox(height: 16),
MergeSemantics(
child: TextFormField(
controller: _userController,
focusNode: _userFocus,
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'Enter username',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _passFocus.requestFocus(),
),
),
const SizedBox(height: 16),
MergeSemantics(
child: TextFormField(
controller: _passController,
focusNode: _passFocus,
decoration: const InputDecoration(
labelText: 'Password / Passphrase',
hintText: 'Enter password or key passphrase',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.key),
),
obscureText: true,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _keyFocus.requestFocus(),
),
),
const SizedBox(height: 16),
// Key Picker
MergeSemantics(
child: Row(
children: [
ElevatedButton.icon(
focusNode: _keyFocus,
onPressed: _pickKeyFile,
icon: const Icon(Icons.file_open),
label: const Text('Select Private Key'),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_privateKeyName ?? 'No key selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _privateKeyName != null ? Colors.green : Colors.grey,
),
),
),
if (_privateKeyName != null)
IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear key',
onPressed: () {
setState(() {
_privateKeyContent = null;
_privateKeyName = null;
});
},
),
],
),
),
const SizedBox(height: 16),
// Save Toggle
MergeSemantics(
child: SwitchListTile(
title: const Text('Save Connection Details'),
subtitle: const Text('Key content will be saved locally'),
value: _saveConnection,
onChanged: (val) => setState(() => _saveConnection = val),
),
),
const SizedBox(height: 24),
ElevatedButton(
focusNode: _connectFocus,
onPressed: _isLoading ? null : _connect,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:accessible_terminal/state/terminal_provider.dart';
import 'package:accessible_terminal/ui/views/connection_view.dart';
import 'package:accessible_terminal/ui/views/terminal_view.dart';
class TabletScreen extends ConsumerStatefulWidget {
const TabletScreen({super.key});
@override
ConsumerState<TabletScreen> createState() => _TabletScreenState();
}
class _TabletScreenState extends ConsumerState<TabletScreen> {
bool _isCreatingNew = false;
@override
Widget build(BuildContext context) {
final isConnected = ref.watch(connectionStateProvider);
return Scaffold(
body: Row(
children: [
// Left Panel: Saved Connections List
SizedBox(
width: 320,
child: SavedConnectionsList(
onCreateNew: () {
setState(() => _isCreatingNew = true);
},
),
),
const VerticalDivider(width: 1),
// Right Panel: Dynamic Content
Expanded(
child: Builder(
builder: (context) {
if (isConnected) {
return const TerminalView();
} else if (_isCreatingNew) {
return NewConnectionForm(
onCancel: () {
setState(() => _isCreatingNew = false);
},
);
} else {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.terminal, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'Select a connection from the list\nor create a new one.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
);
}
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; // For SemanticsService
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:accessible_terminal/state/terminal_provider.dart';
import 'package:accessible_terminal/models/terminal_line.dart';
import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart';
class TerminalView extends ConsumerStatefulWidget {
const TerminalView({super.key});
@override
ConsumerState<TerminalView> createState() => _TerminalViewState();
}
class _TerminalViewState extends ConsumerState<TerminalView> {
final FocusNode _inputFocusNode = FocusNode();
final TextEditingController _inputController = TextEditingController();
final AnsiTextParser _ansiParser = AnsiTextParser();
bool _isChatMode = true; // Default to Chat Mode for better a11y
@override
void initState() {
super.initState();
WakelockPlus.enable();
WidgetsBinding.instance.addPostFrameCallback((_) {
_inputFocusNode.requestFocus();
});
// We start in Chat Mode, so no HardwareKeyboard handler initially needed
// (unless we want to support it there too, but Chat Mode uses the TextField).
}
@override
void dispose() {
WakelockPlus.disable();
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
_inputFocusNode.dispose();
_inputController.dispose();
super.dispose();
}
void _toggleMode(bool value) {
setState(() => _isChatMode = value);
if (_isChatMode) {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
// Clear any partial input from interactive mode if needed, or just focus the chat field
} else {
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
// Refocus to ensure input works immediately after switch
WidgetsBinding.instance.addPostFrameCallback((_) => _inputFocusNode.requestFocus());
}
bool _handleKeyEvent(KeyEvent event) {
if (_isChatMode) return false; // Should not happen if handler removed, but safe check
if (!_inputFocusNode.hasFocus) return false;
if (event is KeyDownEvent) {
final String? character = event.character;
if (character != null) {
ref.read(terminalOutputProvider.notifier).sendInput(character);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
ref.read(terminalOutputProvider.notifier).sendInput('\r');
return true;
}
if (event.logicalKey == LogicalKeyboardKey.backspace) {
ref.read(terminalOutputProvider.notifier).sendInput('\x08');
return true;
}
// Simple Arrow key mapping for Interactive Mode
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[A');
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[B');
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[C');
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[D');
return true;
}
}
return false;
}
void _sendMessage() {
final text = _inputController.text;
if (text.isNotEmpty) {
ref.read(terminalOutputProvider.notifier).sendInput('$text\r');
_inputController.clear();
_inputFocusNode.requestFocus();
} else {
ref.read(terminalOutputProvider.notifier).sendInput('\r');
_inputFocusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
final terminalOutput = ref.watch(terminalOutputProvider);
// Auto-announce new lines for accessibility
ref.listen<List<TerminalLine>>(terminalOutputProvider, (previous, next) {
if (previous != null && next.length > previous.length) {
// A new line was added at the beginning (index 0)
final newLine = next.first;
final cleanText = AnsiTextParser.strip(newLine.text);
if (cleanText.trim().isNotEmpty) {
SemanticsService.announce(cleanText, TextDirection.ltr);
}
}
});
return Scaffold(
appBar: AppBar(
title: const Text('Terminal'),
actions: [
Row(
children: [
const Text('Chat Mode', style: TextStyle(fontSize: 12)),
Semantics(
label: 'Toggle Chat Mode',
child: Switch(
value: _isChatMode,
onChanged: _toggleMode,
),
),
],
),
Semantics(
label: 'Disconnect',
button: true,
child: IconButton(
icon: const Icon(Icons.link_off),
onPressed: () {
ref.read(terminalOutputProvider.notifier).disconnect();
},
),
),
],
),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: true,
itemCount: terminalOutput.length,
padding: const EdgeInsets.all(8.0),
itemBuilder: (context, index) {
final line = terminalOutput[index];
return _TerminalLineItem(
textSpan: _ansiParser.parse(line.text),
);
},
),
),
if (_isChatMode)
// Chat Mode Input
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Row(
children: [
Expanded(
child: Semantics(
label: 'Command Input',
textField: true,
child: TextField(
controller: _inputController,
focusNode: _inputFocusNode,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.send,
decoration: const InputDecoration(
hintText: 'Type command...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
tooltip: 'Send Command',
onPressed: _sendMessage,
),
],
),
)
else
// Interactive Mode Input Trap (Invisible)
Semantics(
label: 'Interactive Input (Type directly)',
textField: true,
child: Opacity(
opacity: 0,
child: SizedBox(
height: 1,
width: 1,
child: TextField(
controller: _inputController,
focusNode: _inputFocusNode,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
// In interactive mode + soft keyboard, we send chars immediately
onChanged: (value) {
if (value.isNotEmpty) {
ref.read(terminalOutputProvider.notifier).sendInput(value);
_inputController.clear();
}
},
onSubmitted: (value) {
ref.read(terminalOutputProvider.notifier).sendInput('\r');
_inputController.clear();
_inputFocusNode.requestFocus();
},
),
),
),
),
if (!_isChatMode)
Container(
width: double.infinity,
padding: const EdgeInsets.all(4),
color: Colors.amber.withValues(alpha: 0.2),
child: const Text(
'Interactive Mode: Character-by-character input active.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
),
],
),
);
}
}
class _TerminalLineItem extends StatefulWidget {
final TextSpan textSpan;
const _TerminalLineItem({required this.textSpan});
@override
State<_TerminalLineItem> createState() => _TerminalLineItemState();
}
class _TerminalLineItemState extends State<_TerminalLineItem> {
bool _isFocused = false;
@override
Widget build(BuildContext context) {
final plainText = widget.textSpan.toPlainText();
final isEmpty = plainText.trim().isEmpty;
if (isEmpty) {
// Return a visual spacer only.
// No Focus widget = Not navigable by keyboard.
// No Semantics = Ignored by screen reader.
return const SizedBox(height: 12);
}
return Focus(
onFocusChange: (focused) {
setState(() => _isFocused = focused);
},
child: Container(
decoration: BoxDecoration(
color: _isFocused ? Theme.of(context).colorScheme.primaryContainer : null,
border: _isFocused
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
: Border.all(color: Colors.transparent, width: 2),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: MergeSemantics(
child: SelectableText.rich(
widget.textSpan,
style: const TextStyle(fontFamily: 'monospace'),
),
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
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<TextSpan> 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;
}
}
}