. 마진에서의 마우스 처리

마진 영역은 북마크나 줄번호를 보여주기도 하지만 마우스 액션을 위한 특별 구역이기도 하다. 이 영역에서의 마우스동작은 포맷팅영역에서의 그것과는 다른 의미로 해석되어 다양한 선택 동작을 가능하게 한다. ApiEdit는 마진영역에서의 마우스동작을 다음과 같이 처리한다. 마진에서의 마우스동작은 편집기마다 다르게 정의되며 편집기의 고유한 정책 중 하나이다.

 

동작

처리

누름

줄을 선택한다.

드래그

모드로 여러 줄을 선택한다.

더블클릭

문단을 선택한다.

<Ctrl> 클릭

문서 전체를 선택한다.

<Shift> 클릭

캐럿이 있는 줄에서 현재 줄까지 선택한다.

 

이 영역을 잘 활용하면 선택을 훨씬 더 효율적으로 할 수 있으며 따라서 편집속도를 향상시킬 수 있다. 여러 가지 이점이 있기 때문에 대부분의 편집기들이 마진에서의 마우스처리를 지원하는데 어떤 편집기들은 트리플클릭이나 더블클릭 드래그 같은 복잡한 동작에 대해서도 지원하고 있다. 마진에서의 선택은 줄단위로 이루어지는데 일반 선택과는 모드가 다르고 코드에서도 모드에 따라 선택영역을 확장하는 방법이 다르기 때문에 선택모드 구분을 위한 전역변수가 필요하다.

 

BOOL bSelLine;

int SelStartLine;

 

BOOL OnCreate(HWND hWnd, LPCREATESTRUCT lpCreateStruct)

