. 자동 들여쓰기

자동 들여쓰기 기능은 <Enter>키를 눌러 개행을 할 때 앞 줄의 들여쓰기 정도와 똑같은 들여쓰기를 새 줄에 미리 작성하는 기능이다. 프로그래밍 소스들은 {, } 괄호나 begin, end 예약어에 의해 코드의 블록이 구성되며 블록을 쉽게 구분하기 위해 들여쓰기를 하는데 연속되는 줄들은 보통 같은 블록에 속하는 경우가 많기 때문에 들여쓰기 정도가 같다. 그래서 개행을 할 때 프로그램이 알아서 들여쓰기를 하면 새 줄을 작성할 때 일일이 탭을 누르지 않아도 된다.

자동 들여쓰기는 프로그래밍 소스를 편집할 때는 거의 필수적인 기능이지만 일반 텍스트를 편집할 때는 오히려 귀찮은 기능이 될 수도 있다. 그래서 사용자가 이 기능의 동작 여부를 선택할 수 있도록 설정 옵션을 제공하고 원할 때만 자동 들여쓰기를 하는 것이 좋다. 이런 목적으로 bAutoIndent 옵션을 만드는데 옵션 변수는 이미 앞에서 탭과 공백 변환을 할 때 같이 선언해두었으며 TRUE로 초기화하였다.

자동 들여쓰기는 실제로 개행될 때, <Enter>키가 입력될 때 처리되므로 OnChar에서 처리해야 한다. 한 함수에서 모든 것을 다 처리하기에는 너무 큰 기능이므로 자동 들여쓰기를 처리하는 별도의 함수를 만들고 OnChar에서는 이 함수를 호출하도록 하자. bAutoIndent 옵션이 선택되어 있지 않다면 이 함수는 호출할 필요가 없다. OnChar 함수에 다음 코드를 작성한다.

 

void CApiEdit::OnChar(HWND hWnd, TCHAR ch, int cRepeat)

{

     ....

     bComp=FALSE;

     Invalidate(FindParaStart(off-lstrlen(szChar)));

 

    if (bAutoIndent) {

        ProcessIndent(ch);

    }

 

     SetCaret();

}

 

입력된 문자를 일단 처리한 후에 ProcessIndent 함수를 호출하여 자동 들여쓰기를 처리하도록 하였다. OnChar는 입력된 문자가 엔터든 탭이든 또는 { }이든 상관하지 않고 무조건 삽입한 후 나머지 처리를 ProcessIndent 함수에게 일임한다. 이런 구조라면 일단 문자가 삽입되고 자동 들여쓰기에 의해 탭이나 공백이 추가로 삽입될 것이다. 이렇게 하지 말고 OnChar가 먼저 입력된 문자를 보고 이 문자가 들여쓰기와 관련된 문자이면 들여쓰기를 조정한 후 한 번에 삽입하는 것이 훨씬 더 좋을 것 같다.

실제 입력된 문자와 들여쓰기에 의해 삽입되는 여백을 따로 입력하지 않고 한꺼번에 입력하면 취소 레코드도 하나밖에 쓰지 않게 되고 삽입 속도도 빨라지고 여러 모로 깔끔해질 것이다. 그러나 이렇게 하면 좋다는 것은 분명하지만 구현하기는 현실적으로 무척 어렵다. 들여쓰기 기능이란 입력되어 있는 문단의 모양을 보고 판단을 해야 하는데 문자가 실제로 입력되어야만 정확한 판단이 가능하다. 문자가 아직 입력되지 않은 상황에서는 정렬 버퍼에 없는 유령 줄을 있다고 가정하고 분석해야 하므로 조건 판단이 아주 까다로워진다.

그래서 OnChar 함수는 아무 생각없이 입력된 문자를 문서에 삽입하고 ProcessIndent 함수가 들여쓰기 조정을 따로 한 번 더 한다. 이렇게 하더라도 화면은 한 번만 무효화되기 때문에 사용자는 별 차이를 느낄 수 없다. 하지만 한 번에 할 수 있는 작업을 두 번에 나누어 했으므로 취소 레코드가 낭비되는 문제가 있다. 들여쓰기는 삽입 병합에 의해 두 개의 삽입 동작이 하나로 합쳐지지만 내어쓰기는 삽입 레코드와 삭제 레코드가 따로 생긴다. 레코드의 낭비보다는 한 번에 취소할 수 없다는 점이 문제가 되지만 막상 사용해보면 별로 어색하지는 않다.

