진행했던 외주 중에 repl.it이나 코랩처럼 웹으로 코딩이 가능한 사이트를 만들어야 했던 게 있었다. front를 flutter로 구현하고 back은 flask로 구현했는데, flutter상에서 코드 에디터를 만드는 문제로 시간을 좀 썼다.
구글링을 많이 해보고 플러터 소스코드를 좀 뜯어보니 여러 방법이 있었다.
이미 구현된 패키지들
일단 이미 구현된 패키지들은 여러 종류가 있는데, 대부분은 editing이 안되고 준비된 text를 rendering해주는 것들이었다. 또 어떤 것은 정확히 내가 원하는 것이었는데, 좀 지난 것이어서 flutter 버전과 호환되지 않아 쓸 수가 없었다. (플러터의 TextField를 상속받는데 버전이 올라가면서 unimplemented properties가 생김)
그 외에는 내가 원하는 구현에 비해 너무 큰 프로젝트여서 쓸데없이 복잡해지는 등의 일이 생겼다.
html 위젯을 가져오기
html과 js로는 이미 코드 에디터가 많이 구현되어 있을 거다. systax highlight rule도 수많은 언어마다 잘 정리되어 있을 것이기에 좋은 방법임은 분명했다. 그러나 모든 것이 dart 코드 안에서 처리가 되지 않으니 귀찮을 것 같았고, js채널도 따로 만들어주어야 했다.
요약하자면 이 방법 조사하다가 귀찮을 것 같아서 안했다.
기존 플러터 위젯을 확장하기
TextField는 TextEditingController에서 buildTextSpan에서 TextSpan을 받아와 렌더링하는 구조이다. 코드 에디터에서 일부 텍스트만 highlight(coloring)한다는 것은 Text Span으로 덩어리를 분류해 스타일을 입힌 뒤 합치는 방법으로 구현하는 것이므로, TextEditingController를 상속받아 buildTextSpan method를 override하면 구현할 수 있을 것 같았다.
그래서 시도를 해봤더니 컬러링은 잘 되던데, 커서 제어나 스크롤 제어가 완전 개판이 되어서... 좀 더 소스코드를 뜯어봐야 했는데 생각보다 더 수고스러울 것 같아 다른 방법을 찾게 시작했다.
빠른 시간 안에 해결하기 위해서 나는 결국 다소 트릭적인(?) 방법으로 해결했다. 바로 Stack으로 Text 위에 TextField를 입힌 뒤 TextField를 transparent처리하는 것이다.
교묘하게 TextField의 Text와 그 뒤에 Text위젯을 정확히 겹치고, 스크롤 제어를 controller를 써서 잘 하면 사용자 입장에선 그냥 하나의 TextField를 쓰는 것과 다름 없는 것이다.
기본 구조는 아래와 같다.
class ScriptField extends StatefulWidget {
final TextEditingController controller;
ScriptField({this.controller});
_ScriptFieldState createState() => _ScriptFieldState();
}
class _ScriptFieldState extends State<ScriptField> {
String _code = '';
int _fontSize = 14;
TextStyle _style1 = TextStyle(
color: Colors.black,
fontSize: 14,
);
TextStyle _style2 = TextStyle(
color: Colors.transparent,
fontSize: 14,
);
void initState() {
super.initState();
_code = widget.controller.text;
}
Widget build(BuildContext context) {
return Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 8.0, 8.0, 8.0),
child: Text(
_code,
style: _style1,
),
),
TextField(
onChanged: _onChanged,
maxLines: null,
controller: widget.controller,
style: _style2,
expands: true,
decoration: InputDecoration(
border: InputBorder.none,
labelText: null,
hintText: 'Code your script.',
),
),
],
);
}
void _onChanged(String text) {
setState(() {
_code = text;
});
}
}
위아래 패딩은 TextField가 기본적으로 8만큼 들어가 있어서 Text에도 추가해줘야 정확히 겹친다.(당연히 fromLTRB가 아니라 symmetric으로 해도 상관 없다.)
expands 옵션은 true로 해줘야 한다. false면 line이 1, 2줄일 때 TextField에서 텍스트 위치가 바뀌면서 뒤에 Text위젯하고 안겹치게 된다.
문제는 TextField가 multiline=null이기 때문에 자동으로 스크롤이 된다는 부분이다.
그래서 아래와 같이 Text를 SingleChildScrollView로 묶어줘야 한다.
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 8.0, 8.0, 8.0),
child: SingleChildScrollView(
child: Text(
_code,
style: _style1,
),
),
),
근데 Text는 Stack으로 TextField에 가려지기 때문에, 마우스로 스크롤이 되지 않는다.(스크롤이벤트는 가장 위 레이어인 TextField에만 적용된다)
그리고 마우스 커서 뿐만 아니라 키보드 커서에 의해서도 스크롤이 될 수 있는데, 이 역시 Text위젯은 반응하지 않는다.
그래서 ScrollController를 사용했다.
final ScrollController _scrollController1 = ScrollController();
final ScrollController _scrollController2 = ScrollController();
1개만 선언해서 SingleChildScrollView와 TextField가 같은 걸 공유하면 되지 않나 싶었는데 그러지 않았다. 그래서 한쪽에서 scroll되면 event listener를 붙여서 동기화되게 해주었다.
void initState() {
super.initState();
_code = widget.controller.text;
_scrollController2.addListener(() {
if (_scrollController1.hasClients && _scrollController2.hasClients)
_scrollController1.jumpTo(_scrollController2.position.pixels);
});
}
scrollController는 attach/detach 등의 이벤트가 있어서, hasClients 조건을 확인해줘야 jump하면서 예외를 만들지 않는다.
그리고 ScrollController에 대해서 dispose처리를 해주면 끝이다.
void dispose() {
super.dispose();
_scrollController1.dispose();
_scrollController2.dispose();
}
이제 Text위젯과 TextField는 (겉으로 보기에) sync된 것이다!
비교를 위해 TextField은 Colors.red값을 주고, Text에 LeftPadding을 8만큼 줬다.
transparent 처리하고 padding을 맞추면 두 위젯의 텍스트가 정확히 일치하게 된다.
이제 Text를 가지고 노는 일만 남았다!
TextSpan을 이용하기 위해 Text 위젯을 RichText로 바꿔보자
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 8.0, 8.0, 8.0),
child: SingleChildScrollView(
controller: _scrollController1,
child: RichText(
text: TextSpan(
style: _style1,
children: [
TextSpan(text: _code)
]
),
)
),
),
근데 이러니까 갑자기 Text 위치가 안맞는다... height, width가 서로 다른 것 같은데, 일단 TextSpan과 TextField의 TextStyle에 height값을 모두 1로 주면 세로로는 위치가 맞게 된다.
가로가 문제인데, 기본 폰트에서는 TextStyle에서 letterSpacing값을 조절하면 '얼추' 맞게 된다. 근데 폰트를 코딩용 폰트로 바꾸면 알아서 잘 맞게 된다.(아무래도 character별로 width가 달라서 생기는 문제같다.)
나는 consolas를 커스텀 폰트로 추가해 적용했다.
이제 하이라이팅을 해보자. R 언어 문법을 기반으로 해보겠다. highlight_rule.dart파일을 만들어보자.
import 'package:flutter/material.dart';
class SyntaxHighlighter {
final String quotedStringRegExp;
final RegExp _quotedStringRegExp;
final List<List<String>> highlightWords;
final List<Color> highlightColors;
final List<TextStyle> _highlightStyles;
final Color quotedStringColor;
final Color defaultColor;
final TextStyle _defaultStyle;
final TextStyle _quotedStringStyle;
TextStyle baseStyle;
static final List<String> _defaultDelimiters = [' ', '\t'] + r', < . > / ? ; : \ | ! @ # $ % ^ & * ( ) - _ = + [ ] ` ~'.split(' ');
SyntaxHighlighter({
this.quotedStringRegExp = r"((?<![\\])['" '"' r"])((?:.(?!(?<![\\])\1))*.?)\1",
this.highlightWords,
this.highlightColors,
this.defaultColor = Colors.black,
this.quotedStringColor = Colors.red,
this.baseStyle,
}) :
assert(highlightWords != null),
assert(highlightColors != null),
assert(highlightWords.length == highlightColors.length),
assert(baseStyle != null),
_quotedStringRegExp = RegExp(quotedStringRegExp),
_highlightStyles = highlightColors.map((e) => TextStyle(color: e, inherit: true)).toList(),
_quotedStringStyle = TextStyle(color: quotedStringColor, inherit: true),
_defaultStyle = TextStyle(color: defaultColor, inherit: true);
List<TextSpan> highlight(String code) {
List<String> tokenized = tokenizeAll(code);
List<TextStyle> mergedStyles = _highlightStyles.map((e) => baseStyle.merge(e)).toList();
TextStyle mergedQuotedStringStyle = baseStyle.merge(_quotedStringStyle);
TextStyle mergedDefaultStyle = baseStyle.merge(_defaultStyle);
List<TextSpan> spans = [];
for(String token in tokenized) {
if (token == '') continue;
TextStyle style;
String trimToken = token.trim();
bool found = false;
for(int i = 0; i < highlightWords.length; i++) {
if (highlightWords[i].contains(trimToken)) {
found = true;
style = mergedStyles[i];
break;
}
}
if (!found) {
if((trimToken.startsWith('"') && trimToken.endsWith('"')) || (trimToken.startsWith("'") && trimToken.endsWith("'")))
style = mergedQuotedStringStyle;
else
style = mergedDefaultStyle;
}
spans.add(TextSpan(text: token, style: style));
}
return spans;
}
List<String> splitKeepingDelimiter(String text, {String delimiter='\n'}) {
List<String> splited = text.split(delimiter);
splited = splited.asMap().entries.map((e) {
return (e.key == splited.length) ? e.value : (e.value + delimiter);
}).toList();
return splited;
}
List<String> splitLine(String text, {List<String> delimiterList}) {
if (delimiterList == null)
delimiterList = _defaultDelimiters;
List<String> tokens = [];
List<String> chars = text.split('');
int currentIdx = 0;
int lastIdx = 0;
while (currentIdx < text.length) {
if (delimiterList.contains(chars[currentIdx])) {
String tk = text.substring(lastIdx, currentIdx);
if (tk != '')
tokens.add(tk);
tokens.add(text.substring(currentIdx, currentIdx + 1));
lastIdx = currentIdx + 1;
}
currentIdx++;
}
if (lastIdx != currentIdx)
tokens.add(text.substring(lastIdx, currentIdx));
return tokens;
}
/// input all source code
List<String> tokenizeAll(String allText, {quotedStringRegExp}) {
List<RegExpMatch> qsMatches = _quotedStringRegExp.allMatches(allText).toList();
List<String> textChunks = [];
if (qsMatches.length > 0) {
int lastEnd = 0;
qsMatches.forEach((element) {
textChunks += splitKeepingDelimiter(allText.substring(lastEnd, element.start));
textChunks.add(allText.substring(element.start, element.end));
lastEnd = element.end;
});
if (qsMatches[qsMatches.length - 1].end < allText.length)
textChunks += splitKeepingDelimiter(allText.substring(qsMatches[qsMatches.length - 1].end, allText.length));
}
else
textChunks += splitKeepingDelimiter(allText);
List<String> tokenized = [];
for (String chunk in textChunks)
tokenized += tokenizeLine(chunk);
return tokenized;
}
List<String> tokenizeLine(String lineText) {
if (lineText.startsWith('"') || lineText.startsWith("'"))
return <String> [lineText];
else
return splitLine(lineText);
}
}
class RSyntaxHighlighter extends SyntaxHighlighter {
static final List<String> keywords = 'function if in break next repeat else for while ...'.split(' ');
static final List<String> literals = 'NULL NA TRUE FALSE Inf NaN NA_integer_ NA_real_ NA_character_ NA_complex_'.split(' ');
static final List<String> builtIns = ('abs acos acosh all any anyNA Arg as.call as.character ' +
'as.complex as.double as.environment as.integer as.logical ' +
'as.null.default as.numeric as.raw asin asinh atan atanh attr ' +
'attributes baseenv browser c call ceiling class Conj cos cosh ' +
'cospi cummax cummin cumprod cumsum digamma dim dimnames ' +
'emptyenv exp expression floor forceAndCall gamma gc.time ' +
'globalenv Im interactive invisible is.array is.atomic is.call ' +
'is.character is.complex is.double is.environment is.expression ' +
'is.finite is.function is.infinite is.integer is.language ' +
'is.list is.logical is.matrix is.na is.name is.nan is.null ' +
'is.numeric is.object is.pairlist is.raw is.recursive is.single ' +
'is.symbol lazyLoadDBfetch length lgamma list log max min ' +
'missing Mod names nargs nzchar oldClass on.exit pos.to.env ' +
'proc.time prod quote range Re rep retracemem return round ' +
'seq_along seq_len seq.int sign signif sin sinh sinpi sqrt ' +
'standardGeneric substitute sum switch tan tanh tanpi tracemem ' +
'trigamma trunc unclass untracemem UseMethod xtfrm').split(' ');
RSyntaxHighlighter({TextStyle baseStyle}) : super(
highlightWords: [keywords, literals, builtIns],
highlightColors: [Colors.yellow, Colors.blue, Colors.green],
baseStyle: baseStyle
);
SyntaxHighlighter를 대충 만들고 RSyntaxHighlighter에서는 keyword와 literal만 지정해주었다. 사실 컬러링해줘야 할 요소는 저거 말고 많지만.. 그냥 대충 했다.
이제 SyntaxHighlighter를 ScriptField에서 받아주고 span을 적용한 뒤, 위젯을 호출하는 쪽에서 하이라이터를 만들어서 넘겨주면 된다.
이를 좀 일반화 하기 위해서 아래와 같은 코드를 highlight_rule.dart에 추가했다.
import 'package:flutter/material.dart';
typedef SyntaxHighlighter SyntaxHighlighterGenerator({List<dynamic> args, Map<String, dynamic> kwargs});
SyntaxHighlighterGenerator getHighlighterWithName(String languageName) {
languageName = languageName.toLowerCase();
SyntaxHighlighterGenerator g;
switch(languageName) {
case 'r': g = ({List<dynamic> args, Map<String, dynamic> kwargs}) => RSyntaxHighlighter.fromMap(args: args, kwargs: kwargs); break;
default: g = ({List<dynamic> args, Map<String, dynamic> kwargs}) => SyntaxHighlighter.fromMap(args: args, kwargs: kwargs); break;
}
return g;
}
class SyntaxHighlighter {
...
static SyntaxHighlighter fromMap({List<dynamic> args, Map<String, dynamic> kwargs}) {
assert(args == null, 'SyntaxHighlighter does not need positional arguments');
assert(kwargs != null, 'SyntaxHighlighter needs keyworded arguments.');
var contains = kwargs.keys.contains;
return SyntaxHighlighter(
quotedStringRegExp: contains('quotedStringRegExp') ? kwargs['quotedStringRegExp'] : r"((?<![\\])['" '"' r"])((?:.(?!(?<![\\])\1))*.?)\1",
highlightWords: kwargs['highlightWords'],
highlightColors: kwargs['highlightColors'],
quotedStringColor: contains('quotedStringColor') ? kwargs['quotedStringColor'] : Colors.red,
defaultColor: contains('defaultColor') ? kwargs['defaultColor'] : Colors.black,
baseStyle: kwargs['baseStyle']
);
}
}
class RSyntaxHighlighter extends SyntaxHighlighter {
...
static SyntaxHighlighter fromMap({List<dynamic> args, Map<String, dynamic> kwargs}) {
assert(args == null, 'RSyntaxHighlighter does not need positional arguments');
assert(kwargs != null, 'RSyntaxHighlighter needs keyworded arguments.');
return RSyntaxHighlighter(baseStyle: kwargs['baseStyle']);
}
}
이러면 언어 이름만으로 쉽게 highlighter를 가져올 수 있다.
Python 쓸 때는 몰랐다. args와 *kwargs가 그렇게 좋은 건지...
이제 ScriptField를 수정하자.
import 'highlight_rule.dart';
class ScriptField extends StatefulWidget {
...
final String language;
ScriptField({..., this.language});
...
}
class _ScriptFieldState extends State<ScriptField> {
...
TextStyle _style1 = TextStyle(
fontFamily: 'consolas',
color: Colors.black,
fontSize: 14,
height: 1,
);
SyntaxHighlighter highlighter;
void initState() {
...
highlighter = getHighlighterWithName(widget.language)(
kwargs: {'baseStyle': _style1}
);
}
...
Widget build(BuildContext context) {
return Stack(
children: [
Padding(
...,
child: SingleChildScrollView(
...,
child: RichText(
text: this.highlighter.highlight(_code),
)
),
),
TextField(...),
),
],
);
}
...
}
완성이다!
이제 이런 식으로 ScriptField 위젯을 쓸 수 있다.
TextEditingController scriptController; // need to initialize.
Widget Build(...) {
...
return Widget(
...
Container(
height: 100,
width: 100,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ScriptField(controller: this.scriptController, language: 'R'),
),
),
...
);
}
이 글을 쓰고 나서 몇 가지 기능을 더 추가했다. 근데 코드로 남기기가 귀찮아져서... 이렇게 글로라도 남긴다.