From eb6ac79eed490cce5004b3634f19d0d751708aa2 Mon Sep 17 00:00:00 2001
From: Nate Brown <nbrown.us@gmail.com>
Date: Mon, 31 Aug 2020 18:32:40 -0500
Subject: [PATCH] Wrap SelectableText with keyboard shortcuts to help chromeos

---
 lib/components/SpecialSelectableText.dart     | 683 ++++++++++++++++++
 lib/components/SpecialTextField.dart          |   3 +
 lib/screens/AboutScreen.dart                  |   3 +-
 lib/screens/HostInfoScreen.dart               |  13 +-
 lib/screens/SiteDetailScreen.dart             |   3 +-
 lib/screens/SiteLogsScreen.dart               |   3 +-
 .../siteConfig/CertificateDetailsScreen.dart  |  19 +-
 lib/screens/siteConfig/CertificateScreen.dart |   9 +-
 .../siteConfig/RenderedConfigScreen.dart      |   3 +-
 lib/screens/siteConfig/SiteConfigScreen.dart  |   3 +-
 10 files changed, 721 insertions(+), 21 deletions(-)
 create mode 100644 lib/components/SpecialSelectableText.dart

diff --git a/lib/components/SpecialSelectableText.dart b/lib/components/SpecialSelectableText.dart
new file mode 100644
index 0000000..f63f9a0
--- /dev/null
+++ b/lib/components/SpecialSelectableText.dart
@@ -0,0 +1,683 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// @dart = 2.8
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+
+
+/// An eyeballed value that moves the cursor slightly left of where it is
+/// rendered for text on Android so its positioning more accurately matches the
+/// native iOS text cursor positioning.
+///
+/// This value is in device pixels, not logical pixels as is typically used
+/// throughout the codebase.
+const int iOSHorizontalOffset = -2;
+
+class _TextSpanEditingController extends TextEditingController {
+  _TextSpanEditingController({@required TextSpan textSpan}):
+        assert(textSpan != null),
+        _textSpan = textSpan,
+        super(text: textSpan.toPlainText());
+
+  final TextSpan _textSpan;
+
+  @override
+  TextSpan buildTextSpan({TextStyle style ,bool withComposing}) {
+    // This does not care about composing.
+    return TextSpan(
+      style: style,
+      children: <TextSpan>[_textSpan],
+    );
+  }
+
+  @override
+  set text(String newText) {
+    // This should never be reached.
+    throw UnimplementedError();
+  }
+}
+
+class _SpecialSelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
+  _SpecialSelectableTextSelectionGestureDetectorBuilder({
+    @required _SpecialSelectableTextState state,
+  }) : _state = state,
+        super(delegate: state);
+
+  final _SpecialSelectableTextState _state;
+
+  @override
+  void onForcePressStart(ForcePressDetails details) {
+    super.onForcePressStart(details);
+    if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
+      editableText.showToolbar();
+    }
+  }
+
+  @override
+  void onForcePressEnd(ForcePressDetails details) {
+    // Not required.
+  }
+
+  @override
+  void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
+    if (delegate.selectionEnabled) {
+      switch (Theme.of(_state.context).platform) {
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          renderEditable.selectPositionAt(
+            from: details.globalPosition,
+            cause: SelectionChangedCause.longPress,
+          );
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          renderEditable.selectWordsInRange(
+            from: details.globalPosition - details.offsetFromOrigin,
+            to: details.globalPosition,
+            cause: SelectionChangedCause.longPress,
+          );
+          break;
+      }
+    }
+  }
+
+  @override
+  void onSingleTapUp(TapUpDetails details) {
+    editableText.hideToolbar();
+    if (delegate.selectionEnabled) {
+      switch (Theme.of(_state.context).platform) {
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          renderEditable.selectPosition(cause: SelectionChangedCause.tap);
+          break;
+      }
+    }
+    if (_state.widget.onTap != null)
+      _state.widget.onTap();
+  }
+
+  @override
+  void onSingleLongTapStart(LongPressStartDetails details) {
+    if (delegate.selectionEnabled) {
+      switch (Theme.of(_state.context).platform) {
+        case TargetPlatform.iOS:
+        case TargetPlatform.macOS:
+          renderEditable.selectPositionAt(
+            from: details.globalPosition,
+            cause: SelectionChangedCause.longPress,
+          );
+          break;
+        case TargetPlatform.android:
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.windows:
+          renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+          Feedback.forLongPress(_state.context);
+          break;
+      }
+    }
+  }
+}
+
+/// A run of selectable text with a single style.
+///
+/// The [SpecialSelectableText] widget displays a string of text with a single style.
+/// The string might break across multiple lines or might all be displayed on
+/// the same line depending on the layout constraints.
+///
+/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc}
+///
+/// The [style] argument is optional. When omitted, the text will use the style
+/// from the closest enclosing [DefaultTextStyle]. If the given style's
+/// [TextStyle.inherit] property is true (the default), the given style will
+/// be merged with the closest enclosing [DefaultTextStyle]. This merging
+/// behavior is useful, for example, to make the text bold while using the
+/// default font family and size.
+///
+/// {@tool snippet}
+///
+/// ```dart
+/// SpecialSelectableText(
+///   'Hello! How are you?',
+///   textAlign: TextAlign.center,
+///   style: TextStyle(fontWeight: FontWeight.bold),
+/// )
+/// ```
+/// {@end-tool}
+///
+/// Using the [SpecialSelectableText.rich] constructor, the [SpecialSelectableText] widget can
+/// display a paragraph with differently styled [TextSpan]s. The sample
+/// that follows displays "Hello beautiful world" with different styles
+/// for each word.
+///
+/// {@tool snippet}
+///
+/// ```dart
+/// const SpecialSelectableText.rich(
+///   TextSpan(
+///     text: 'Hello', // default text style
+///     children: <TextSpan>[
+///       TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)),
+///       TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)),
+///     ],
+///   ),
+/// )
+/// ```
+/// {@end-tool}
+///
+/// ## Interactivity
+///
+/// To make [SpecialSelectableText] react to touch events, use callback [onTap] to achieve
+/// the desired behavior.
+///
+/// See also:
+///
+///  * [Text], which is the non selectable version of this widget.
+///  * [TextField], which is the editable version of this widget.
+class SpecialSelectableText extends StatefulWidget {
+  /// Creates a selectable text widget.
+  ///
+  /// If the [style] argument is null, the text will use the style from the
+  /// closest enclosing [DefaultTextStyle].
+  ///
+
+  /// The [showCursor], [autofocus], [dragStartBehavior], and [data] parameters
+  /// must not be null. If specified, the [maxLines] argument must be greater
+  /// than zero.
+  const SpecialSelectableText(
+      this.data, {
+        Key key,
+        this.focusNode,
+        this.style,
+        this.strutStyle,
+        this.textAlign,
+        this.textDirection,
+        this.textScaleFactor,
+        this.showCursor = false,
+        this.autofocus = false,
+        ToolbarOptions toolbarOptions,
+        this.minLines,
+        this.maxLines,
+        this.cursorWidth = 2.0,
+        this.cursorRadius,
+        this.cursorColor,
+        this.dragStartBehavior = DragStartBehavior.start,
+        this.enableInteractiveSelection = true,
+        this.onTap,
+        this.scrollPhysics,
+        this.textHeightBehavior,
+        this.textWidthBasis,
+      }) :  assert(showCursor != null),
+        assert(autofocus != null),
+        assert(dragStartBehavior != null),
+        assert(maxLines == null || maxLines > 0),
+        assert(minLines == null || minLines > 0),
+        assert(
+        (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+        'minLines can\'t be greater than maxLines',
+        ),
+        assert(
+        data != null,
+        'A non-null String must be provided to a SpecialSelectableText widget.',
+        ),
+        textSpan = null,
+        toolbarOptions = toolbarOptions ??
+            const ToolbarOptions(
+              selectAll: true,
+              copy: true,
+            ),
+        super(key: key);
+
+  /// Creates a selectable text widget with a [TextSpan].
+  ///
+  /// The [textSpan] parameter must not be null and only contain [TextSpan] in
+  /// [textSpan.children]. Other type of [InlineSpan] is not allowed.
+  ///
+  /// The [autofocus] and [dragStartBehavior] arguments must not be null.
+  const SpecialSelectableText.rich(
+      this.textSpan, {
+        Key key,
+        this.focusNode,
+        this.style,
+        this.strutStyle,
+        this.textAlign,
+        this.textDirection,
+        this.textScaleFactor,
+        this.showCursor = false,
+        this.autofocus = false,
+        ToolbarOptions toolbarOptions,
+        this.minLines,
+        this.maxLines,
+        this.cursorWidth = 2.0,
+        this.cursorRadius,
+        this.cursorColor,
+        this.dragStartBehavior = DragStartBehavior.start,
+        this.enableInteractiveSelection = true,
+        this.onTap,
+        this.scrollPhysics,
+        this.textHeightBehavior,
+        this.textWidthBasis,
+      }) :  assert(showCursor != null),
+        assert(autofocus != null),
+        assert(dragStartBehavior != null),
+        assert(maxLines == null || maxLines > 0),
+        assert(minLines == null || minLines > 0),
+        assert(
+        (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+        'minLines can\'t be greater than maxLines',
+        ),
+        assert(
+        textSpan != null,
+        'A non-null TextSpan must be provided to a SpecialSelectableText.rich widget.',
+        ),
+        data = null,
+        toolbarOptions = toolbarOptions ??
+            const ToolbarOptions(
+              selectAll: true,
+              copy: true,
+            ),
+        super(key: key);
+
+  /// The text to display.
+  ///
+  /// This will be null if a [textSpan] is provided instead.
+  final String data;
+
+  /// The text to display as a [TextSpan].
+  ///
+  /// This will be null if [data] is provided instead.
+  final TextSpan textSpan;
+
+  /// Defines the focus for this widget.
+  ///
+  /// Text is only selectable when widget is focused.
+  ///
+  /// The [focusNode] is a long-lived object that's typically managed by a
+  /// [StatefulWidget] parent. See [FocusNode] for more information.
+  ///
+  /// To give the focus to this widget, provide a [focusNode] and then
+  /// use the current [FocusScope] to request the focus:
+  ///
+  /// ```dart
+  /// FocusScope.of(context).requestFocus(myFocusNode);
+  /// ```
+  ///
+  /// This happens automatically when the widget is tapped.
+  ///
+  /// To be notified when the widget gains or loses the focus, add a listener
+  /// to the [focusNode]:
+  ///
+  /// ```dart
+  /// focusNode.addListener(() { print(myFocusNode.hasFocus); });
+  /// ```
+  ///
+  /// If null, this widget will create its own [FocusNode].
+  final FocusNode focusNode;
+
+  /// The style to use for the text.
+  ///
+  /// If null, defaults [DefaultTextStyle] of context.
+  final TextStyle style;
+
+  /// {@macro flutter.widgets.editableText.strutStyle}
+  final StrutStyle strutStyle;
+
+  /// {@macro flutter.widgets.editableText.textAlign}
+  final TextAlign textAlign;
+
+  /// {@macro flutter.widgets.editableText.textDirection}
+  final TextDirection textDirection;
+
+  /// {@macro flutter.widgets.editableText.textScaleFactor}
+  final double textScaleFactor;
+
+  /// {@macro flutter.widgets.editableText.autofocus}
+  final bool autofocus;
+
+  /// {@macro flutter.widgets.editableText.minLines}
+  final int minLines;
+
+  /// {@macro flutter.widgets.editableText.maxLines}
+  final int maxLines;
+
+  /// {@macro flutter.widgets.editableText.showCursor}
+  final bool showCursor;
+
+  /// {@macro flutter.widgets.editableText.cursorWidth}
+  final double cursorWidth;
+
+  /// {@macro flutter.widgets.editableText.cursorRadius}
+  final Radius cursorRadius;
+
+  /// The color to use when painting the cursor.
+  ///
+  /// Defaults to the theme's `cursorColor` when null.
+  final Color cursorColor;
+
+  /// {@macro flutter.widgets.editableText.enableInteractiveSelection}
+  final bool enableInteractiveSelection;
+
+  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
+  final DragStartBehavior dragStartBehavior;
+
+  /// Configuration of toolbar options.
+  ///
+  /// Paste and cut will be disabled regardless.
+  ///
+  /// If not set, select all and copy will be enabled by default.
+  final ToolbarOptions toolbarOptions;
+
+  /// {@macro flutter.rendering.editable.selectionEnabled}
+  bool get selectionEnabled {
+    return enableInteractiveSelection;
+  }
+
+  /// Called when the user taps on this selectable text.
+  ///
+  /// The selectable text builds a [GestureDetector] to handle input events like tap,
+  /// to trigger focus requests, to move the caret, adjust the selection, etc.
+  /// Handling some of those events by wrapping the selectable text with a competing
+  /// GestureDetector is problematic.
+  ///
+  /// To unconditionally handle taps, without interfering with the selectable text's
+  /// internal gesture detector, provide this callback.
+  ///
+  /// To be notified when the text field gains or loses the focus, provide a
+  /// [focusNode] and add a listener to that.
+  ///
+  /// To listen to arbitrary pointer events without competing with the
+  /// selectable text's internal gesture detector, use a [Listener].
+  final GestureTapCallback onTap;
+
+  /// {@macro flutter.widgets.editableText.scrollPhysics}
+  final ScrollPhysics scrollPhysics;
+
+  /// {@macro flutter.dart:ui.textHeightBehavior}
+  final TextHeightBehavior textHeightBehavior;
+
+  /// {@macro flutter.painting.textPainter.textWidthBasis}
+  final TextWidthBasis textWidthBasis;
+
+  @override
+  _SpecialSelectableTextState createState() => _SpecialSelectableTextState();
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
+    properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
+    properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+    properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+    properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
+    properties.add(IntProperty('minLines', minLines, defaultValue: null));
+    properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+    properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+    properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
+    properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null));
+    properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
+    properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
+    properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
+    properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
+    properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
+    properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
+  }
+}
+
+class _SpecialSelectableTextState extends State<SpecialSelectableText> with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate {
+  EditableTextState get _editableText => editableTextKey.currentState;
+
+  _TextSpanEditingController _controller;
+
+  FocusNode _keyFocusNode = FocusNode();
+  FocusNode _focusNode;
+  FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
+
+  bool _showSelectionHandles = false;
+
+  _SpecialSelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
+
+  // API for TextSelectionGestureDetectorBuilderDelegate.
+  @override
+  bool forcePressEnabled;
+
+  @override
+  final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
+
+  @override
+  bool get selectionEnabled => widget.selectionEnabled;
+  // End of API for TextSelectionGestureDetectorBuilderDelegate.
+
+  @override
+  void initState() {
+    super.initState();
+    _selectionGestureDetectorBuilder = _SpecialSelectableTextSelectionGestureDetectorBuilder(state: this);
+    _controller = _TextSpanEditingController(
+        textSpan: widget.textSpan ?? TextSpan(text: widget.data)
+    );
+  }
+
+  @override
+  void didUpdateWidget(SpecialSelectableText oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) {
+      _controller = _TextSpanEditingController(
+          textSpan: widget.textSpan ?? TextSpan(text: widget.data)
+      );
+    }
+    if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
+      _showSelectionHandles = false;
+    }
+  }
+
+  @override
+  void dispose() {
+    _focusNode?.dispose();
+    super.dispose();
+  }
+
+  void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
+    final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
+    if (willShowSelectionHandles != _showSelectionHandles) {
+      setState(() {
+        _showSelectionHandles = willShowSelectionHandles;
+      });
+    }
+
+    switch (Theme.of(context).platform) {
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        if (cause == SelectionChangedCause.longPress) {
+          _editableText?.bringIntoView(selection.base);
+        }
+        return;
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+      // Do nothing.
+    }
+  }
+
+  /// Toggle the toolbar when a selection handle is tapped.
+  void _handleSelectionHandleTapped() {
+    if (_controller.selection.isCollapsed) {
+      _editableText.toggleToolbar();
+    }
+  }
+
+  bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
+    // When the text field is activated by something that doesn't trigger the
+    // selection overlay, we shouldn't show the handles either.
+    if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar)
+      return false;
+
+    if (_controller.selection.isCollapsed)
+      return false;
+
+    if (cause == SelectionChangedCause.keyboard)
+      return false;
+
+    if (cause == SelectionChangedCause.longPress)
+      return true;
+
+    if (_controller.text.isNotEmpty)
+      return true;
+
+    return false;
+  }
+
+  @override
+  bool get wantKeepAlive => true;
+
+  @override
+  Widget build(BuildContext context) {
+    super.build(context); // See AutomaticKeepAliveClientMixin.
+    assert(() {
+      return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan);
+    }(), 'SpecialSelectableText only supports TextSpan; Other type of InlineSpan is not allowed');
+    assert(debugCheckHasMediaQuery(context));
+    assert(debugCheckHasDirectionality(context));
+    assert(
+    !(widget.style != null && widget.style.inherit == false &&
+        (widget.style.fontSize == null || widget.style.textBaseline == null)),
+    'inherit false style must supply fontSize and textBaseline',
+    );
+
+    final ThemeData themeData = Theme.of(context);
+    final FocusNode focusNode = _effectiveFocusNode;
+
+    TextSelectionControls textSelectionControls;
+    bool paintCursorAboveText;
+    bool cursorOpacityAnimates;
+    Offset cursorOffset;
+    Color cursorColor = widget.cursorColor;
+    Radius cursorRadius = widget.cursorRadius;
+
+    switch (themeData.platform) {
+      case TargetPlatform.iOS:
+      case TargetPlatform.macOS:
+        forcePressEnabled = true;
+        textSelectionControls = cupertinoTextSelectionControls;
+        paintCursorAboveText = true;
+        cursorOpacityAnimates = true;
+        cursorColor ??= CupertinoTheme.of(context).primaryColor;
+        cursorRadius ??= const Radius.circular(2.0);
+        cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
+        break;
+
+      case TargetPlatform.android:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        forcePressEnabled = false;
+        textSelectionControls = materialTextSelectionControls;
+        paintCursorAboveText = false;
+        cursorOpacityAnimates = false;
+        cursorColor ??= themeData.cursorColor;
+        break;
+    }
+
+    final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
+    TextStyle effectiveTextStyle = widget.style;
+    if (widget.style == null || widget.style.inherit)
+      effectiveTextStyle = defaultTextStyle.style.merge(widget.style);
+    if (MediaQuery.boldTextOverride(context))
+      effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold));
+    final Widget child = RepaintBoundary(
+      child: EditableText(
+        key: editableTextKey,
+        style: effectiveTextStyle,
+        readOnly: true,
+        textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
+        textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
+        showSelectionHandles: _showSelectionHandles,
+        showCursor: widget.showCursor,
+        controller: _controller,
+        focusNode: focusNode,
+        strutStyle: widget.strutStyle ?? const StrutStyle(),
+        textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
+        textDirection: widget.textDirection,
+        textScaleFactor: widget.textScaleFactor,
+        autofocus: widget.autofocus,
+        forceLine: false,
+        toolbarOptions: widget.toolbarOptions,
+        minLines: widget.minLines,
+        maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
+        selectionColor: themeData.textSelectionColor,
+        selectionControls: widget.selectionEnabled ? textSelectionControls : null,
+        onSelectionChanged: _handleSelectionChanged,
+        onSelectionHandleTapped: _handleSelectionHandleTapped,
+        rendererIgnoresPointer: true,
+        cursorWidth: widget.cursorWidth,
+        cursorRadius: cursorRadius,
+        cursorColor: cursorColor,
+        cursorOpacityAnimates: cursorOpacityAnimates,
+        cursorOffset: cursorOffset,
+        paintCursorAboveText: paintCursorAboveText,
+        backgroundCursorColor: CupertinoColors.inactiveGray,
+        enableInteractiveSelection: widget.enableInteractiveSelection,
+        dragStartBehavior: widget.dragStartBehavior,
+        scrollPhysics: widget.scrollPhysics,
+      ),
+    );
+
+    return Semantics(
+      onTap: () {
+        if (!_controller.selection.isValid)
+          _controller.selection = TextSelection.collapsed(offset: _controller.text.length);
+        _effectiveFocusNode.requestFocus();
+      },
+      onLongPress: () {
+        _effectiveFocusNode.requestFocus();
+      },
+      child: _selectionGestureDetectorBuilder.buildGestureDetector(
+        behavior: HitTestBehavior.translucent,
+        child: RawKeyboardListener(
+          focusNode: _keyFocusNode,
+          onKey: _onKey,
+          child: child,
+        )
+      ),
+    );
+  }
+
+  _onKey(RawKeyEvent event) {
+    // We don't care about key up events
+    if (event is RawKeyUpEvent) {
+      return;
+    }
+
+    //TODO: tab to next focus node
+
+    // Handle special keyboard events with control key
+    if (event.data.isControlPressed) {
+      // Handle select all
+      if (event.logicalKey == LogicalKeyboardKey.keyA) {
+        _controller.selection = TextSelection(baseOffset: 0, extentOffset: _controller.text.length);
+        return;
+      }
+
+      // Handle copy
+      if (event.logicalKey == LogicalKeyboardKey.keyC) {
+        Clipboard.setData(ClipboardData(text: _controller.selection.textInside(_controller.text)));
+        return;
+      }
+    }
+  }
+}
diff --git a/lib/components/SpecialTextField.dart b/lib/components/SpecialTextField.dart
index ac5deca..0df620a 100644
--- a/lib/components/SpecialTextField.dart
+++ b/lib/components/SpecialTextField.dart
@@ -24,6 +24,7 @@ class SpecialTextField extends StatefulWidget {
       this.textAlign,
       this.autofocus,
       this.onChanged,
+      this.enabled,
       this.expands,
       this.keyboardAppearance,
       this.textAlignVertical,
@@ -51,6 +52,7 @@ class SpecialTextField extends StatefulWidget {
 
   final bool autofocus;
   final ValueChanged<String> onChanged;
+  final bool enabled;
   final List<TextInputFormatter> inputFormatters;
   final bool expands;
 
@@ -98,6 +100,7 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
             autofocus: widget.autofocus,
             focusNode: widget.focusNode,
             onChanged: widget.onChanged,
+            enabled: widget.enabled,
             onSubmitted: (_) {
               if (widget.nextFocusNode != null) {
                 FocusScope.of(context).requestFocus(widget.nextFocusNode);
diff --git a/lib/screens/AboutScreen.dart b/lib/screens/AboutScreen.dart
index 7741793..a489557 100644
--- a/lib/screens/AboutScreen.dart
+++ b/lib/screens/AboutScreen.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
 import 'package:mobile_nebula/components/config/ConfigSection.dart';
@@ -66,6 +67,6 @@ class _AboutScreenState extends State<AboutScreen> {
   }
 
   _buildText(String str) {
-    return Align(alignment: AlignmentDirectional.centerEnd, child: SelectableText(str));
+    return Align(alignment: AlignmentDirectional.centerEnd, child: SpecialSelectableText(str));
   }
 }
\ No newline at end of file
diff --git a/lib/screens/HostInfoScreen.dart b/lib/screens/HostInfoScreen.dart
index 50cab7a..9151f7d 100644
--- a/lib/screens/HostInfoScreen.dart
+++ b/lib/screens/HostInfoScreen.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
@@ -60,7 +61,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
 
   Widget _buildMain() {
     return ConfigSection(children: [
-      ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
+      ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SpecialSelectableText(hostInfo.vpnIp)),
       hostInfo.cert != null
           ? ConfigPageItem(
               label: Text('Certificate'),
@@ -75,12 +76,12 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
   Widget _buildDetails() {
     return ConfigSection(children: <Widget>[
       ConfigItem(
-          label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')),
-      ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')),
-      ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')),
+          label: Text('Lighthouse'), labelWidth: 150, content: SpecialSelectableText(widget.isLighthouse ? 'Yes' : 'No')),
+      ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.localIndex}')),
+      ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.remoteIndex}')),
       ConfigItem(
-          label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')),
-      ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SelectableText('${hostInfo.cachedPackets}')),
+          label: Text('Message Counter'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.messageCounter}')),
+      ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.cachedPackets}')),
     ]);
   }
 
diff --git a/lib/screens/SiteDetailScreen.dart b/lib/screens/SiteDetailScreen.dart
index 58b2c38..7d8d84e 100644
--- a/lib/screens/SiteDetailScreen.dart
+++ b/lib/screens/SiteDetailScreen.dart
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigSection.dart';
@@ -109,7 +110,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
     List<Widget> items = [];
     site.errors.forEach((error) {
       items.add(ConfigItem(
-          labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error))));
+          labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SpecialSelectableText(error))));
     });
 
     return ConfigSection(
diff --git a/lib/screens/SiteLogsScreen.dart b/lib/screens/SiteLogsScreen.dart
index 19be308..b40774b 100644
--- a/lib/screens/SiteLogsScreen.dart
+++ b/lib/screens/SiteLogsScreen.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/models/Site.dart';
 import 'package:mobile_nebula/services/share.dart';
 import 'package:mobile_nebula/services/utils.dart';
@@ -54,7 +55,7 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
       child: Container(
           padding: EdgeInsets.all(5),
           constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
-          child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
+          child: SpecialSelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
       bottomBar: _buildBottomBar(),
     );
   }