ProcessIndent 함수는 입력된 문자인 ch만 인수로 받아들이며 이 값과 현재 buf의 상황을 종합적으로 판단하여 자동 들여쓰기를 한다. 들여쓰기를 하건, 내어쓰기를 하건 어떤 방식을 사용하든 모든 것은 이 함수 내부에서 처리되므로 리턴값은 필요 없다. OnChar 함수는 키입력이 있을 때마다 이 함수를 호출하기만 하면 된다. 들여쓰기가 조정된다 하더라도 그 문단 내에서만 조정되므로 무효영역이 변하는 것은 아니다.

문서의 현재 상황을 보고 들여쓰기와 내어쓰기를 제대로 처리하는 것은 아주 복잡한 계산을 필요로 한다. ProcessIndent 함수가 얼마나 많은 일을 해야 할 것인지 짐작이 갈 것이다. 이런 복잡한 일을 혼자서 다 하라고 하기에는 너무 잔인하므로 몇 가지 보조 함수를 작성하도록 하자. 다음 함수들을 CApiEdit 클래스의 멤버로 포함시킨다.

 

class CApiEdit

{

     ....

     void ProcessIndent(TCHAR ch);

     BOOL IsParaEmpty(TCHAR *p);

     int GetPrevPara(int nPos);

     int GetIndentLevel(TCHAR *p);

     void GetIndentString(int nPara,TCHAR *szIndent,int len);

     void MakeIndentString(int nTab, TCHAR *str, int len);

