Syntax Highlighting이 되는 Code Editor 구현하기(with TextField)

나용수·2021년 2월 16일
0

진행했던 외주 중에 repl.it이나 코랩처럼 웹으로 코딩이 가능한 사이트를 만들어야 했던 게 있었다. front를 flutter로 구현하고 back은 flask로 구현했는데, flutter상에서 코드 에디터를 만드는 문제로 시간을 좀 썼다.

구글링을 많이 해보고 플러터 소스코드를 좀 뜯어보니 여러 방법이 있었다.

  1. 이미 구현된 패키지들
  2. html 위젯을 가져오기
  3. 기존 플러터 위젯을 확장하기

이미 구현된 패키지들
일단 이미 구현된 패키지들은 여러 종류가 있는데, 대부분은 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'),
					  ),
					),
		...
		);
}

이 포스트엔 없는 개선점들

이 글을 쓰고 나서 몇 가지 기능을 더 추가했다. 근데 코드로 남기기가 귀찮아져서... 이렇게 글로라도 남긴다.

  • defaultColor, quotedStringColor처럼 numberColor와 _numberStyle도 추가해서 숫자에도 하이라이팅을 넣었다.
  • onChanged마다 이렇게 파싱하는 건 컴퓨팅적으로 별로다(안그래도 flutter web이 별로라..). 그래서 onChanged가 아닌 timer를 이용해 highlighting했다.
  • comment도 컬러 처리 해줬다.
  • 소스코드의 로직들은 되게 불안정하다. 그래서 대거 고쳤다. 최종 코드는 이곳에..
  • 사실 가장 중요한 건 highlight method라서.. 그 외에는 자기 입맛대로 고치면 된다. RegExp를 적극활용하든...

시도 안해본 개선점

  • SyntaxHighlighter가 다 for문으로 진짜 무식하게 파싱하고 있는데, 이를 RegExp를 사용해볼 수 있겠다.
    • 성능이 좋아질 수도?
    • 근데 RegExp가 있다고 모든 문법이 완벽하게 하이라이팅될 수 있는 건 아니다. 아무래도 lexer와 tokenizer를 체계적으로 설계해야... 컴파일러나 언어론을 공부해 볼 필요가 있다.
  • 나한테는 해당사항이 없는데, ScriptField의 parent 위젯에 따라서 width를 초과하는 text를 softwrap하면서 Text와 TextField의 텍스트가 겹치지 않고 엇갈리는 경우가 있다. 프로퍼티를 좀만 손 보면 될 것 같긴 하다.
profile
컴퓨터에 관심 많은 산업공학과 학부생. 슬프게도 지금은 대한민국 육군에서 복무 중입니다.

0개의 댓글