. PrevX

ApiEdit1에는 상하이동시 캐럿이 한글의 중간에 걸치는 버그가 있었으며 이미 앞에서 그림까지 보여주면서 자수했었다. 상하이동시 또 다른 문제도 있다. 첫 줄에 우리나라 대한민국이라고 써 놓고 두 번째 줄에 첫 줄의 길이만큼 마침표를 찍어 보자. 그리고 자 다음에 캐럿을 위치시킨 후 아래로 이동해보면 증상을 확인할 수 있다.

자 바로 아래로 캐럿이 내려가는 것이 아니라 두 번째 줄 중간 위치로 이동한다. 자 다음의 열번호는 17번이고 이 열번호에 해당하는 두 번째 줄의 위치가 바로 중간쯤 되는 것이다. 왜 그런가 하면 마침표의 글자폭이 한글에 비해서는 훨씬 작아 같은 폭이라도 더 많은 문자가 들어가기 때문이다. 우리가 기대하는 대로 되려면 마침표의 폭이 한글의 절반이 되어야 하나 윈도우즈 기본 시스템 폰트는 가변폭을 가지기 때문에 글자마다 폭이 제 각각이다.

이 두 가지 문제점보다 더 큰 문제는 상하이동시 캐럿의 수평좌표를 보관하지 않는다는 점이다. 다음과 같이 문장을 입력해보고 상하로 이동을 해보아라.

첫 줄 제일 뒤의 자에 캐럿을 위치시킨 후 아래로 이동하면 두 번째 줄의 마지막 글자인 자로 이동할 것이다. 두 번째 줄이 첫 번째 줄보다 길이가 짧기 때문에 마지막 위치로 가는 것이 당연하다. 첫 번째 줄의 자 위치에 해당하는 위치가 두 번째 줄에는 없다.

그렇다면 두 번째 줄의 자로 이동한 상태에서 다시 한 칸 더 아래로 이동해보자. 이때는 수직으로 바로 아래에 있는 자 다음으로 이동할 것이다. 세 번째 줄에는 자 바로 아래에 글자가 있으므로 수평위치를 그대로 유지하면서 아래로 내려올 수가 있다. 언뜻 보기에 두 번의 이동에서 어떤 문제점이 있는지 잘 파악되지 않겠지만 이런 식의 캐럿이동은 아주 조잡한 것이다.

편집기는 상하이동시 수평위치를 보존해야 한다. 두 번째 줄이 길이가 짧아 어쩔 수 없이 대응되는 수평위치를 찾지 못했다 하더라도 다시 세 번째 줄로 갈 때는 첫 번째 줄의 수평위치대로 이동해야 한다. 비주얼 C++의 에디터에 똑같은 문장을 입력해놓고 테스트해보면 수평위치가 보존됨을 알 수 있다.

이런 수평위치 보존을 하지 않는 유일한 편집기는 메모장밖에 없다. 즉 표준 컨트롤인 Edit 컨트롤은 수평위치를 보존하지 않는다. 아주 사소한 것 같지만 조금이라도 사용자를 생각한다면 반드시 구현해야 할 필수 기능이다. 다음과 같은 문장을 입력하고 있다고 해보자.

 

우리나라는 무척 살기좋고 아름다울 나라입

니다.

그래서 나는 우리나라를 사랑합니다.

 

즐겁게 타이핑을 하고 있는데 사랑합니다.까지 써 놓고 보니 첫 번째 줄의 오타가 눈에 보였다. 그래서 이 오타를 수정하고 싶은데 위로 두 칸 이동해서는 이 위치로 바로 갈 수 없다. 왜냐하면 가운데 줄이 짧아 위로 두 칸 올리면 첫 줄의 세 번째 문자인 자 다음 위치로 캐럿이 이동하기 때문이다. 그래서 위로 일단 두 칸 올린 후 다시 오른쪽으로 이동해야 하는데 실제 겪어 보면 무척 불편하고 자주 겪다 보면 짜증이 날 정도다. 사용자들은 저 문장을 입력하는 중에 첫 줄의 자로 가고 싶으면 두 칸 위로 이동하면 된다고 기대한다.