{

     ....

     bSelLine=FALSE;

     SelStartLine=0;

 

bSelLine은 현재 선택모드가 줄단위 모드라는 것을 나타내는 상태값이며 SelStartLine은 선택을 시작한 줄의 번호를 가진다. OnCreate에서 두 변수를 FALSE, 0으로 초기화하도록 한다. 다음 함수는 줄단위 선택을 위한 도우미 함수이다.

 

int IncludeEnter(int nPos)

{

     if (buf[nPos]==‘\r’) {

          return nPos+2;

     } else {

          return nPos;

     }

}

 

nPos 위치의 문자가 개행코드이면 이 코드를 포함시킨다. 줄단위 선택을 할 때는 줄 끝에 있는 개행코드까지 같이 선택해야 문단이 제대로 복사된다. 물론 자동개행된 줄의 끝이라면 개행코드가 없으므로 무시한다. 이 함수는 줄단위 선택처리에 공통적으로 사용된다.

마진 영역에서의 마우스처리는 모두 마우스 메시지 처리 루틴에 작성되는데 대부분 OnLButtonDown에서 처리한다. 많은 코드가 추가되므로 기능별로 어떤 코드가 추가되는지를 보기 어려우므로 완성된 결과를 보고 분석하자.

 

void OnLButtonDown(HWND hWnd, BOOL fDoubleClick, int x, int y, UINT keyFlags)

{

     int toff;

     BOOL bShift, bControl;

    int r,c;

    int nr;

    int SelFirst, SelSecond;

 

     bShift=((GetKeyState(VK_SHIFT) & 0x8000) != 0);

     bControl=((GetKeyState(VK_CONTROL) & 0x8000) != 0);

 

    if (x < MarginWidth) {

        toff=GetOffFromXY(x+xPos,y+yPos);

        GetRCFromOff(toff,r,c);

        if (fDoubleClick) {

           while (pLine[r].nLine!=0) r--;

           for (nr=r+1;;nr++) {

               if (nr == TotalLine || pLine[nr].nLine == 0) {

                   break;

               }

           }

           nr--;

 

           SelStart=pLine[r].Start;

           off=SelEnd=IncludeEnter(pLine[nr].End);

           SetCaret();

           Invalidate(-1);

           return;

        }

 

        if (bControl) {

           SendMessage(hWnd,WM_COMMAND,MAKEWPARAM(IDM_AE_SELALL,0),0);

           return;

        }

 

        if (bShift) {

           SelFirst=min(SelStart,SelEnd);

           SelSecond=max(SelStart,SelEnd);

           if (SelStart == SelEnd) {

               GetRCFromOff(off,nr,c);

           } else {

               if (toff > SelFirst) {

                   GetRCFromOff(SelFirst,nr,c);

               } else {

                   GetRCFromOff(SelSecond,nr,c);

               }

           }

           SelStartLine=nr;

 

           if (r >= SelStartLine) {

               SelStart=pLine[SelStartLine].Start;

               off=SelEnd=IncludeEnter(pLine[r].End);

           } else {

               SelStart=pLine[SelStartLine].End;

               off=SelEnd=pLine[r].Start;

           }

        } else {

           SelStart=pLine[r].Start;;

           off=SelEnd=IncludeEnter(pLine[r].End);

           SelStartLine=r;

        }

        SetCaret();

        Invalidate(-1);

        bSelLine=TRUE;

        SetCapture(hWnd);

        bCapture=TRUE;

        return;

    }

 

     if (fDoubleClick) {

     ==== 이하 생략 ====

 

void OnMouseMove(HWND hWnd, int x, int y, UINT keyFlags)

{

     BOOL bInstTimer;

     int r,c;

     int OldOff;

    int toff;

 

     if (bCapture == FALSE) {

          return;

     }

 

     OldOff=off;

    if (bSelLine) {

        toff=GetOffFromXY(x+xPos,y+yPos);

        GetRCFromOff(toff,r,c);

 

        if (r >= SelStartLine) {

           SelStart=pLine[SelStartLine].Start;

           off=SelEnd=IncludeEnter(pLine[r].End);

        } else {

           SelStart=pLine[SelStartLine].End;

           off=SelEnd=pLine[r].Start;

        }

    } else {

          off=SelEnd=GetOffFromXY(x+xPos,y+yPos);

     }

     SetCaret();

     Invalidate(min(OldOff,off),max(OldOff,off));

 

     bInstTimer=FALSE;

     ==== 이하 생략 ====

 

void OnLButtonUp(HWND hWnd, int x, int y, UINT keyFlags)

{

     bCapture=FALSE;

    bSelLine=FALSE;

     ReleaseCapture();

     KillTimer(hWnd,1);

}

 

OnLButtonDown 함수의 선두에 if (x < MarginWidth) { 조건문이 있는데 이 조건문 안이 마진에서의 마우스처리를 담당하는 코드이다. 마진이 숨겨져 있는 상태(MarginWidth == 0)라면 이 코드들은 전혀 실행되지 않을 것이다. 쉬운 액션부터 분석을 해보도록 하자.

더블클릭

마진에서의 더블클릭은 클릭한 줄이 속해 있는 문단을 선택하는 것이다. toff에 클릭한 곳의 오프셋을 조사하고 오프셋으로부터 현재 줄을 구해 r에 대입한다. 이 줄이 속한 문단의 첫 줄과 끝줄을 찾아 선택하면 된다. 각 문단의 첫 줄은 pLine 배열의 nLine 멤버가 0인 줄을 찾으면 되며 문단의 범위는 다음 순서대로 구할 수 있다.

더블클릭한 줄에서부터 위로 올라가면서 nLine 0인 줄을 찾는데 여기가 문단의 선두 줄이다. 위쪽으로 nLine 0인 줄은 반드시 있으므로 별도의 예외 처리는 필요 없으며 단순한 while문으로 선두를 찾을 수 있다.

선두를 찾은 후 바로 아래줄부터 다시 nLine 0인 줄을 찾으면 다음 문단의 선두가 된다. 다음 문단의 선두에서 한 줄만 위로 올라가면 바로 여기가 더블클릭한 문단의 마지막 줄이 되며 nr에 이 줄번호가 기록된다. 이때 더블클릭한 문단이 문서의 마지막 문단이라면 다음 문단이 없으므로 예외 처리가 필요하다.

그래서 nLine==0 조건 외에도 TotalLine과 같은 줄번호인지도 점검하고 있다. Nr 줄이 TotalLine이 되었다는 것은 문서의 마지막 줄에 도달했다는 뜻이 아니라 이미 문서 끝을 초과했다는 뜻이며 이 시점에서 한 줄 위로 올라가면 문서의 끝임과 동시에 마지막 문단의 끝을 제대로 찾게 된다. 문서의 마지막 줄은 TotalLine-1인데 이 값과 비교해서는 안되며 반드시 초과값인 TotalLine과 곧바로 비교해야 nr--에 의해 문단 끝을 찾을 수 있다.

문단의 처음 줄과 끝 줄을 찾았으면 선택영역을 만드는 것은 아주 쉽다. 첫 줄의 처음 오프셋이 SelStart가 되고 마지막 줄의 끝이 SelEnd가 된다. 이때 문단 마지막에 있는 엔터코드도 반드시 포함시켜야 완전한 문단을 선택하게 되므로 SelEnd IncludeEnter 함수가 리턴하는 오프셋을 받아야 한다. 다음 그림을 보자.

문단을 복사하여 다른 문단 중간에 붙여넣기를 하였다. 이때 문단 끝에 있는 개행코드까지 포함해서 복사를 했다면 왼쪽 그림처럼 두 문단 사이에 복사된 문단이 자연스럽게 삽입된다. 하지만 개행코드를 빼고 문단의 텍스트만 복사했다면 오른쪽 그림처럼 붙여넣기 한 위치의 문단과 복사된 문단이 합쳐진다. 이렇게 되면 사용자는 <Enter>키를 한 번 더 입력해야 하는 불편함이 있다.

문단선택이란 문단의 텍스트만 선택하는 것이 아니라 문단 자체를 선택하는 것이며 이 문단을 복사하면 문단이 통째로 복사되어야 한다. 문단의 정의 자체가 개행코드로 구분된 텍스트의 집합이므로 문단의 끝에 있는 개행코드도 같이 복사되어야 하며 이 처리를 하는 함수가 바로 IncludeEnter 함수이다.

<Ctrl> 클릭

<Ctrl> 클릭은 문서를 통째로 선택하는 것이다. 이 기능은 쉽기도 하지만 이미 작성된 코드가 있으므로 따로 코드를 작성할 필요가 없다. IDM_AE_SELALL 명령을 보내 주기만 하면 <Ctrl+A> 단축키의 코드가 실행되어 문서 전체가 선택된다.

마진에서의 더블클릭과 <Ctrl> 클릭은 그 자체로서 완벽하며 OnLButtonDown에서 모든 처리를 다 했으므로 다른 처리가 필요없다. 그래서 처리 후 곧바로 리턴하였다. 반면 드래그 같은 동작은 마우스 이동시에도 계속 처리해야 하므로 커서를 캡처하는 추가 동작이 필요하다.

한 줄 선택

한 줄을 선택하는 것은 아주 간단하다. if (x < MarginWidth) { 블록의 처음에서 클릭한 곳의 오프셋 toff와 줄번호 r은 이미 조사해놓았으므로 r 줄의 시작에서 끝까지를 선택영역으로 만들기만 하면 된다. , 이 경우도 줄 끝에 개행코드가 있다면 포함시켜야 하므로 IncludeEnter 함수가 필요하다. OnLButtonDown 코드가 길다 보니 한 줄 선택을 처리하는 부분이 어딘가 잘 안보일 수도 있는데 좀 정리해서 보이면 다음 부분이다.

 

void OnLButtonDown(HWND hWnd, BOOL fDoubleClick, int x, int y, UINT keyFlags)

{

     ...

     if (x < MarginWidth) {

     ....

          if (bShift) {

              ....

          } else {

           SelStart=pLine[r].Start;;

           off=SelEnd=IncludeEnter(pLine[r].End);

           SelStartLine=r;

          }

          SetCaret();

          Invalidate(-1);

          bSelLine=TRUE;

          SetCapture(hWnd);

          bCapture=TRUE;

          ....

 

x 좌표는 마진영역 안이고 <Shift>키는 눌러지지 않았을 때의 코드를 보아라. 앞에서 설명한대로 SelStart r 줄의 처음이고 SelEnd r 줄의 끝에서 IncludeEnter 한 위치를 대입하고 있다. 보다시피 한 줄을 선택하는 작업은 이렇게 간단하다. 그러나 이 작업은 여기서 끝나는 것이 아니다. 마진에서 마우스를 누른 채로 드래그를 할 수도 있기 때문에 다음 처리를 위해 약간의 준비를 해야 한다.

마진에서 드래그할 때는 줄단위로만 선택을 해야 하고 포맷팅영역에서 드래그하는 것과는 동작이 다르므로 선택모드를 구분해야 할 필요가 있다. 그래서 bSelLine 변수를 TRUE로 설정하여 줄단위 선택모드로 만들고 최초 선택을 시작한 줄번호를 SelStartLine 변수에 기록해두었다. 그리고 계속 마우스 메시지를 받을 수 있도록 커서를 캡처했다. 이후의 처리는 OnMouseMove로 넘어간다.

 

void OnMouseMove(HWND hWnd, int x, int y, UINT keyFlags)

{

     ....

     OldOff=off;

     if (bSelLine) {

          toff=GetOffFromXY(x+xPos,y+yPos);

          GetRCFromOff(toff,r,c);

 

          if (r >= SelStartLine) {

              SelStart=pLine[SelStartLine].Start;

              off=SelEnd=IncludeEnter(pLine[r].End);

          } else {

              SelStart=IncludeEnter(pLine[SelStartLine].End);

              off=SelEnd=pLine[r].Start;

          }

     } else {

          off=SelEnd=GetOffFromXY(x+xPos,y+yPos);

     }

     ....

 

OnMouseMove에서는 줄단위 선택일 때와 일반 선택일 때를 bSelLine 변수값으로 구분하는데 일반 선택일 때는 else 블록의 기존 코드가 실행되고 줄단위 선택일 때는 if (bSelLine) { 블록의 코드를 실행한다. 줄단위 선택은 선택 시작 줄에서 현재 마우스 커서가 있는 줄까지 선택하는 동작인데 마우스가 위로 이동한 경우와 아래로 이동한 경우의 선택영역 계산이 달라진다.

마우스가 아래로 이동했으면 선택 시작줄의 처음에서 이동한 줄의 끝까지를 선택하고 반대로 마우스가 위로 이동했으면 선택 시작줄의 끝에서 이동한 줄의 처음까지를 선택한다. 두 경우 모두 줄 끝의 오프셋을 취할 때는 항상 IncludeEnter 함수가 필요하다.

드래그한 방향과는 상관없이 SelStartLine은 항상 선택영역에 포함되며 SelStart는 항상 선택을 시작한 줄에 있어야 한다. 방향에 따라 SelStart가 줄의 처음이나 끝으로 이동하기는 하지만 선택 시작 줄을 벗어나지는 않으며 드래그한 방향의 끝은 SelEnd가 가리킨다. 커서가 최초 선택을 시작한 줄보다 위로 올라갔다고 해서 SelStart가 윗줄의 선두가 되도록 해서는 안된다. 왜냐하면 선택중의 캐럿은 블록이 확장되고 있는 방향을 가리키는 역할을 하며 이 방향은 항상 SelEnd가 되어야 하기 때문이다.

마우스로 줄단위 선택을 하다가도 선택 방법을 키보드로 바꿀 수 있는데 이때 선택 확장 방향이 항상 제대로 되어 있어야 한다. 마우스로 위로 긁다가 다시 <Shift+Up>했는데 선택영역이 위로 확장되지 않고 아래에서 축소되어 올라오면 이상하지 않겠는가? 이런 차이는 아주 미세하기 때문에 프로그래밍을 할 때 고려하기 어렵지만 실제로 이 프로그램을 사용하는 사용자들은 당장 알아챌 수 있다.

이 코드에서 또 한 가지 주의해서 볼 것은 방향 검사를 위한 조건문이다. 현재 커서가 있는 줄 r과 선택 시작줄을 비교하는 if (r >= SelStartLine) 조건문에는 등호가 포함되어 있다. 이 조건문은 최초 선택을 시작한 줄과 지금 커서가 있는 줄이 같을 때는 커서가 아래쪽에 있는 것과 같이 취급하라는 뜻이 담겨 있다.

이렇게 처리하면 한 줄만 선택할 때 가급적이면 SelEnd SelStart보다 뒤에 있도록 함으로써 한 줄 선택을 더 자연스럽게 만든다. 만약 이 등호가 생략된다면 마진에서 최초 마우스를 누를 때는 캐럿이 줄 끝에 있다가도 같은 줄 내에서 조금이라도 마우스를 움직이면 줄 처음으로 캐럿이 이동한다. 물론 그래도 큰 불편함이나 차이는 없지만 왠지 어색하다. 이런 코드를 짤 때는 부등호 하나를 선택할 때도 많은 생각이 필요하다.

OnLButtonUp에서는 특별히 더 할 일이 없고 한 줄 선택모드를 끝내기 위해 bSelLine FALSE로 바꿔 주었다. bSelLine은 오로지 마우스처리 함수간의 모드 구분을 위해서만 존재하며 버튼을 놓는 즉시 FALSE가 되어야 한다.

줄단위 선택모드

마진에서의 줄단위 선택은 마우스처리 함수 셋의 합작품이다. OnLButtonDown에서는 한 줄 선택해놓고 모드만 바꿔 주고 드래그 처리는 OnMouseMove가 담당하고 마지막 뒤치닥거리는 OnLButtonUp이 한다. 이 세 함수가 줄단위 선택을 위해 bSelLine, SelStartLine 두 개의 전역변수를 사용하고 있는데 이 전역변수가 과연 꼭 필요한가 생각해보자.

줄단위 선택이든 포맷팅영역에서의 선택이든 어차피 커서는 캡처해야 하고 WM_MOUSEMOVE 메시지는 연속적으로 전달된다. 또한 OnMouseMove에서도 커서의 현재 위치를 알 수 있으므로 커서가 마진영역에 있는지 아닌지를 알 수 있다. 그럼에도 불구하고 bSelLine 모드가 필요한 이유는 사람의 손동작이 정확하지 않기 때문이다.

최초 마진영역에서 줄단위 선택을 했다면 마우스 이동중에 커서가 포맷팅영역으로 들어와도 줄단위 선택 상태를 계속 유지하도록 하는 것이 좋다. 마진영역은 좁기 때문에 이 영역에서 수직으로 정확하게 마우스를 이동시키기는 쉽지 않다. 그래서 최초 마우스 버튼을 클릭한 위치가 마진영역이었으면 버튼을 놓기 전까지는 사용자가 줄단위 선택을 원한다는 것으로 해석하고 그대로 동작하기 위해 bSelLine이라는 모드가 필요한 것이다.

편집기에 따라서는 마진에서 줄단위 선택을 시작하더라도 포맷팅영역으로 들어오면 사용자가 줄단위 선택을 취소한 것으로 해석하고 일반 모드로 전환하기도 한다. 일단 일반 모드로 전환하면 마우스 버튼을 떼기 전에는 다시 줄단위 모드로 갈 수 없다. 줄단위 선택에 큰 의미를 두지 않고 다만 여러 줄을 빨리 선택하기 위한 임시적인 방법으로만 줄단위 선택을 제공한다면 이런 방식이 적합하다. 어떤 편집기가 그런가 하면 바로 비주얼 C++ 6.0의 편집기가 이 방식대로 동작한다.

반면 워드나 기타 워드프로세서 류들은 줄단위 모드를 철저하게 구분한다. 줄단위 모드를 강제로 유지할 것인가 아니면 사용자에게 맡길 것인가는 선택의 문제인데 ApiEdit처럼 사용자가 대충 드래그해도 최초의 명령을 존중하고 싶다면 bSelLine이 반드시 필요하다.

SelStartLine 변수는 줄단위 선택을 시작한 줄의 번호인데 이 정보도 반드시 필요하다. SelStart는 단순한 오프셋이고 줄단위 선택모드에서는 드래그 방향에 따라 블록 시작위치가 가변적이므로 줄단위 선택시의 시작점으로 쓰기에는 부적당한다.

드래그중에 방향이 바뀌면 이미 선택되어 있는 역방향 쪽은 선택이 취소되어야 한다. 예를 들어 3번 줄에서 시작해서 5번 줄로 드래그했다면 3~5번 줄이 선택되어 있을 것이다. 이 상태에서 커서를 1번 줄로 가져가면 1~5번 줄이 선택되는 것이 아니라 1~3번 줄까지만 선택되고 4,5번 줄은 선택이 취소되어야 한다. 이때 취소의 경계선이 되는 지점을 바로 SelStartLine 변수가 가지고 있다.

<Shift> 클릭

마진에서의 <Shift> 클릭은 캐럿이 있는 줄에서 클릭한 줄까지 선택하는 기능을 가진다. 이때도 줄단위로 선택되므로 캐럿 위치의 오프셋이 시작점이 되는 것이 아니라 캐럿이 있는 줄의 처음이 시작점이 된다. 클릭한 방향에 따라서 선택영역을 계산하는 방법은 OnMouseMove와 동일하며 <Shift> 클릭한 후에도 계속 드래그할 수 있으므로 SelStartLine에는 캐럿이 있는 줄번호를 대입하고 bSelLine TRUE로 바꿔야 한다.

선택영역이 이미 있는 상태에서 <Shift> 클릭할 때는 클릭한 지점이 선택영역보다 더 앞인가 뒤인가에 따라 선택의 시작점이 달라진다. 블록의 아래쪽을 클릭했다면 선택영역의 선두부터 새로 클릭한 곳까지 선택하고 반대라면 선택영역의 끝에서부터 새로 클릭한 곳까지 선택한다. , 이미 선택된 영역은 보존하면서 최대한 블록을 확장한다. , 선택영역 내부의 줄을 클릭할 때는 어쩔 수 없이 블록이 축소된다.

선택이 이미 있고 마진에서 <Shift> 클릭할 때의 블록 관리 정책에 대해서도 개발자의 선택이 필요하다. 어떤 식으로 블록을 확장 또는 축소할 것인지, 그때의 선택 시작점을 어디로 잡을 것인지가 모두 개발자의 취향에 따라 달라질 것이며 여기서 내린 결정에 따라 편집기의 개성이 결정되는 것이다.

앞에서도 말했듯이 ApiEdit는 가급적이면 블록을 확장하는 쪽으로 결정했는데 이 정책은 편집기마다 아주 독특한데다 문제점도 많다. 대표적으로 비주얼 C++ 6.0의 편집기의 이 정책을 보면 좀 이상하다는 생각도 든다. 다음 그림을 보자.

  

5번 줄에서 드래그를 시작해서 4번 줄까지 선택한 상태이다. 이 상태에서 3번 줄에서 <Shift> 클릭하면 6번 줄이 같이 선택된다. 계속해서 2번 줄에서 <Shift> 클릭하면 7번 줄까지 같이 선택된다. 버그라고 한다면 그럴 수도 있겠지만 비주얼 C++ 개발자들이 정책을 이렇게 결정했다면 이것이 자연스러운 현상일 수도 있다. ApiEdit도 자세히 보면 버그가 더 있을 것 같은데 적어도 내가 보기에는 별 문제가 없는 것 같다. 이런 문제는 개발자가 솔직히 자수를 하기 전에는 사용자들이 눈치채기 무척 어렵다. 그만큼 정책을 결정하기 어렵고 정책대로 구현하기도 어렵다.