import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; import '../core/app_controller.dart'; import '../l10n/strings.dart'; import 'adaptive.dart'; import 'common.dart'; import 'theme.dart'; // xterm's default palette on the desktop terminal background. const _terminalTheme = TerminalTheme( cursor: Color(0xccecf5ff), selection: Color(0x6642a5ff), foreground: Color(0xffdce8f5), background: Brand.terminal, black: Color(0xff000000), red: Color(0xffcd3131), green: Color(0xff0dbc79), yellow: Color(0xffe5e510), blue: Color(0xff2472c8), magenta: Color(0xffbc3fbc), cyan: Color(0xff11a8cd), white: Color(0xffe5e5e5), brightBlack: Color(0xff666666), brightRed: Color(0xfff14c4c), brightGreen: Color(0xff23d18b), brightYellow: Color(0xfff5f543), brightBlue: Color(0xff3b8eea), brightMagenta: Color(0xffd670d6), brightCyan: Color(0xff29b8db), brightWhite: Color(0xffffffff), searchHitBackground: Color(0xffffff2b), searchHitBackgroundCurrent: Color(0xff31ff26), searchHitForeground: Color(0xff000000), ); class TerminalPage extends StatefulWidget { final AppController controller; const TerminalPage(this.controller, {super.key}); @override State createState() => _TerminalPageState(); } class _TerminalPageState extends State { final selection = TerminalController(); final focus = FocusNode(); bool ctrl = false, alt = false; @override void initState() { super.initState(); // The keyboard button offers show or hide depending on focus. focus.addListener(_focusChanged); } void _focusChanged() { if (mounted) setState(() {}); } @override void dispose() { focus.removeListener(_focusChanged); selection.dispose(); focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final c = widget.controller; if (!c.ssh.connected) { return const PageMessage('selectServer', icon: Icons.terminal); } final terminal = c.ssh.terminal; // Key bar plus at least a few terminal rows; shorter space scrolls. return MinHeight( minHeight: 120, child: Column( children: [ Expanded( child: Focus( onKeyEvent: (_, _) { c.activity(); return KeyEventResult.ignored; }, // xterm paints lines without clipping; scrolled-out lines // would otherwise draw over the status row and app bar. child: ClipRect( child: ColoredBox( color: Brand.terminal, child: TerminalView( terminal, controller: selection, focusNode: focus, theme: _terminalTheme, // Desktop terminal insets; narrower on compact phones. padding: MediaQuery.sizeOf(context).width < 600 ? const EdgeInsets.fromLTRB(8, 6, 4, 4) : const EdgeInsets.fromLTRB(14, 12, 8, 8), // Soft keyboards (iOS, many Android IMEs) send no backspace // key event on an empty input buffer; this detects deletion. deleteDetection: defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android, textStyle: TerminalStyle( fontSize: 14 * MediaQuery.textScalerOf(context).scale(1), ), onKeyEvent: (_, key) { c.activity(); return KeyEventResult.ignored; }, ), ), ), ), ), // Key bar styled like the desktop terminal toolbar. DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).navigationBarTheme.backgroundColor, border: Border( top: BorderSide(color: Theme.of(context).dividerColor), ), ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ for (final entry in { 'ESC': TerminalKey.escape, 'TAB': TerminalKey.tab, '↑': TerminalKey.arrowUp, '↓': TerminalKey.arrowDown, '←': TerminalKey.arrowLeft, '→': TerminalKey.arrowRight, }.entries) TextButton( onPressed: () { c.activity(); terminal.keyInput(entry.value); }, child: Text(entry.key), ), FilterChip( label: const Text('CTRL'), selected: c.ssh.ctrlNext, onSelected: (v) => setState(() => c.ssh.ctrlNext = v), ), const SizedBox(width: 6), FilterChip( label: const Text('ALT'), selected: c.ssh.altNext, onSelected: (v) => setState(() => c.ssh.altNext = v), ), IconButton( tooltip: context.tr( focus.hasFocus ? 'hideKeyboard' : 'keyboard', ), icon: Icon( focus.hasFocus ? Icons.keyboard_hide : Icons.keyboard, ), onPressed: () => focus.hasFocus ? focus.unfocus() : focus.requestFocus(), ), IconButton( tooltip: context.tr('copy'), icon: const Icon(Icons.copy), onPressed: () { if (selection.selection != null) { Clipboard.setData( ClipboardData( text: terminal.buffer.getText(selection.selection), ), ); } }, ), IconButton( tooltip: context.tr('paste'), icon: const Icon(Icons.paste), onPressed: () async { final data = await Clipboard.getData( Clipboard.kTextPlain, ); if (c.ssh.connected && c.vault.unlocked && data?.text != null) { terminal.paste(data!.text!); } }, ), ], ), ), ), ], ), ); } }