인터넷 게시판의 글들은 줄간이 너무 좁기 때문에 읽기 쉽게 하기 위해 한 줄씩 빈 줄을 넣는 것이 관행적이다. 이런 문장에서 수평위치를 보존하지 않는다면 상하이동 기능으로 문서를 제대로 이동하기 어려워진다. 아래위로 이동하면 빈 줄 때문에 캐럿은 무조건 줄의 처음으로 가버리기 때문이다. 이렇게 되면 얼마나 신경질이 나겠는가? 다행히 웹브라우저의 텍스트 필드 컨트롤들은 수평위치를 잘 보존한다.

이런 기능이 가능하려면 편집기는 항상 수평 X 좌표를 저장해두어야 하며 상하이동시 저장해 둔 X 좌표로 가도록 하고 저장위치는 건드리지 말아야 한다. 좌우이동시에만 저장위치가 변경된다. 그럼 이제 코드를 작성해보자. 수평위치는 PrevX라는 변수에 기억시키도록 할 것이다. 다음과 같이 전역변수를 선언한다.

 

int PrevX;

 

편집기가 처음 실행될 때 캐럿은 화면의 좌상단에 있으므로 수평위치의 초기값은 0이다. OnCreate에서 PrevX 0으로 초기화한다.

 

BOOL OnCreate(HWND hWnd, LPCREATESTRUCT lpCreateStruct)