     BOOL IsPrevParaBlockOpen(int nPos);

 

들여쓰기를 처리하는데 저렇게 많은 함수들이 필요하다니 좀 놀랍지 않은가? 사실 이 함수들뿐만 아니라 앞 절에서 작성한 문단 관리 함수들도 들여쓰기를 지원하기 위해 작성된 것인데 이런 것들을 보면 들여쓰기가 과연 작은 기능이 아니라는 것을 알 수 있다. 사람을 대신해서 무엇인가를 판단해야 한다는 것 자체가 쉽지 않은 것이다. 다행히 이 함수들의 코드는 그리 길지 않으며 코드도 쉽다.

 

BOOL CApiEdit::IsParaEmpty(TCHAR *p)

{

     for (;;p++) {

          if (AeIsLineEnd(*p))

              return TRUE;

          if (!AeIsWhiteSpace(*p))

              return FALSE;

     }

}

 

int CApiEdit::GetPrevPara(int nPos)

{

     int pr,pc;

 

     GetParaFromOff(nPos,pr,pc);

     if (pr > 0) pr--;

     while (IsParaEmpty(buf+GetOffFromPara(pr,0)) && pr!=0) pr--;

     return pr;

}

 

int CApiEdit::GetIndentLevel(TCHAR *p)

{

     int level;

 

     for (level=0;;p++) {

          if (*p==‘\t’) {

              level+=TabWidth;

          } else if (*p==‘ ‘){

              level++;

          } else {

              break;

          }

     }

     return level;

}

 

void CApiEdit::GetIndentString(int nPara,TCHAR *szIndent,int len)

{

     TCHAR *p1,*p2;

 

     p1=buf+GetOffFromPara(nPara,0);

     p2=szIndent;

     while (AeIsWhiteSpace(*p1)) {

          *p2++=*p1++;

          if (p2-szIndent == len-1)

              break;

     }

     *p2=0;

}

 

void CApiEdit::MakeIndentString(int nTab, TCHAR *str, int len)

{

     int i;

 

     if (bSpaceForTab) {

          for (i=0;i<nTab*TabWidth && i < len-1;i++) {

              str[i]=‘ ‘;

          }

     } else {

          for (i=0;i<nTab && i < len-1;i++) {

              str[i]=‘\t’;

          }

     }

     str[i]=0;

}

 

BOOL CApiEdit::IsPrevParaBlockOpen(int nPos)

{

     int tlen;

     int r;

     int toff;

 

     tlen=lstrlen(Parser->GetInfo(5));

     if (tlen==0) {

          return FALSE;

     }

 

     r=GetParaLastLine(GetPrevPara(nPos));

 

     toff=pLine[r].End;

     if (toff)

          toff--;

     while (AeIsWhiteSpace(buf[toff]) && toff > 0)

          toff--;

 

     if (toff >= tlen-1) {

          toff=toff-tlen+1;

          if (_strnicmp(buf+toff,Parser->GetInfo(5),tlen)==0) {

              return TRUE;

          }

     }

     return FALSE;

}

 

유효 앞문 단

들여쓰기 기능은 항상 앞 문단의 상태를 참조하여 새 문단을 작성한다. 앞 문단이 탭 두 개로 두 칸 들여쓰기 되어 있다면 다음 문단도 두 칸 들여쓰고 앞 문단에 공백이 여섯 개 있다면 다음 문단도 공백 여섯 개로 들여쓰기를 한다. 그렇다면 현재 문단이 nPara일 때 nPara-1문단의 들여쓰기를 참조하면 될까? 다음 그림을 보자.

왼쪽 그림은 첫 번째 줄에 두 칸 들여쓰기 되어 있고 두 번째 줄 끝에서 <Enter>를 친 경우이다. 이때 세 번째 줄은 2칸 들여쓰기를 하는 것이 맞을까 4칸 들여쓰기를 하는 것이 맞을까? 바로 위 문단을 참조한다면 4칸을 들여써야겠지만 문서 모양으로 볼 때 2칸을 들여쓰는 것이 옳다. 오른쪽 그림은 두 번째 줄에 전혀 들여쓰기가 되어 있지 않은 상태이다. 이 상태에서 <Enter>를 치면 세 번째 줄은 들여쓰기를 하지 말아야 할까 아니면 첫 번째 줄처럼 2칸을 들여쓰야 할까?

상식적으로 생각해 볼 때 공백으로만 되어 있는 빈 줄의 들여쓰기 상태를 참조하는 것은 합당하지 않다. 빈 줄이 아닌 앞 줄, 즉 글자를 하나라도 가지고 있는 줄을 유효 앞 문단이라고 하며 들여쓰기는 이 유효 앞 문단의 상태를 참조해야 한다. GetPrevPara 함수는 nPos 위치에서 유효 앞 문단의 번호를 찾아준다. 현재 문단에서 위쪽으로 이동하면서 비어있지 않은 최초의 문단을 찾아주면 되는데 단, 첫 문단은 항상 유효한 것으로 가정한다. 만약 첫 문단이 공백으로만 되어 있다고 해서 무효한 것으로 취급해버리면 참조할 문단이 없게 되므로 자동 들여쓰기를 할 수가 없게 된다.

IsParaEmpty 함수는 p번지 이후 개행코드를 만날 때까지 비어 있는지 아닌지를 판단한다. 개행코드를 만날 때까지 공백으로만 되어 있으면 비어 있는 줄이고 글자가 하나라도 발견되면 비어 있지 않은 줄이다. 탭과 공백은 아무리 많아도 글자로 인정되지 않는다.

들여쓰기 정도 조사

유효 앞 문단을 구했으면 이 문단의 들여쓰기 정도만큼 다음 문단 선두에 들여쓰기를 작성해야 한다. GetIndentString 함수는 nPara 문단의 선두에 있는 탭과 공백 문자열을 szIndent 버퍼에 복사한다. 유효 앞 문단의 들여쓰기 문자열을 그대로 다음 문단의 선두에 복사하면 자동 들여쓰기가 된다.

이 함수의 특징은 탭과 공백의 조합을 그대로 유지하면서 szIndent로 복사한다는 점이다. 문단 선두에 \t---- 문자열이 있으면 이 문자열을 그대로 복사할 뿐 \t\t로 바꾸지 않는다. 공백 4칸이 탭문자 하나와 같더라도 탭과 공백을 함부로 전환하지 않고 있는 그대로 복사함으로써 사용자가 들여쓰기 해놓은 방식을 그대로 존중하는 것이다. 설사 --\t----\t--와 같이 복잡한 들여쓰기를 했더라도 말이다.

나머지 세 함수는 자동 들여쓰기에서는 사용되지 않으며 문법 들여쓰기에서만 사용되므로 다음 항에서 분석해보도록 하자. 자동 들여쓰기를 처리하는 ProcessIndent 함수는 다음과 같이 작성된다. 이 함수는 엔터코드가 입력되었을 때만 자동 들여쓰기를 하며 그 외의 문자는 무시한다. OnChar 함수에서 입력된 엔터코드를 이미 문서에 삽입한 상태로 이 함수가 호출된다.

 

void CApiEdit::ProcessIndent(TCHAR ch)

{

     TCHAR szIndent[4096];

     int toff;

 

     if (ch == ‘\r’) {

          GetIndentString(GetPrevPara(off),szIndent, 4096);

 

          StartUndoGroup();

          toff=off;

          while (AeIsWhiteSpace(buf[toff]) && !AeIsLineEnd(buf[toff])) toff++;

          if (toff != off) {

              Delete(off,toff-off);

          }

 

          if (lstrlen(szIndent)) {

              Insert(off,szIndent);

              off+=lstrlen(szIndent);

          }

          EndUndoGroup();

     }

}

 

보조 함수들이 많은 일을 하기 때문에 막상 이 함수는 별로 복잡하지 않다. 유효 앞 문단의 들여쓰기 문자열을 szIndent 버퍼에 조사하고 이 문자열을 새 문단에 그대로 복사했다. <Enter>에 의해 개행된 줄에 자동으로 들여쓰기가 된다.

보다시피 자동 들여쓰기란 아주 간단하다. 그런데 ProcessIndent 함수의 코드가 이 보다 더 복잡한 걸로 봐서는 다른 민감한 문제가 있는 모양이다. 만약 문단의 끝에서 <Enter>를 누르지 않고 공백이 있는 중간에서 <Enter>를 누르면 다음 문단의 선두에 이미 공백이 있는 상태가 된다. 이때는 다음 문단의 선두까지 내려간 공백을 어떻게 처리할 것인가가 문제가 된다. 다음 그림을 보자.

<Enter>키와 함께 다음 줄로 내려온 문자열 선두에 공백이 이미 있다면 이 공백을 유지한 채로 자동 들여쓰기를 할 것인가 아니면 제거한 후 자동 들여쓰기를 할 것인가에 따라 결과가 달라진다. 원래 있던 공백을 유지한 채로 들여쓰기를 하면 아래위 문단의 들여쓰기가 일치하지 않게 되고 들여쓰기를 강제로 맞추려면 이미 입력되어 있는 공백을 제거해야 한다. 이 두 방식 중 어떤 방식이 옳은가에 대해서는 논쟁의 여지가 있는데 당근은 공백을 제거한 후 들여쓰기를 맞추는 방식을 선택했다.

두 방식 다 틀린 것도 아니고 또한 두 방식 중 어느 하나가 맞는 것도 아니다. 이런 애매한 동작에 대해서는 두 방식 모두를 다 채용하고 사용자가 설정 옵션으로 방식을 선택하도록 하는 정책이 좋으며 지금까지 그런 식으로 설정 옵션을 만들어 왔다. 하지만 이런 동작방식을 설정 옵션으로 만들기에는 아주 곤란한 문제가 하나 있다. 설정 옵션에 따라 공백을 처리하도록 코드를 작성하는 것 정도야 식은 죽 먹기지만 그 설정의 이름을 무엇으로 할 것인가가 문제다.

문단 중간의 공백 앞에서 개행시 공백 유지하면서 들여쓰기 이런 이름을 붙여 놓으면 100명중 99명은 저게 도대체 무슨 말이지?라고 할 것이다. 옵션을 바꿔 가며 <Enter>키를 눌러 봐도 뭐가 다른지 얼른 파악하기도 힘들다. 지금 코드를 작성하고 있는 여러분들과 개발자 자신은 이 옵션의 의미에 대해서 잘 알고 있지만 이것을 짧은 말로 설명하기는 정말 어렵다. 당근의 옵션 중 bUseLineEnd를 사용자에게 이해시키기 어렵듯이 말이다. 이런 옵션은 만들어 봐야 사용자에게 선택을 자유를 부여한다는 긍정적인 측면보다 괜히 프로그램을 어렵게 보이도록 하는 부정적인 효과가 더 크기 때문에 차라리 안 만드는 것이 더 낫다.