From 462279fef7e466d76f29dec174e597a3697bffa7 Mon Sep 17 00:00:00 2001 From: Vishesh Handa Date: Wed, 7 Jul 2021 18:05:45 +0200 Subject: [PATCH] Markdown Toolbar: Add tab/backtab on long press of > or < --- lib/forks/icon_button_more_gestures.dart | 422 +++++++++++++++++++++++ lib/widgets/markdown_toolbar.dart | 62 +++- test/markdown_toolbar_test.dart | 84 ++++- 3 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 lib/forks/icon_button_more_gestures.dart diff --git a/lib/forks/icon_button_more_gestures.dart b/lib/forks/icon_button_more_gestures.dart new file mode 100644 index 00000000..370a3e5f --- /dev/null +++ b/lib/forks/icon_button_more_gestures.dart @@ -0,0 +1,422 @@ +// ignore_for_file: unnecessary_null_comparison, curly_braces_in_flow_control_structures +// vHanda: Added onLongPressed + +// 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. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +// Minimum logical pixel size of the IconButton. +// See: . +const double _kMinButtonSize = kMinInteractiveDimension; + +/// A material design icon button. +/// +/// An icon button is a picture printed on a [Material] widget that reacts to +/// touches by filling with color (ink). +/// +/// Icon buttons are commonly used in the [AppBar.actions] field, but they can +/// be used in many other places as well. +/// +/// If the [onPressed] callback is null, then the button will be disabled and +/// will not react to touch. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// The hit region of an icon button will, if possible, be at least +/// kMinInteractiveDimension pixels in size, regardless of the actual +/// [iconSize], to satisfy the [touch target size](https://material.io/design/layout/spacing-methods.html#touch-targets) +/// requirements in the Material Design specification. The [alignment] controls +/// how the icon itself is positioned within the hit region. +/// +/// {@tool dartpad --template=stateful_widget_scaffold_center} +/// +/// This sample shows an `IconButton` that uses the Material icon "volume_up" to +/// increase the volume. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/icon_button.png) +/// +/// ```dart preamble +/// double _volume = 0.0; +/// ``` +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// return Column( +/// mainAxisSize: MainAxisSize.min, +/// children: [ +/// IconButton( +/// icon: const Icon(Icons.volume_up), +/// tooltip: 'Increase volume by 10', +/// onPressed: () { +/// setState(() { +/// _volume += 10; +/// }); +/// }, +/// ), +/// Text('Volume : $_volume') +/// ], +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// ### Adding a filled background +/// +/// Icon buttons don't support specifying a background color or other +/// background decoration because typically the icon is just displayed +/// on top of the parent widget's background. Icon buttons that appear +/// in [AppBar.actions] are an example of this. +/// +/// It's easy enough to create an icon button with a filled background +/// using the [Ink] widget. The [Ink] widget renders a decoration on +/// the underlying [Material] along with the splash and highlight +/// [InkResponse] contributed by descendant widgets. +/// +/// {@tool dartpad --template=stateless_widget_scaffold} +/// +/// In this sample the icon button's background color is defined with an [Ink] +/// widget whose child is an [IconButton]. The icon button's filled background +/// is a light shade of blue, it's a filled circle, and it's as big as the +/// button is. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/icon_button_background.png) +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// return Material( +/// color: Colors.white, +/// child: Center( +/// child: Ink( +/// decoration: const ShapeDecoration( +/// color: Colors.lightBlue, +/// shape: CircleBorder(), +/// ), +/// child: IconButton( +/// icon: const Icon(Icons.android), +/// color: Colors.white, +/// onPressed: () {}, +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Icons], a library of predefined icons. +/// * [BackButton], an icon button for a "back" affordance which adapts to the +/// current platform's conventions. +/// * [CloseButton], an icon button for closing pages. +/// * [AppBar], to show a toolbar at the top of an application. +/// * [TextButton], [ElevatedButton], [OutlinedButton], for buttons with text labels and an optional icon. +/// * [InkResponse] and [InkWell], for the ink splash effect itself. +class IconButton extends StatelessWidget { + /// Creates an icon button. + /// + /// Icon buttons are commonly used in the [AppBar.actions] field, but they can + /// be used in many other places as well. + /// + /// Requires one of its ancestors to be a [Material] widget. + /// + /// The [iconSize], [padding], [autofocus], and [alignment] arguments must not + /// be null (though they each have default values). + /// + /// The [icon] argument must be specified, and is typically either an [Icon] + /// or an [ImageIcon]. + const IconButton({ + Key? key, + this.iconSize = 24.0, + this.visualDensity, + this.padding = const EdgeInsets.all(8.0), + this.alignment = Alignment.center, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + required this.onPressed, + this.onLongPressed, + this.mouseCursor = SystemMouseCursors.click, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback = true, + this.constraints, + required this.icon, + }) : assert(iconSize != null), + assert(padding != null), + assert(alignment != null), + assert(splashRadius == null || splashRadius > 0), + assert(autofocus != null), + assert(icon != null), + super(key: key); + + /// The size of the icon inside the button. + /// + /// This property must not be null. It defaults to 24.0. + /// + /// The size given here is passed down to the widget in the [icon] property + /// via an [IconTheme]. Setting the size here instead of in, for example, the + /// [Icon.size] property allows the [IconButton] to size the splash area to + /// fit the [Icon]. If you were to set the size of the [Icon] using + /// [Icon.size] instead, then the [IconButton] would default to 24.0 and then + /// the [Icon] itself would likely get clipped. + final double iconSize; + + /// Defines how compact the icon button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// The padding around the button's icon. The entire padded icon will react + /// to input gestures. + /// + /// This property must not be null. It defaults to 8.0 padding on all sides. + final EdgeInsetsGeometry padding; + + /// Defines how the icon is positioned within the IconButton. + /// + /// This property must not be null. It defaults to [Alignment.center]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry alignment; + + /// The splash radius. + /// + /// If null, default splash radius of [Material.defaultSplashRadius] is used. + final double? splashRadius; + + /// The icon to display inside the button. + /// + /// The [Icon.size] and [Icon.color] of the icon is configured automatically + /// based on the [iconSize] and [color] properties of _this_ widget using an + /// [IconTheme] and therefore should not be explicitly given in the icon + /// widget. + /// + /// This property must not be null. + /// + /// See [Icon], [ImageIcon]. + final Widget icon; + + /// The color for the button's icon when it has the input focus. + /// + /// Defaults to [ThemeData.focusColor] of the ambient theme. + final Color? focusColor; + + /// The color for the button's icon when a pointer is hovering over it. + /// + /// Defaults to [ThemeData.hoverColor] of the ambient theme. + final Color? hoverColor; + + /// The color to use for the icon inside the button, if the icon is enabled. + /// Defaults to leaving this up to the [icon] widget. + /// + /// The icon is enabled if [onPressed] is not null. + /// + /// ```dart + /// IconButton( + /// color: Colors.blue, + /// onPressed: _handleTap, + /// icon: Icons.widgets, + /// ) + /// ``` + final Color? color; + + /// The primary color of the button when the button is in the down (pressed) state. + /// The splash is represented as a circular overlay that appears above the + /// [highlightColor] overlay. The splash overlay has a center point that matches + /// the hit point of the user touch event. The splash overlay will expand to + /// fill the button area if the touch is held for long enough time. If the splash + /// color has transparency then the highlight and button color will show through. + /// + /// Defaults to the Theme's splash color, [ThemeData.splashColor]. + final Color? splashColor; + + /// The secondary color of the button when the button is in the down (pressed) + /// state. The highlight color is represented as a solid color that is overlaid over the + /// button color (if any). If the highlight color has transparency, the button color + /// will show through. The highlight fades in quickly as the button is held down. + /// + /// Defaults to the Theme's highlight color, [ThemeData.highlightColor]. + final Color? highlightColor; + + /// The color to use for the icon inside the button, if the icon is disabled. + /// Defaults to the [ThemeData.disabledColor] of the current [Theme]. + /// + /// The icon is disabled if [onPressed] is null. + final Color? disabledColor; + + /// The callback that is called when the button is tapped or otherwise activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + final VoidCallback? onLongPressed; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// Defaults to [SystemMouseCursors.click]. + final MouseCursor mouseCursor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + /// Optional size constraints for the button. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: kMinInteractiveDimension, + /// minHeight: kMinInteractiveDimension, + /// ) + /// ``` + /// where [kMinInteractiveDimension] is 48.0, and then with visual density + /// applied. + /// + /// The default constraints ensure that the button is accessible. + /// Specifying this parameter enables creation of buttons smaller than + /// the minimum size, but it is not recommended. + /// + /// The visual density uses the [visualDensity] parameter if specified, + /// and `Theme.of(context).visualDensity` otherwise. + final BoxConstraints? constraints; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ThemeData theme = Theme.of(context); + Color? currentColor; + if (onPressed != null) + currentColor = color; + else + currentColor = disabledColor ?? theme.disabledColor; + + final VisualDensity effectiveVisualDensity = + visualDensity ?? theme.visualDensity; + + final BoxConstraints unadjustedConstraints = constraints ?? + const BoxConstraints( + minWidth: _kMinButtonSize, + minHeight: _kMinButtonSize, + ); + final BoxConstraints adjustedConstraints = + effectiveVisualDensity.effectiveConstraints(unadjustedConstraints); + + Widget result = ConstrainedBox( + constraints: adjustedConstraints, + child: Padding( + padding: padding, + child: SizedBox( + height: iconSize, + width: iconSize, + child: Align( + alignment: alignment, + child: IconTheme.merge( + data: IconThemeData( + size: iconSize, + color: currentColor, + ), + child: icon, + ), + ), + ), + ), + ); + + if (tooltip != null) { + result = Tooltip( + message: tooltip!, + child: result, + ); + } + + return Semantics( + button: true, + enabled: onPressed != null, + child: InkResponse( + focusNode: focusNode, + autofocus: autofocus, + canRequestFocus: onPressed != null, + onTap: onPressed, + onLongPress: onLongPressed, + mouseCursor: mouseCursor, + enableFeedback: enableFeedback, + child: result, + focusColor: focusColor ?? theme.focusColor, + hoverColor: hoverColor ?? theme.hoverColor, + highlightColor: highlightColor ?? theme.highlightColor, + splashColor: splashColor ?? theme.splashColor, + radius: splashRadius ?? + math.max( + Material.defaultSplashRadius, + (iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7, + // x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps. + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('icon', icon, showName: false)); + properties.add( + StringProperty('tooltip', tooltip, defaultValue: null, quoted: false)); + properties.add(ObjectFlagProperty('onPressed', onPressed, + ifNull: 'disabled')); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties + .add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add( + ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties + .add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, + defaultValue: null)); + properties.add(DiagnosticsProperty('focusNode', focusNode, + defaultValue: null)); + } +} diff --git a/lib/widgets/markdown_toolbar.dart b/lib/widgets/markdown_toolbar.dart index 8efc99fa..db6d2bae 100644 --- a/lib/widgets/markdown_toolbar.dart +++ b/lib/widgets/markdown_toolbar.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:gitjournal/forks/icon_button_more_gestures.dart' as fork; + // FIXME: // - Pin this on top of the keyboard // - It should only be visible when the keyboard is shown @@ -58,15 +60,17 @@ class MarkdownToolBar extends StatelessWidget { height: 20, child: const VerticalDivider(), ), - IconButton( + fork.IconButton( icon: const Icon(Icons.navigate_before), padding: const EdgeInsets.all(0.0), onPressed: _navigateToPrevWord, + onLongPressed: _addBackTab, ), - IconButton( + fork.IconButton( icon: const Icon(Icons.navigate_next), padding: const EdgeInsets.all(0.0), onPressed: _navigateToNextWord, + onLongPressed: _addTab, ), ], ), @@ -90,6 +94,16 @@ class MarkdownToolBar extends StatelessWidget { var offset = nextWordPos(textController.value); textController.selection = TextSelection.collapsed(offset: offset); } + + // FIXME: Maybe add Tab should work on lines instead? Independent of the cursor pos + // FIXME: Make addTab work for selections as well? + void _addTab() { + textController.value = addTab(textController.value); + } + + void _addBackTab() { + textController.value = addBackTab(textController.value); + } } final _allowedBlockTags = [ @@ -287,3 +301,47 @@ int prevWordPos(TextEditingValue textEditingValue) { return lastSpacePos + 1; } + +TextEditingValue addTab(TextEditingValue textEditingValue) { + var cursorPos = textEditingValue.selection.baseOffset; + var text = textEditingValue.text; + + var newText = ""; + if (cursorPos == text.length) { + newText = text + "\t"; + } else { + newText = text.substring(0, cursorPos) + "\t" + text.substring(cursorPos); + } + + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: cursorPos + 1), + ); +} + +TextEditingValue addBackTab(TextEditingValue textEditingValue) { + var cursorPos = textEditingValue.selection.baseOffset; + var text = textEditingValue.text; + + if (cursorPos <= 0) { + return textEditingValue; + } + + var prevChar = text[cursorPos - 1]; + if (prevChar == '\t') { + var newText = ""; + if (cursorPos - 1 > 1) { + newText += text.substring(0, cursorPos - 1); + } + if (cursorPos != text.length) { + newText += text.substring(cursorPos); + } + + return TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: cursorPos - 1), + ); + } + + return textEditingValue; +} diff --git a/test/markdown_toolbar_test.dart b/test/markdown_toolbar_test.dart index b093b0e5..2094f2f8 100644 --- a/test/markdown_toolbar_test.dart +++ b/test/markdown_toolbar_test.dart @@ -272,5 +272,87 @@ void main() { _testPrevWord(text, 0, 0); }); - // Test that if some text is selected then it should be modified + void _testTab({ + required String before, + required int beforeOffset, + required String after, + required int afterOffset, + }) { + var val = TextEditingValue( + text: before, + selection: TextSelection.collapsed(offset: beforeOffset), + ); + + var expectedVal = TextEditingValue( + text: after, + selection: TextSelection.collapsed(offset: afterOffset), + ); + + expect(addTab(val), expectedVal); + } + + void _testBackTab({ + required String before, + required int beforeOffset, + required String after, + required int afterOffset, + }) { + var val = TextEditingValue( + text: before, + selection: TextSelection.collapsed(offset: beforeOffset), + ); + + var expectedVal = TextEditingValue( + text: after, + selection: TextSelection.collapsed(offset: afterOffset), + ); + + expect(addBackTab(val), expectedVal); + } + + test('Tab', () { + _testTab( + before: 'Hello', + beforeOffset: 0, + after: '\tHello', + afterOffset: 1, + ); + + _testTab( + before: 'Hello', + beforeOffset: 1, + after: 'H\tello', + afterOffset: 2, + ); + + _testTab( + before: 'Hi', + beforeOffset: 2, + after: 'Hi\t', + afterOffset: 3, + ); + + _testBackTab( + before: '\tHello', + beforeOffset: 1, + after: 'Hello', + afterOffset: 0, + ); + + _testBackTab( + before: 'Hi', + beforeOffset: 0, + after: 'Hi', + afterOffset: 0, + ); + + _testBackTab( + before: 'Hi', + beforeOffset: 1, + after: 'Hi', + afterOffset: 1, + ); + }); + + // TODO: Test that if some text is selected then it should be modified }