diff --git a/lib/screens/siteConfig/CertificateDetailsScreen.dart b/lib/screens/siteConfig/CertificateDetailsScreen.dart
index 8a4e33f..313fc5f 100644
--- a/lib/screens/siteConfig/CertificateDetailsScreen.dart
+++ b/lib/screens/siteConfig/CertificateDetailsScreen.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigSection.dart';
 import 'package:mobile_nebula/models/Certificate.dart';
@@ -36,7 +37,7 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
 
   Widget _buildID() {
     return ConfigSection(children: <Widget>[
-      ConfigItem(label: Text('Name'), content: SelectableText(widget.certificate.cert.details.name)),
+      ConfigItem(label: Text('Name'), content: SpecialSelectableText(widget.certificate.cert.details.name)),
       ConfigItem(
           label: Text('Type'),
           content: Text(widget.certificate.cert.details.isCa ? 'CA certificate' : 'Client certificate')),
@@ -55,10 +56,10 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
         ConfigItem(label: Text('Valid?'), content: valid),
         ConfigItem(
             label: Text('Created'),
-            content: SelectableText(widget.certificate.cert.details.notBefore.toLocal().toString())),
+            content: SpecialSelectableText(widget.certificate.cert.details.notBefore.toLocal().toString())),
         ConfigItem(
             label: Text('Expires'),
-            content: SelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())),
+            content: SpecialSelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())),
       ],
     );
   }
