新增代码文件支持行号显示

This commit is contained in:
jyuesong
2022-06-15 11:05:49 +08:00
parent 4e3f8a0df9
commit 2005083d2e
117 changed files with 4625 additions and 136 deletions

View File

@@ -0,0 +1,2 @@
export 'src/code_field.dart';
export 'src/code_controller.dart';

View File

@@ -0,0 +1,273 @@
import 'dart:math';
import 'package:code_text_field/src/code_modifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:highlight/highlight_core.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
const _MIDDLE_DOT = '·';
class EditorParams {
final int tabSpaces;
const EditorParams({this.tabSpaces = 2});
}
class CodeController extends TextEditingController {
/// A highlight language to parse the text with
final Mode? language;
/// The theme to apply to the [language] parsing result
final Map<String, TextStyle>? theme;
/// A map of specific regexes to style
final Map<String, TextStyle>? patternMap;
/// A map of specific keywords to style
final Map<String, TextStyle>? stringMap;
/// Common editor params such as the size of a tab in spaces
///
/// Will be exposed to all [modifiers]
final EditorParams params;
/// A list of code modifiers to dynamically update the code upon certain keystrokes
final List<CodeModifier> modifiers;
/// On web, replace spaces with invisible dots “·” to fix the current issue with spaces
///
/// https://github.com/flutter/flutter/issues/77929
final bool webSpaceFix;
/// onChange callback, called whenever the content is changed
final void Function(String)? onChange;
/* Computed members */
final String languageId = _genId();
final styleList = <TextStyle>[];
final modifierMap = <String, CodeModifier>{};
RegExp? styleRegExp;
CodeController({
String? text,
this.language,
this.theme,
this.patternMap,
this.stringMap,
this.params = const EditorParams(),
this.modifiers = const <CodeModifier>[
const IntendModifier(),
const CloseBlockModifier(),
const TabModifier(),
],
this.webSpaceFix = true,
this.onChange,
}) : super(text: text) {
// PatternMap
if (language != null && theme == null) throw Exception("A theme must be provided for language parsing");
// Register language
if (language != null) {
highlight.registerLanguage(languageId, language!);
}
// Create modifier map
modifiers.forEach((el) {
modifierMap[el.char] = el;
});
}
/// Sets a specific cursor position in the text
void setCursor(int offset) {
selection = TextSelection.collapsed(offset: offset);
}
/// Replaces the current [selection] by [str]
void insertStr(String str) {
final sel = selection;
text = text.replaceRange(selection.start, selection.end, str);
final len = str.length;
selection = sel.copyWith(
baseOffset: sel.start + len,
extentOffset: sel.start + len,
);
}
/// Remove the char just before the cursor or the selection
void removeChar() {
if (selection.start < 1) return;
final sel = selection;
text = text.replaceRange(selection.start - 1, selection.start, "");
selection = sel.copyWith(
baseOffset: sel.start - 1,
extentOffset: sel.start - 1,
);
}
/// Remove the selected text
void removeSelection() {
final sel = selection;
text = text.replaceRange(selection.start, selection.end, "");
selection = sel.copyWith(
baseOffset: sel.start,
extentOffset: sel.start,
);
}
/// Remove the selection or last char if the selection is empty
void backspace() {
if (selection.start < selection.end)
removeSelection();
else
removeChar();
}
KeyEventResult onKey(RawKeyEvent event) {
if (event.isKeyPressed(LogicalKeyboardKey.tab)) {
text = text.replaceRange(selection.start, selection.end, "\t");
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// See webSpaceFix
static String _spacesToMiddleDots(String str) {
return str.replaceAll(" ", _MIDDLE_DOT);
}
/// See webSpaceFix
static String _middleDotsToSpaces(String str) {
return str.replaceAll(_MIDDLE_DOT, " ");
}
/// Get untransformed text
/// See webSpaceFix
String get rawText {
if (!_webSpaceFix) return super.text;
return _middleDotsToSpaces(super.text);
}
// Private methods
bool get _webSpaceFix => kIsWeb && webSpaceFix;
static String _genId() {
const _chars = 'abcdefghijklmnopqrstuvwxyz1234567890';
final _rnd = Random();
return String.fromCharCodes(
Iterable.generate(10, (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length))),
);
}
int? _insertedLoc(String a, String b) {
final sel = selection;
if (a.length + 1 != b.length || sel.start != sel.end) return null;
return sel.start;
}
@override
set value(TextEditingValue newValue) {
final loc = _insertedLoc(text, newValue.text);
if (loc != null) {
final char = newValue.text[loc];
final modifier = modifierMap[char];
final val = modifier?.updateString(rawText, selection, params);
if (val != null) {
// Update newValue
newValue = newValue.copyWith(
text: val.text,
selection: val.selection,
);
}
}
// Now fix the textfield for web
if (_webSpaceFix) newValue = newValue.copyWith(text: _spacesToMiddleDots(newValue.text));
if (onChange != null) onChange!(_webSpaceFix ? _middleDotsToSpaces(newValue.text) : newValue.text);
super.value = newValue;
}
TextSpan _processPatterns(String text, TextStyle? style) {
final children = <TextSpan>[];
text.splitMapJoin(
styleRegExp!,
onMatch: (Match m) {
if (styleList.isEmpty) return '';
int idx;
for (idx = 1; idx < m.groupCount && idx <= styleList.length && m.group(idx) == null; idx++) {}
children.add(TextSpan(
text: m[0],
style: styleList[idx - 1],
));
return '';
},
onNonMatch: (String span) {
children.add(TextSpan(text: span, style: style));
return '';
},
);
return TextSpan(style: style, children: children);
}
TextSpan _processLanguage(String text, TextStyle? style) {
final rawText = _webSpaceFix ? _middleDotsToSpaces(text) : text;
final result = highlight.parse(rawText, language: languageId);
final nodes = result.nodes;
final children = <TextSpan>[];
var currentSpans = children;
final stack = <List<TextSpan>>[];
void _traverse(Node node) {
var val = node.value;
final nodeChildren = node.children;
if (val != null) {
if (_webSpaceFix) val = _spacesToMiddleDots(val);
var child = TextSpan(text: val, style: theme?[node.className]);
if (styleRegExp != null) child = _processPatterns(val, theme?[node.className]);
currentSpans.add(child);
} else if (nodeChildren != null) {
List<TextSpan> tmp = [];
currentSpans.add(TextSpan(
children: tmp,
style: theme?[node.className],
));
stack.add(currentSpans);
currentSpans = tmp;
nodeChildren.forEach((n) {
_traverse(n);
if (n == nodeChildren.last) {
currentSpans = stack.isEmpty ? children : stack.removeLast();
}
});
}
}
if (nodes != null) for (var node in nodes) _traverse(node);
return TextSpan(style: style, children: children);
}
@override
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, bool? withComposing}) {
// Retrieve pattern regexp
final patternList = <String>[];
if (_webSpaceFix) {
patternList.add("(" + _MIDDLE_DOT + ")");
styleList.add(TextStyle(color: Colors.transparent));
}
if (stringMap != null) {
patternList.addAll(stringMap!.keys.map((e) => r'(\b' + e + r'\b)'));
styleList.addAll(stringMap!.values);
}
if (patternMap != null) {
patternList.addAll(patternMap!.keys.map((e) => "(" + e + ")"));
styleList.addAll(patternMap!.values);
}
styleRegExp = RegExp(patternList.join('|'), multiLine: true);
// Return parsing
if (language != null)
return _processLanguage(text, style);
else if (styleRegExp != null)
return _processPatterns(text, style);
else
return TextSpan(text: text, style: style);
}
}

