From 401b0981554c4c2e678cec25812121ea31042dd1 Mon Sep 17 00:00:00 2001 From: Alexey Inkin Date: Thu, 15 Dec 2022 14:21:17 +0400 Subject: [PATCH] Refactor focusing to contextLine (#24613) --- .../widgets/embedded_editor.dart | 3 +- .../widgets/editor_textarea_wrapper.dart | 3 +- .../lib/playground_components.dart | 2 +- .../snippet_editing_controller.dart | 24 +++ .../lib/src/models/example_base.dart | 2 + .../lib/src/widgets/editor_text_area.dart | 139 ++++++++++++++ .../lib/src/widgets/editor_textarea.dart | 178 ------------------ .../lib/src/widgets/snippet_editor.dart | 46 ----- 8 files changed, 168 insertions(+), 229 deletions(-) create mode 100644 playground/frontend/playground_components/lib/src/widgets/editor_text_area.dart delete mode 100644 playground/frontend/playground_components/lib/src/widgets/editor_textarea.dart delete mode 100644 playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart diff --git a/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart b/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart index 94000034f299..0fa598808127 100644 --- a/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart +++ b/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart @@ -34,10 +34,9 @@ class EmbeddedEditor extends StatelessWidget { return const LoadingIndicator(); } - return SnippetEditor( + return EditorTextArea( controller: snippetController, isEditable: isEditable, - goToContextLine: false, ); } } diff --git a/playground/frontend/lib/pages/standalone_playground/widgets/editor_textarea_wrapper.dart b/playground/frontend/lib/pages/standalone_playground/widgets/editor_textarea_wrapper.dart index 65c6e4177c60..8319648b095f 100644 --- a/playground/frontend/lib/pages/standalone_playground/widgets/editor_textarea_wrapper.dart +++ b/playground/frontend/lib/pages/standalone_playground/widgets/editor_textarea_wrapper.dart @@ -53,10 +53,9 @@ class CodeTextAreaWrapper extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: SnippetEditor( + child: EditorTextArea( controller: snippetController, isEditable: true, - goToContextLine: true, ), ), Positioned( diff --git a/playground/frontend/playground_components/lib/playground_components.dart b/playground/frontend/playground_components/lib/playground_components.dart index 9e08eaff733f..2f59ee9dd8f8 100644 --- a/playground/frontend/playground_components/lib/playground_components.dart +++ b/playground/frontend/playground_components/lib/playground_components.dart @@ -66,6 +66,7 @@ export 'src/widgets/clickable.dart'; export 'src/widgets/complexity.dart'; export 'src/widgets/dismissible_overlay.dart'; export 'src/widgets/divider.dart'; +export 'src/widgets/editor_text_area.dart'; export 'src/widgets/header_icon_button.dart'; export 'src/widgets/loading_error.dart'; export 'src/widgets/loading_indicator.dart'; @@ -75,7 +76,6 @@ export 'src/widgets/reset_button.dart'; export 'src/widgets/run_or_cancel_button.dart'; export 'src/widgets/shortcut_tooltip.dart'; export 'src/widgets/shortcuts_manager.dart'; -export 'src/widgets/snippet_editor.dart'; export 'src/widgets/split_view.dart'; export 'src/widgets/tab_header.dart'; export 'src/widgets/toasts/toast_listener.dart'; diff --git a/playground/frontend/playground_components/lib/src/controllers/snippet_editing_controller.dart b/playground/frontend/playground_components/lib/src/controllers/snippet_editing_controller.dart index 195c0bd1e105..e88f2d755431 100644 --- a/playground/frontend/playground_components/lib/src/controllers/snippet_editing_controller.dart +++ b/playground/frontend/playground_components/lib/src/controllers/snippet_editing_controller.dart @@ -16,6 +16,8 @@ * limitations under the License. */ +import 'dart:math'; + import 'package:flutter/widgets.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:get_it/get_it.dart'; @@ -77,6 +79,7 @@ class SnippetEditingController extends ChangeNotifier { codeController.removeListener(_onCodeControllerChanged); setSource(example.source); _applyViewOptions(viewOptions); + _toStartOfContextLineIfAny(); codeController.addListener(_onCodeControllerChanged); notifyListeners(); @@ -100,6 +103,27 @@ class SnippetEditingController extends ChangeNotifier { } } + void _toStartOfContextLineIfAny() { + final contextLine1Based = selectedExample?.contextLine; + + if (contextLine1Based == null) { + return; + } + + _toStartOfFullLine(max(contextLine1Based - 1, 0)); + } + + void _toStartOfFullLine(int line) { + final fullPosition = codeController.code.lines.lines[line].textRange.start; + final visiblePosition = codeController.code.hiddenRanges.cutPosition( + fullPosition, + ); + + codeController.selection = TextSelection.collapsed( + offset: visiblePosition, + ); + } + Example? get selectedExample => _selectedExample; ExampleLoadingDescriptor? get descriptor => _descriptor; diff --git a/playground/frontend/playground_components/lib/src/models/example_base.dart b/playground/frontend/playground_components/lib/src/models/example_base.dart index dd9b7fcb34c4..d70018b602eb 100644 --- a/playground/frontend/playground_components/lib/src/models/example_base.dart +++ b/playground/frontend/playground_components/lib/src/models/example_base.dart @@ -50,6 +50,8 @@ extension ExampleTypeToString on ExampleType { /// These objects are fetched as lists from [ExampleRepository]. class ExampleBase with Comparable, EquatableMixin { final Complexity? complexity; + + // Index of the line to focus, 1-based. final int contextLine; final String description; final bool isMultiFile; diff --git a/playground/frontend/playground_components/lib/src/widgets/editor_text_area.dart b/playground/frontend/playground_components/lib/src/widgets/editor_text_area.dart new file mode 100644 index 000000000000..c9ae16a1a45b --- /dev/null +++ b/playground/frontend/playground_components/lib/src/widgets/editor_text_area.dart @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO(alexeyinkin): Refactor this, merge into snippet_editor.dart + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; + +import '../controllers/snippet_editing_controller.dart'; +import '../theme/theme.dart'; + +class EditorTextArea extends StatefulWidget { + final SnippetEditingController controller; + final bool isEditable; + + EditorTextArea({ + required this.controller, + required this.isEditable, + }) : super( + // When the example is changed, will scroll to the context line again. + key: ValueKey(controller.selectedExample), + ); + + @override + State createState() => _EditorTextAreaState(); +} + +class _EditorTextAreaState extends State { + bool _didAutoFocus = false; + final _focusNode = FocusNode(); + final _scrollController = ScrollController(); + final _sizeKey = LabeledGlobalKey('CodeFieldKey'); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (!_didAutoFocus) { + _didAutoFocus = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scrollSoCursorIsOnTop(); + } + }); + } + } + + void _scrollSoCursorIsOnTop() { + _focusNode.requestFocus(); + + final position = max(widget.controller.codeController.selection.start, 0); + final characterOffset = _getLastCharacterOffset( + text: widget.controller.codeController.text.substring(0, position), + style: kLightTheme.extension()!.codeRootStyle, + ); + + _scrollController.jumpTo( + min( + characterOffset.dy, + _scrollController.position.maxScrollExtent, + ), + ); + } + + @override + void dispose() { + super.dispose(); + _focusNode.dispose(); + } + + @override + Widget build(BuildContext context) { + final ext = Theme.of(context).extension()!; + final isMultifile = widget.controller.selectedExample?.isMultiFile ?? false; + final isEnabled = widget.isEditable && !isMultifile; + + return Semantics( + container: true, + textField: true, + multiline: true, + enabled: isEnabled, + readOnly: isEnabled, + label: 'widgets.codeEditor.label', + child: FocusScope( + key: _sizeKey, + node: FocusScopeNode(canRequestFocus: isEnabled), + child: CodeTheme( + data: ext.codeTheme, + child: Container( + color: ext.codeTheme.styles['root']?.backgroundColor, + child: SingleChildScrollView( + controller: _scrollController, + child: CodeField( + key: ValueKey(widget.controller.codeController), + focusNode: _focusNode, + enabled: isEnabled, + controller: widget.controller.codeController, + textStyle: ext.codeRootStyle, + ), + ), + ), + ), + ), + ); + } +} + +Offset _getLastCharacterOffset({ + required String text, + required TextStyle style, +}) { + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan(text: text, style: style), + )..layout(); + + return textPainter.getOffsetForCaret( + TextPosition(offset: text.length), + Rect.zero, + ); +} diff --git a/playground/frontend/playground_components/lib/src/widgets/editor_textarea.dart b/playground/frontend/playground_components/lib/src/widgets/editor_textarea.dart deleted file mode 100644 index 571415a1e71f..000000000000 --- a/playground/frontend/playground_components/lib/src/widgets/editor_textarea.dart +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// TODO(alexeyinkin): Refactor this, merge into snippet_editor.dart - -import 'package:flutter/material.dart'; -import 'package:flutter_code_editor/flutter_code_editor.dart'; - -import '../models/example.dart'; -import '../models/sdk.dart'; -import '../theme/theme.dart'; - -const kJavaRegExp = r'import\s[A-z.0-9]*\;\n\n[(\/\*\*)|(public)|(class)]'; -const kPythonRegExp = r'[^\S\r\n](import|as)[^\S\r\n][A-z]*\n\n'; -const kGoRegExp = r'[^\S\r\n]+\' - r'"' - r'.*' - r'"' - r'\n\)\n\n'; -const kAdditionalLinesForScrolling = 4; - -class EditorTextArea extends StatefulWidget { - final CodeController codeController; - final Sdk sdk; - final Example? example; - final bool enabled; - final bool isEditable; - final bool goToContextLine; - - const EditorTextArea({ - super.key, - required this.codeController, - required this.sdk, - this.example, - required this.enabled, - required this.isEditable, - required this.goToContextLine, - }); - - @override - State createState() => _EditorTextAreaState(); -} - -class _EditorTextAreaState extends State { - var focusNode = FocusNode(); - final GlobalKey _sizeKey = LabeledGlobalKey('CodeFieldKey'); - - @override - void dispose() { - super.dispose(); - focusNode.dispose(); - } - - @override - Widget build(BuildContext context) { - if (widget.goToContextLine) { - WidgetsBinding.instance.addPostFrameCallback((_) => _setTextScrolling()); - } - - final ext = Theme.of(context).extension()!; - - return Semantics( - container: true, - textField: true, - multiline: true, - enabled: widget.enabled, - readOnly: widget.enabled, - label: 'widgets.codeEditor.label', - child: FocusScope( - key: _sizeKey, - node: FocusScopeNode(canRequestFocus: widget.isEditable), - child: CodeTheme( - data: ext.codeTheme, - child: Container( - color: ext.codeTheme.styles['root']?.backgroundColor, - child: SingleChildScrollView( - child: CodeField( - key: ValueKey(widget.codeController), - focusNode: focusNode, - enabled: widget.enabled, - controller: widget.codeController, - textStyle: ext.codeRootStyle, - ), - ), - ), - ), - ), - ); - } - - void _setTextScrolling() { - focusNode.requestFocus(); - if (widget.codeController.text.isNotEmpty) { - widget.codeController.selection = TextSelection.fromPosition( - TextPosition( - offset: _getOffset(), - ), - ); - } - } - - int _getOffset() { - int contextLine = _getIndexOfContextLine(); - String pattern = _getPattern(_getQntOfStringsOnScreen()); - if (pattern == '' || pattern == '}') { - return widget.codeController.text.lastIndexOf(pattern); - } - - return widget.codeController.text.indexOf( - pattern, - contextLine, - ); - } - - String _getPattern(int qntOfStrings) { - int contextLineIndex = _getIndexOfContextLine(); - List stringsAfterContextLine = - widget.codeController.text.substring(contextLineIndex).split('\n'); - - String result = - stringsAfterContextLine.length + kAdditionalLinesForScrolling > - qntOfStrings - ? _getResultSubstring(stringsAfterContextLine, qntOfStrings) - : stringsAfterContextLine.last; - - return result; - } - - int _getQntOfStringsOnScreen() { - final renderBox = _sizeKey.currentContext!.findRenderObject()! as RenderBox; - final height = renderBox.size.height * .75; - - return height ~/ codeFontSize; - } - - int _getIndexOfContextLine() { - final contextLine = widget.example!.contextLine; - final code = widget.codeController.code; - final fullCharIndex = code.lines.lines[contextLine].textRange.start; - final visibleCharIndex = code.hiddenRanges.cutPosition(fullCharIndex); - - return visibleCharIndex; - } - - // This function made for more accuracy in the process of finding an exact line. - String _getResultSubstring( - List stringsAfterContextLine, - int qntOfStrings, - ) { - StringBuffer result = StringBuffer(); - - for (int i = qntOfStrings - kAdditionalLinesForScrolling; - i < qntOfStrings + kAdditionalLinesForScrolling; - i++) { - if (i == stringsAfterContextLine.length - 1) { - return result.toString(); - } - result.write(stringsAfterContextLine[i] + '\n'); - } - - return result.toString(); - } -} diff --git a/playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart b/playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart deleted file mode 100644 index fe7ecc4e6037..000000000000 --- a/playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 'package:flutter/widgets.dart'; - -import '../controllers/snippet_editing_controller.dart'; -import 'editor_textarea.dart'; - -class SnippetEditor extends StatelessWidget { - final SnippetEditingController controller; - final bool isEditable; - final bool goToContextLine; - - const SnippetEditor({ - required this.controller, - required this.isEditable, - required this.goToContextLine, - }); - - @override - Widget build(BuildContext context) { - return EditorTextArea( - codeController: controller.codeController, - sdk: controller.sdk, - enabled: !(controller.selectedExample?.isMultiFile ?? false), - example: controller.selectedExample, - isEditable: isEditable, - goToContextLine: goToContextLine, - ); - } -}