{

     ....

     PrevX=0;

 

이제 캐럿의 위치가 변경될 때마다 PrevX에 현재 수평좌표를 저장해야 한다. 좌우이동은 물론이고 Home, End, 마우스 클릭, 북마크 이동, 윈도우 크기 변화 등등 많은 곳에서 PrevX의 값을 갱신해야 하는데 다행히 이 모든 함수들을 수정할 필요는 없다. 왜냐하면 캐럿의 위치가 변경될 때는 항상 SetCaret을 호출하므로 SetCaret에서만 PrevX를 변경하면 된다. , 항상 PrevX를 변경하는 것은 아니며 PrevX를 유지해야 할 때도 있다. 그래서 SetCaret PrevX를 갱신할 것인가 아닌가를 인수로 전달받도록 하였다. 이 함수의 원형을 다음과 같이 수정한다.

 

void SetCaret(BOOL bUpdatePrevX=TRUE);

 

bUpdatePrevX라는 인수를 추가했는데 디폴트 인수값은 TRUE이다. 디폴트 인수이므로 이 함수를 호출하는 곳은 수정할 필요없이 예전대로 SetCaret()이라고 불러주면 된다. 본체에 다음 코드를 작성한다.

 

void SetCaret(BOOL bUpdatePrevX/*=TRUE*/)

{

     ....

     if (bUpdatePrevX) {

          PrevX=x;

     }

}

 

bUpdatePrevX TRUE일 때 PrevX를 방금 계산한 캐럿 위치로 갱신한다. 대부분의 경우 디폴트 인수인 TRUE를 적용하면 된다. 그러나 상하이동시에는 이 인수를 FALSE로 주어 저장된 수평좌표를 그대로 유지해야 하며 OnSetFocus PrevX를 건드리지 말아야 한다.

 

void OnSetFocus(HWND hWnd, HWND hwndOldFocus)

{

    SetCaret(FALSE);

}

 

OnSetFocus SetCaret을 호출하는 이유는 숨겨진 캐럿을 보이도록 하라는 뜻이지 캐럿의 위치를 옮기라는 뜻이 아니다. 그래서 PrevX는 갱신하지 말아야 한다. 상하이동중에 다른 프로그램을 잠시 사용하고 다시 편집기로 돌아왔을 때 PrevX가 변경되어 있으면 곤란하다. 이제 PrevX는 항상 최후의 수평위치를 저장하고 있을 것이며 상하이동시 이 좌표를 참조하여 이동할 오프셋을 찾으면 된다. 이 작업을 대신하는 함수를 추가해보자.

 

int GetXPosOnLine(int r,int DestX)

{

     int s,e;

     HDC hdc;

     TCHAR *p;

     int len, acwidth;

 

     GetLine(r,s,e);

     if (DestX == 0) {

          return s;

     }

     hdc=GetDC(hWndMain);

     for (p=buf+s, acwidth=0;;) {

          if (p-buf == e)

               break;

 

          if (IsDBCS(p-buf)) {

               len=2;

          } else {

               len=1;

          }

          acwidth+=GetCharWidth(hdc,p,len);

          p+=len;

         

          if (acwidth >= DestX) {

               break;

          }

     }

     ReleaseDC(hWndMain,hdc);

     return p-buf;

}

 

새로운 함수를 추가했으므로 소스 선두에 함수 원형도 당연히 추가해야 한다. 앞으로의 실습 과정에서 함수를 추가하면 따로 설명하지 않아도 원형은 알아서 추가하기 바란다. 이 함수는 r 줄에서 인수로 전달된 DestX 좌표에 해당하는 오프셋을 찾아준다. GetLine으로 r 줄의 범위를 먼저 구하는데 찾는 오프셋은 s~e사이에 존재할 것이다.

s에서 시작해서 루프를 돌며 누적폭을 acwidth에 더해 나간다. 루프를 탈출할 조건은 두 가지가 있다. 첫 번째는 e에 이르렀을 경우, 즉 줄의 끝을 만난 경우인데 이때는 DestX에 도달하지 못했더라도 즉시 리턴해야 한다. 두 번째는 누적폭이 DestX보다 크거나 같을 때인데 이 위치가 바로 찾고자 하는 위치가 된다. 비교 연산자는 반드시 >=여야 한다. > ==가 아니다. 일단 DestX와 같은 좌표를 찾도록 하되 일치하는 좌표가 없을 수도 있으므로 더 커지는 경우도 조건에 포함된다.

이 함수에서 p를 증가시키는 위치에 대해 생각해보자. 소스를 보면 루프를 돌 때마다 p len만큼 증가시켜 다음 글자로 이동하는데 이 명령이 acwidth DestX를 비교하는 문장보다 더 앞에 있어야 한다. 왜 그런가 하면 acwidth p 위치 문자까지의 누적폭이며 따라서 이때 구하고자 하는 오프셋은 p 문자를 포함해야 한다. 그래서 누적폭을 점검하기 전에 일단 p를 다음 문자로 이동시켜 놓아야 하는 것이다.

예를 들어 ABCD 문자열이 있는데 ABC까지 폭을 합산한 결과가 PrevX 이상이 되었다면 이때 리턴되어야 할 오프셋은 D여야 한다. 그래야 ABC 다음으로 캐럿이 이동될 것이다. 이 두 명령의 순서를 바꿔버리면 PrevX 이상의 문자보다 하나 더 앞 문자를 가리키게 될 것이다. 그런데 이렇게 처리하면 또 부작용이 생긴다.

항상 p를 증가시켜 놓고 누적폭이 DestX 이상이 되었는지 검사를 하기 때문에 설사 PrevX 0일지라도 p는 무조건 하나 증가한 후 탈출하게 될 것이며 PrevX의 값과는 상관없이 첫 번째 열에 캐럿이 오지 못한다. 이 부작용을 해결하기 위해 GetLine 호출 후 곧바로 PrevX 0인지 검사해보고 맞다면 루프로 들어가지도 않고 s를 리턴하는 조건문이 필요해졌다.

상하이동 코드에서는 이제 GetOffFromRC 대신에 GetXPosOnLine 함수를 호출하도록 수정한다. 그리고 SetCaret의 인수는 반드시 FALSE여야 한다. PrevX가 수직이동시 수평위치를 저장하기 위해 사용하는 변수인데 수직이동할 때 이 값을 바꿔버리면 저장해놓은 보람이 없어진다.

 

void OnKey(HWND hWnd, UINT vk, BOOL fDown, int cRepeat, UINT flags)

{

     ....

     case VK_UP:

          GetRCFromOff(off,r,c);

          if (r > 0) {

               r--;

            off=GetXPosOnLine(r,PrevX);

            SetCaret(FALSE);

          }

          return;

     case VK_DOWN:

          GetRCFromOff(off,r,c);

          if (r < GetRowCount()-1) {

               r++;

            off=GetXPosOnLine(r,PrevX);

            SetCaret(FALSE);

          }

          return;

 

한글자씩 폭을 누적시킬 때마다 IsDBCS로 문자의 길이를 계산하여 이동하므로 한글의 경계에 걸치는 일이 없어졌다. 그리고 저장해놓은 픽셀값으로 이동할 오프셋을 찾으므로 문자들의 폭에 상관없이 화면상에 보이는 수평위치로 이동하게 될 것이다. 변수 하나와 함수 하나를 통해 여러 가지 문제들을 쉽게 해결하였다.