View File

@@ -0,0 +1,336 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
import './code_controller.dart';
class LineNumberController extends TextEditingController {
final TextSpan Function(int, TextStyle?)? lineNumberBuilder;
LineNumberController(this.lineNumberBuilder);
@override
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, bool? withComposing}) {
final children = <TextSpan>[];
final list = text.split("\n");
for (int k = 0; k < list.length; k++) {
final el = list[k];
final number = int.parse(el);
var textSpan = TextSpan(text: el, style: style);
if (lineNumberBuilder != null) textSpan = lineNumberBuilder!(number, style);
children.add(textSpan);
if (k < list.length - 1) children.add(TextSpan(text: "\n"));
}
return TextSpan(children: children, style: style);
}
}
class LineNumberStyle {
/// Width of the line number column
final double width;
/// Alignment of the numbers in the column
final TextAlign textAlign;
/// Style of the numbers
final TextStyle? textStyle;
/// Background of the line number column
final Color? background;
/// Central horizontal margin between the numbers and the code
final double margin;
const LineNumberStyle({
this.width = 42.0,
this.textAlign = TextAlign.right,
this.margin = 10.0,
this.textStyle,
this.background,
});
}
class CodeField extends StatefulWidget {
/// {@macro flutter.widgets.textField.minLines}
final SmartQuotesType smartQuotesType;
/// {@macro flutter.widgets.textField.minLines}
final int? minLines;
/// {@macro flutter.widgets.textField.maxLInes}
final int? maxLines;
/// {@macro flutter.widgets.textField.expands}
final bool expands;
/// Whether overflowing lines should wrap around or make the field scrollable horizontally
final bool wrap;
/// A CodeController instance to apply language highlight, themeing and modifiers
final CodeController controller;
/// A LineNumberStyle instance to tweak the line number column styling
final LineNumberStyle lineNumberStyle;
/// {@macro flutter.widgets.textField.cursorColor}
final Color? cursorColor;
/// {@macro flutter.widgets.textField.textStyle}
final TextStyle? textStyle;
/// A way to replace specific line numbers by a custom TextSpan
final TextSpan Function(int, TextStyle?)? lineNumberBuilder;
/// {@macro flutter.widgets.textField.enabled}
final bool? enabled;
/// {@macro flutter.widgets.editableText.onChanged}
final void Function(String)? onChanged;
/// {@macro flutter.widgets.editableText.readOnly}
final bool readOnly;
final Color? background;
final EdgeInsets padding;
final Decoration? decoration;
final TextSelectionThemeData? textSelectionTheme;
final FocusNode? focusNode;
final void Function()? onTap;
final bool hideColumn;
const CodeField({
Key? key,
required this.controller,
this.minLines,
this.maxLines,
this.expands = false,
this.wrap = false,
this.background,
this.decoration,
this.textStyle,
this.padding = const EdgeInsets.symmetric(),
this.lineNumberStyle = const LineNumberStyle(),
this.enabled,
this.hideColumn = false,
this.onTap,
this.readOnly = false,
this.cursorColor,
this.textSelectionTheme,
this.lineNumberBuilder,
this.focusNode,
this.onChanged,
this.smartQuotesType = SmartQuotesType.enabled,
}) : super(key: key);
@override
CodeFieldState createState() => CodeFieldState();
}
class CodeFieldState extends State<CodeField> {
// Add a controller
LinkedScrollControllerGroup? _controllers;
ScrollController? _numberScroll;
ScrollController? _codeScroll;
LineNumberController? _numberController;
StreamSubscription<bool>? _keyboardVisibilitySubscription;
FocusNode? _focusNode;
String? lines;
String longestLine = "";
ScrollController? getCodeScroll() {
return _codeScroll;
}
@override
void initState() {
super.initState();
_controllers = LinkedScrollControllerGroup();
_numberScroll = _controllers?.addAndGet();
_codeScroll = _controllers?.addAndGet();
_numberController = LineNumberController(widget.lineNumberBuilder);
widget.controller.addListener(_onTextChanged);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode!.onKey = _onKey;
_focusNode!.attach(context, onKey: _onKey);
_onTextChanged();
}
ScrollController? codeScrollController() {
return _codeScroll;
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (widget.readOnly) return KeyEventResult.ignored;
return widget.controller.onKey(event);
}
@override
void dispose() {
widget.controller.removeListener(_onTextChanged);
_numberScroll?.dispose();
_codeScroll?.dispose();
_numberController?.dispose();
_keyboardVisibilitySubscription?.cancel();
super.dispose();
}
void rebuild() {
setState(() {});
}
void _onTextChanged() {
// Rebuild line number
final str = widget.controller.text.split("\n");
final buf = <String>[];
for (var k = 0; k < str.length; k++) {
buf.add((k + 1).toString());
}
_numberController?.text = buf.join("\n");
// Find longest line
longestLine = "";
widget.controller.text.split("\n").forEach((line) {
if (line.length > longestLine.length) longestLine = line;
});
setState(() {});
}
// Wrap the codeField in a horizontal scrollView
Widget _wrapInScrollView(Widget codeField, TextStyle textStyle, double minWidth) {
final leftPad = widget.lineNumberStyle.margin / 2;
final intrinsic = IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: 0.0,
minWidth: max(minWidth - leftPad, 0.0),
),
child: Padding(
child: Text(longestLine, style: textStyle),
padding: const EdgeInsets.only(right: 16.0),
), // Add extra padding
),
widget.expands ? Expanded(child: codeField) : codeField,
],
),
);
return SingleChildScrollView(
padding: EdgeInsets.only(
left: leftPad,
right: widget.padding.right,
),
scrollDirection: Axis.horizontal,
child: intrinsic,
);
}
@override
Widget build(BuildContext context) {
// Default color scheme
const ROOT_KEY = 'root';
final defaultBg = Colors.grey.shade900;
final defaultText = Colors.grey.shade200;
final theme = widget.controller.theme;
Color? backgroundCol = widget.background ?? theme?[ROOT_KEY]?.backgroundColor ?? defaultBg;
if (widget.decoration != null) {
backgroundCol = null;
}
TextStyle textStyle = widget.textStyle ?? TextStyle();
textStyle = textStyle.copyWith(
color: textStyle.color ?? theme?[ROOT_KEY]?.color ?? defaultText,
fontSize: textStyle.fontSize ?? 16.0,
);
TextStyle numberTextStyle = widget.lineNumberStyle.textStyle ?? TextStyle();
final numberColor = (theme?[ROOT_KEY]?.color ?? defaultText).withOpacity(0.7);
// Copy important attributes
numberTextStyle = numberTextStyle.copyWith(
color: numberTextStyle.color ?? numberColor,
fontSize: textStyle.fontSize,
fontFamily: textStyle.fontFamily,
);
final cursorColor = widget.cursorColor ?? theme?[ROOT_KEY]?.color ?? defaultText;
final lineNumberCol = TextField(
smartQuotesType: widget.smartQuotesType,
scrollPadding: widget.padding,
style: numberTextStyle,
controller: _numberController,
enabled: false,
minLines: widget.minLines,
maxLines: widget.maxLines,
expands: widget.expands,
scrollController: _numberScroll,
decoration: InputDecoration(
disabledBorder: InputBorder.none,
),
textAlign: widget.lineNumberStyle.textAlign,
);
final numberCol = Container(
width: widget.lineNumberStyle.width,
padding: EdgeInsets.only(
left: widget.padding.left,
right: widget.lineNumberStyle.margin / 2,
),
color: widget.lineNumberStyle.background,
child: lineNumberCol,
);
final codeField = TextField(
smartQuotesType: widget.smartQuotesType,
focusNode: _focusNode,
onTap: widget.onTap,
scrollPadding: widget.padding,
style: textStyle,
controller: widget.controller,
minLines: widget.minLines,
maxLines: widget.maxLines,
expands: widget.expands,
scrollController: _codeScroll,
decoration: InputDecoration(
disabledBorder: InputBorder.none,
border: InputBorder.none,
focusedBorder: InputBorder.none,
),
cursorColor: cursorColor,
autocorrect: false,
enableSuggestions: false,
enabled: widget.enabled,
onChanged: widget.onChanged,
readOnly: widget.readOnly,
);
final codeCol = Theme(
data: Theme.of(context).copyWith(
textSelectionTheme: widget.textSelectionTheme,
),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Control horizontal scrolling
return widget.wrap ? codeField : _wrapInScrollView(codeField, textStyle, constraints.maxWidth);
},
),
);
return Container(
decoration: widget.decoration,
color: backgroundCol,
child: widget.hideColumn
? codeCol
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
numberCol,
Expanded(child: codeCol),
],
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'dart:math';
import 'package:code_text_field/code_text_field.dart';
import 'package:flutter/material.dart';
abstract class CodeModifier {
final String char;
const CodeModifier(this.char);
// Helper to insert [str] in [text] between [start] and [end]
TextEditingValue replace(String text, int start, int end, String str) {
final len = str.length;
return TextEditingValue(
text: text.replaceRange(start, end, str),
selection: TextSelection(
baseOffset: start + len,
extentOffset: start + len,
),
);
}
TextEditingValue? updateString(String text, TextSelection sel, EditorParams params);
}
class IntendModifier extends CodeModifier {
final bool handleBrackets;
const IntendModifier({
this.handleBrackets = true,
}) : super('\n');
@override
TextEditingValue? updateString(String text, TextSelection sel, EditorParams params) {
var spacesCount = 0;
var braceCount = 0;
for (var k = min(sel.start, text.length) - 1; k >= 0; k--) {
if (text[k] == "\n") break;
if (text[k] == " ")
spacesCount += 1;
else
spacesCount = 0;
if (text[k] == "{")
braceCount += 1;
else if (text[k] == "}") braceCount -= 1;
}
if (braceCount > 0) spacesCount += params.tabSpaces;
final insert = "\n" + " " * spacesCount;
return replace(text, sel.start, sel.end, insert);
}
}
class CloseBlockModifier extends CodeModifier {
const CloseBlockModifier() : super('}');
@override
TextEditingValue? updateString(String text, TextSelection sel, EditorParams params) {
int spaceCount = 0;
for (var k = min(sel.start, text.length) - 1; k >= 0; k--) {
if (text[k] == "\n") break;
if (text[k] != " ") {
spaceCount = 0;
break;
}
spaceCount += 1;
}
if (spaceCount >= params.tabSpaces) return replace(text, sel.start - params.tabSpaces, sel.end, "}");
return null;
}
}
class TabModifier extends CodeModifier {
const TabModifier() : super('\t');
@override
TextEditingValue? updateString(String text, TextSelection sel, EditorParams params) {
final tmp = replace(text, sel.start, sel.end, " " * params.tabSpaces);
return tmp;
}
}