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..ac319426c79a 100644 --- a/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart +++ b/playground/frontend/lib/pages/embedded_playground/widgets/embedded_editor.dart @@ -37,7 +37,6 @@ class EmbeddedEditor extends StatelessWidget { return SnippetEditor( 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..57c1ba3fa70e 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 @@ -56,7 +56,6 @@ class CodeTextAreaWrapper extends StatelessWidget { child: SnippetEditor( controller: snippetController, isEditable: true, - goToContextLine: true, ), ), Positioned( 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..5963837f8c50 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,31 @@ class SnippetEditingController extends ChangeNotifier { } } + void _toStartOfContextLineIfAny() { + final contextLine1Based = selectedExample?.contextLine; + + if (contextLine1Based == null) { + return; + } + + _toStartOfFullLine(max(contextLine1Based - 1, 0)); + } + + void _toStartOfFullLine(int line) { + if (line >= codeController.code.lines.length) { + return; + } + + 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..2ca76d107b45 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_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 index fe7ecc4e6037..ddcba180b893 100644 --- a/playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart +++ b/playground/frontend/playground_components/lib/src/widgets/snippet_editor.dart @@ -16,31 +16,120 @@ * limitations under the License. */ -import 'package:flutter/widgets.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 'editor_textarea.dart'; +import '../theme/theme.dart'; -class SnippetEditor extends StatelessWidget { +class SnippetEditor extends StatefulWidget { final SnippetEditingController controller; final bool isEditable; - final bool goToContextLine; - const SnippetEditor({ + SnippetEditor({ required this.controller, required this.isEditable, - required this.goToContextLine, - }); + }) : super( + // When the example is changed, will scroll to the context line again. + key: ValueKey(controller.selectedExample), + ); + + @override + State createState() => _SnippetEditorState(); +} + +class _SnippetEditorState extends State { + bool _didAutoFocus = false; + final _focusNode = FocusNode(); + final _scrollController = ScrollController(); + + @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() { + _focusNode.dispose(); + super.dispose(); + } @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, + final ext = Theme.of(context).extension()!; + final isMultiFile = widget.controller.selectedExample?.isMultiFile ?? false; + final isEnabled = widget.isEditable && !isMultiFile; + + return Semantics( + container: true, + enabled: isEnabled, + label: 'widgets.codeEditor.label', + multiline: true, + readOnly: isEnabled, + textField: true, + child: FocusScope( + 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), + controller: widget.controller.codeController, + enabled: isEnabled, + focusNode: _focusNode, + 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, + ); +}