@@ -67,17 +68,17 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
     List<Widget> items = [];
     if (widget.certificate.cert.details.groups.length > 0) {
       items.add(ConfigItem(
-          label: Text('Groups'), content: SelectableText(widget.certificate.cert.details.groups.join(', '))));
+          label: Text('Groups'), content: SpecialSelectableText(widget.certificate.cert.details.groups.join(', '))));
     }
 
     if (widget.certificate.cert.details.ips.length > 0) {
       items
-          .add(ConfigItem(label: Text('IPs'), content: SelectableText(widget.certificate.cert.details.ips.join(', '))));
+          .add(ConfigItem(label: Text('IPs'), content: SpecialSelectableText(widget.certificate.cert.details.ips.join(', '))));
     }
 
     if (widget.certificate.cert.details.subnets.length > 0) {
       items.add(ConfigItem(
-          label: Text('Subnets'), content: SelectableText(widget.certificate.cert.details.subnets.join(', '))));
+          label: Text('Subnets'), content: SpecialSelectableText(widget.certificate.cert.details.subnets.join(', '))));
     }
 
     return items.length > 0
@@ -90,18 +91,18 @@ class _CertificateDetailsScreenState extends State<CertificateDetailsScreen> {
       children: <Widget>[
         ConfigItem(
             label: Text('Fingerprint'),
-            content: SelectableText(widget.certificate.cert.fingerprint,
+            content: SpecialSelectableText(widget.certificate.cert.fingerprint,
                 style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
             crossAxisAlignment: CrossAxisAlignment.start),
         ConfigItem(
             label: Text('Public Key'),
-            content: SelectableText(widget.certificate.cert.details.publicKey,
+            content: SpecialSelectableText(widget.certificate.cert.details.publicKey,
                 style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
             crossAxisAlignment: CrossAxisAlignment.start),
         widget.certificate.rawCert != null
             ? ConfigItem(
                 label: Text('PEM Format'),
-                content: SelectableText(widget.certificate.rawCert,
+                content: SpecialSelectableText(widget.certificate.rawCert,
                     style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
                 crossAxisAlignment: CrossAxisAlignment.start)
             : Container(),
diff --git a/lib/screens/siteConfig/CertificateScreen.dart b/lib/screens/siteConfig/CertificateScreen.dart
index f107182..035a48c 100644
--- a/lib/screens/siteConfig/CertificateScreen.dart
+++ b/lib/screens/siteConfig/CertificateScreen.dart
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 import 'package:mobile_nebula/components/FormPage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigButtonItem.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
@@ -53,6 +54,12 @@ class _CertificateScreenState extends State<CertificateScreen> {
     super.initState();
   }
 
+  @override
+  void dispose() {
+    pasteController.dispose();
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
     List<Widget> items = [];
@@ -135,7 +142,7 @@ class _CertificateScreenState extends State<CertificateScreen> {
           children: [
             ConfigItem(
               labelWidth: 0,
-              content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
+              content: SpecialSelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
             ),
             ConfigButtonItem(
               content: Text('Share Public Key'),
diff --git a/lib/screens/siteConfig/RenderedConfigScreen.dart b/lib/screens/siteConfig/RenderedConfigScreen.dart
index 0a10a6d..b2fa2f3 100644
--- a/lib/screens/siteConfig/RenderedConfigScreen.dart
+++ b/lib/screens/siteConfig/RenderedConfigScreen.dart
@@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
 import 'package:mobile_nebula/components/SimplePage.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/services/share.dart';
 
 class RenderedConfigScreen extends StatelessWidget {
@@ -25,7 +26,7 @@ class RenderedConfigScreen extends StatelessWidget {
       child: Container(
           padding: EdgeInsets.all(5),
           constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
-          child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
+          child: SpecialSelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
     );
   }
 }
diff --git a/lib/screens/siteConfig/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart
index c88b20f..a819468 100644
--- a/lib/screens/siteConfig/SiteConfigScreen.dart
+++ b/lib/screens/siteConfig/SiteConfigScreen.dart
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/widgets.dart';
 import 'package:mobile_nebula/components/FormPage.dart';
 import 'package:mobile_nebula/components/PlatformTextFormField.dart';
+import 'package:mobile_nebula/components/SpecialSelectableText.dart';
 import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
 import 'package:mobile_nebula/components/config/ConfigItem.dart';
 import 'package:mobile_nebula/components/config/ConfigSection.dart';
@@ -89,7 +90,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
       data = err.toString();
     }
 
-    return ConfigSection(label: 'DEBUG', children: [ConfigItem(labelWidth: 0, content: SelectableText(data))]);
+    return ConfigSection(label: 'DEBUG', children: [ConfigItem(labelWidth: 0, content: SpecialSelectableText(data))]);
   }
 
   Widget _main() {