C++/Game

[게임 프레임워크 개발 일지] #10 Text Input은... 서비스 종료다

Kareus 2022. 9. 18. 16:12

그간 개발을 좀 쉬었습니다.

갑자기 음악에 삘이 꽂혀서 그거 하느라인 것도 있고, 몸이 안 좋아서 쉰 것도 있고

Text Input을 건드릴 엄두가 안 나던 탓도 있습니다.

 

아무튼 시작해봅시다.

 

1. SFML에서 한글 입력받는 방법

SFML에서는 sf::Event::TextEntered를 통해 입력받은 글자의 unicode를 가져올 수 있습니다.

문제는 한글(또는 한자)은 조합형 문자라는 특성상, 문자가 완성된 후에야 입력된다는 점입니다.

Windows에서 영문자만을 제대로 지원하는 프로그램에서 한글을 입력하면 대개 우하단에 이런 창이 뜨는 걸 볼 수 있습니다.

IME

한중일권(CJK)의 문자는 Input Method Editor(이하 IME)를 통해 글자를 조합한 후에 입력할 수 있습니다.

IME에서 완성된 문자가 다시 프로그램에 문자로써 입력되는 것이죠.

이와 관련된 내용은 CJK IME에 대해 검색하면 알 수 있습니다. 특정 문화권에 제한되는 문제이기 때문에 키워드를 알기도, 정보를 알아내기도 생각보다 어렵습니다.

 

뭐, 그래서 프로그램에 'ㄱ' - 'ㅡ' - 'ㄹ'를 순서대로 입력하면 이 세 글자가 입력되는 것이 아니라 'ㄹ'이 입력된 후 다음 글자의 입력이 들어올 때 '글'이 완성되어 입력됩니다.

다음 글자가 들어올 때가 되어서야 완성되는 이유는, backspace 등을 통해 글자의 조합이 수정될 수 있기 때문입니다.

만약 '글' 뒤에 'ㄱ'이 입력되면 '글ㄱ'가 아니라 '긁'이 되겠죠.

'ㅇ'의 경우에는 '글'에 조합될 수 있는 경우가 없으므로 '글'이 완성되었다고 판단합니다.

 

Windows에서는 cmd 등을 통해 이러한 현상을 볼 수 있습니다.

 

글자가 실시간으로 입력되지 않는다는 점이 거슬립니다.

메모장에서처럼 글자가 입력되는 대로 그대로 나오게 하려면, 지금 입력되는 글자를 바로 가져올 수 있어야겠죠.

 

크게 생각해볼 수 있는 방법은 두 가지입니다.

1] 키보드 입력 이벤트에서 그 키에 대응되는 글자를 계산한다.

2] IME 이벤트에서 글자를 가져온다.

 

1번은 지금 입력된 자음 혹은 모음만을 바로 가져올 수 있다는 장점이 있습니다.

단점은 그 테이블을 직접 만들어야하고, 현재 조합된 글자를 가져오고 싶다면 직접 조합해줘야 합니다.

뭐 이건 검색하면 잘 다룬 글들이 많습니다.

다른 문제점은 입력되는 글자가 한글인지 영어인지 알 수가 없다는 점입니다.

KeyPressed에서는 입력되는 키보드의 key code만 알지, 입력되는 unicode는 알 수가 없습니다.

 

이걸 위해서는 GetKeyboardLayout 등을 통해서 현재 키보드 언어를 알아내야 되는데,

전 조합하기도 귀찮고 Windows 함수를 이것 저것 불러오는 게 영 모양새가 안 이쁘기 때문에 2번으로 넘어갔습니다.

뭐 그 외에도, 한글 입력에 대한 이벤트를 따로 구별해내기가 번거롭다는 것도 문제라고 할 수 있겠네요.

 

2번은 플랫폼에서 제공하는 IME 조합 관련 이벤트를 이용하는 방법입니다.

저는 Windows를 사용하고 있기 때문에, 설명도 Windows-specific할 수 밖에 없네요.

 

시스템 차원에서 제공하기 때문에 깔끔합니다. 이벤트 구분도 쉽고, 조합하는 글자를 그냥 가져올 수 있습니다.

대신 자모 분리는 직접 작성해줘야 합니다. 이건 1번과 비슷한 문제네요.

그리고 위에서 언급했듯이 Windows에서만 작동합니다. Linux나 Unix 계열에서는 이러한 이벤트가 따로 있는지 모르겠습니다. 훑어보니까 없는 것 같긴 하던데

 

그리고 SFML에서 가장 큰 문제점은 SFML에서 Window를 생성해서 user 측에 건네주기 때문에

SFML에 IME event를 처리할 방법이 없습니다.

이거에 관련된 issue가 2.6.0부터 업데이트될 예정인 것 같은데, 포스팅하는 날짜 기준으로 (2022.09.05) 2.6.0이 나올 기미가 안 보입니다. 뭐가 이리 오래 걸린대요

 

그래서 직접 IME event를 call할 수 있도록 Window Protocol을 작성해줘야 됩니다.

지금 생각해보면 이렇게 멀리 돌아갈 바에 그냥 1번할 걸 그랬습니다.

 

SFML에서는 Protocol을 직접 작성해서 Window를 생성할 수 있도록 지원해주고 있습니다.

다만 그런 만큼 protocol 처리를 모두 직접 작성해야 한다는 단점이 있습니다.

protocol 함수 내에서는 기본적인 이벤트를 모두 처리해줘야 합니다.

 

이것이 저는 귀찮았기 때문에, 그리고 이 경우에 pollEvent가 작동하지 않는다는 점 때문에

기존의 프로토콜에다 필요한 함수를 끼워넣는 방법을 생각했습니다.

injection같은 게 뭔가 해킹하는 것 같네요.

 

윈도우 객체는 getSystemHandle()로 가져올 수 있습니다.

Windows에서 이 WindowHandle은 HWND와 같습니다.

Window의 protocol을 가져오려면 GetWindowLongPtr 함수에서 인자 GWLP_WNDPROC을 사용하면 됩니다.

 

정리하면, 기존의 프로토콜을 가져와서 어딘가에 저장한 뒤에, 새로운 함수를 새 프로토콜로 저장하면 됩니다.

이 새로운 함수에서는 추가적인 함수 (IME event call)를 실행하고, 나머지는 기존 함수로 보내서 처리하게 할 겁니다.

새로운 프로토콜 newProc은 이렇게 작성하면 됩니다.

LRESULT CALLBACK newProc(HWND Handle, UINT Message, WPARAM WParam, LPARAM LParam)
{
    HIMC imc;
    IMEObject e;
    wchar_t str[256] = L"";
    bool ime_activated = false;

    switch (Message)
    {
    case WM_IME_STARTCOMPOSITION:
        e.type = IMEObject::StartComposition;
        ime_activated = true;
        break;

    case WM_IME_COMPOSITION:
        e.type = IMEObject::Composition;
        imc = ImmGetContext(Handle);

        if (LParam & GCS_RESULTSTR)
        {
            if (int len = ImmGetCompositionString(imc, GCS_RESULTSTR, NULL, 0) > 0)
            {
                ImmGetCompositionString(imc, GCS_RESULTSTR, str, len);
                str[len - 1] = 0;

                e.string = str;
                ime_activated = true;
            }
        }
        else if (LParam & GCS_COMPSTR)
        {
            int len = ImmGetCompositionString(imc, GCS_COMPSTR, NULL, 0);
            ImmGetCompositionString(imc, GCS_COMPSTR, str, len);

            e.string = str;
            ime_activated = true;
        }

        ImmReleaseContext(Handle, imc);
        break;

    case WM_IME_COMPOSITIONFULL:
        e.type = IMEObject::CompositionFull;
        ime_activated = true;
        break;

    case WM_IME_ENDCOMPOSITION:
        e.type = IMEObject::EndComposition;
        ime_activated = true;
        break;

    default:
        break;
	}

    if (ime_activated)
        window->pushIMEEvent(e);
        
    return CallWindowProc(reinterpret_cast<WNDPROC>(originalProc), Handle, Message, WParam, LParam);
}

일단 IME 이벤트 처리만 보시면 됩니다.

뭐 이건 구글링한 결과를 긁어와서 조금 수정한 거니까 알아서 하면 되긴 합니다.

 

중요한 건 그래서 IME Event는 어디다 호출할 것이고, 기존 함수는 어디다 저장할 것이냐 입니다.

기본적으로 윈도우 프로토콜은 global function이니까요.

 

SFML에서는 윈도우 객체를 UserData에 저장하고 불러오고 있습니다.

여기서도 마찬가지로 전역 변수에 저장하고 불러올 겁니다.

대신 함수는 SetProp, GetProp을 사용하겠습니다.

 

저장할 객체는 Window와 original protocol입니다. 이름은 마음에 드는 걸로 정해주면 됩니다.

void replaceWndProcWithIME(Window* window)
{
    sf::WindowHandle handle = window->get().getSystemHandle();
    LONG_PTR originalProc = GetWindowLongPtr(handle, GWLP_WNDPROC);

    SetProp(handle, L"WINDOW_PTR", (HANDLE)window);
    SetProp(handle, L"ORIGINAL_PROC", (HANDLE)originalProc);

    SetWindowLongPtr(handle, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&newProc));
}

 

가져올 때는 GetProp을 사용해줍시다.

LRESULT CALLBACK newProc(HWND Handle, UINT Message, WPARAM WParam, LPARAM LParam)
{
//...
    Window* window = reinterpret_cast<Window*>(GetProp(Handle, L"WINDOW_PTR"));
    HANDLE originalProc = GetProp(Handle, L"ORIGINAL_PROC");
}

 

아직은 처리를 안 했는데, 윈도우 객체가 여러 개인 경우에는 이름이 중복되는 문제가 발생합니다.

handle에 따라 구별되게 할 필요가 있는데... 아직 방법을 잘 모르겠네요.

후에 다시 생각해보고 포스팅하겠습니다.

 

다음으로 IME Event 처리를 해봅시다.

기존의 pollEvent에 Event 객체를 끼워넣기는 어렵습니다.

IME로 입력되는 글자는 uint32 하나로 표현할 수 없고, 별도의 Event enum을 지정할 수가 없기 때문입니다.

여기서는 IMEObject라는 struct를 만들고, IME Event 정보를 여기다 저장한 뒤,

custom으로 만든 Window (sf::Window의 wrapper)에 보내도록 했습니다.

 

쉽게 말해서, Window wrapper class에 IME Event만을 저장하는 별도의 큐를 만들고 거기에 저장하게끔 했습니다.

일단 IMEObject부터 살펴봅시다.

class IMEObject
{
public:
    enum EventType
    {
        StartComposition,
        Composition,
        CompositionFull,
        EndComposition,

        Count
    };

    EventType type;
    sf::String string;
};

따로 설명하지는 않겠습니다. IME에서 글자가 조합되는 경우에는 Composition 이벤트가 발생합니다.

 

Window 측에서는 ime object를 받아서 저장하고, event 처리에서 따로 관리해야 합니다.

class Window
{
private:
    sf::RenderWindow window;
    std::queue<IMEObject> ime_queue;
    
    //...
    void _signal(); //event process
    
public:
    //...
    
    void pushIMEEvent(IMEObject e);
    
    bool pollIMEEvent(IMEObject& e);
}

void Window::pushIMEEvent(IMEObject e)
{
     ime_queue.push(e);
}

bool Window::pollIMEEvent(IMEObject& e)
{
    if (ime_queue.empty()) return false;
    
    e = ime_queue.front();
    ime_queue.pop();
    return true;
}

 

SFML의 Event와 IME Event가 서로 다른 큐에 있기 때문에 이벤트를 들어오는 순서대로 처리할 수가 없습니다.

while (window.pollEvent(e))는 SFML Event만 계속 뽑을 테고,

while 바깥으로는 system event가 들어오지 않는 상황에서야 나갑니다.

 

설계상 system event 한 번, IME event 한 번씩 번갈아가면서 처리할 수밖에 없겠군요.

그래도 System Event 쪽이 처리의 우선 순위가 더 높을테니, pollEvent를 처리하고, IME Event를 처리하도록 했습니다.

 

void Window::_signal()
{
    //...
    
    sf::Event e;
    IMEObject ime;
    
    while (window.pollEvent(e))
    {
        //do something for system events
        
        if (pollIMEEvent(ime))
        {
            //do something for ime events
        }
    }
}

이렇게만 작성해두겠습니다.

왜냐면 나중에 다시 와서 이것저것 추가해야 되기 때문입니다.

코드 쓰면서도 어지러웠는데, 포스팅으로 정리하자니 정리가 안 됩니다.


2. Text Rendering

이제 실제로 텍스트를 그려봅시다.

Input Event를 감지해서 입력 글자를 가져오고, String을 업데이트해서 Text 객체로 그려주면 됩니다.

 

처음에는 RichText로 구현해보려 했는데, 특성상 여러 개의 Text 객체를 일일이 관리해야 하기 때문에

그냥 sf::Text 객체를 사용했습니다. 이렇게 해도 어려워 죽겠는데 무슨 RichText를 써

 

Text Input은 굉장히 복잡합니다. 그러니 좀 나눠서 진행해야겠습니다.

먼저 Text의 출력부터 생각해봅시다.

 

Text는 기존에 Text Input에 있던 string과 IME로 조합중인 글자를 합쳐서 출력해야 합니다.

각각 currentString과 imeString이라고 하겠습니다. TextEntered 이벤트와 IME 이벤트가 발생했을 때는

이 두 변수를 수정하고 렌더링하면 됩니다.

 

그리고 커서를 생각해야 됩니다.

사실 드래그도 반영해줘야 되는데, 처음부터 이걸 다 따져가면서 코드를 작성하는 건 어려우니 얘는 나중에 다루겠습니다.

 

커서의 위치를 cursorIndex라고 하면, 글자의 추가는 cursorIndex에 insert를 실행하는 것으로 구현할 수 있습니다.

IME 글자 또한 마찬가지입니다.

 

void TextInput::updateText()
{
    sf::String str = currentString;
    
    if (!imeString.isEmpty())
        str = currentString.substring(0, cursorIndex) + imeString + currentString.substring(cursorIndex);
        
    text.setString(str);
}

 

이제 키보드 입력을 받아서 텍스트에 추가하는 것을 만들어봅시다.

포맷은 이전의 Button과 동일하니, Event Listener는 넘어가겠습니다.

 

void TextInput::getInput(sf::Event e)
{
    if (e.text.unicode == '\b') //backspace
    {
        if (!currentString.isEmpty())
        {
            if (cursorIndex > 0)
                currentString.erase(--cursorIndex, 1);
        }
    }
    else if (e.text.unicode == '\n' || e.text.unicode == '\t' || e.text.unicode > 31)
    {
        currentString.insert(cursorIndex++, e.text.unicode);
    }
    
    imeString = "";
    
    updateText();
}

IME 이벤트와 TextEntered 이벤트가 언제 발동되는지를 보면,

조합중인 경우에는 글자가 변할 때마다 IME Composition 이벤트가 발동되고,

조합이 완성되고 다음 글자로 넘어가는 경우에는 TextEntered 이벤트로 완성된 글자가 들어옵니다.

 

간단하게 예를 들면,

'ㄱ' 입력 -> 현재 글자 : 'ㄱ', IME Composition 이벤트 발생 : 받은 글자 'ㄱ'

'ㅡ' 입력 -> 현재 글자 '그', IME Composition 이벤트 발생 : 받은 글자 '그'

backspace '←'  입력 -> 현재 글자 'ㄱ', IME Composition 이벤트 발생 : 받은 글자 'ㄱ'

'ㅏ' 입력 -> 현재 글자 '가', IME Composition 이벤트 발생 : 받은 글자 '가'

화살표 '→'  입력 -> 글자 조합 종료, TextEntered 이벤트 발생 : 받은 글자 '가'

'ㅇ' 입력 -> 현재 글자 '가ㅇ', IME Composition 이벤트 발생 : 받은 글자 'ㅇ'

같은 형식입니다.

 

따라서 TextEntered 이벤트에서 글자를 받았다면, IME는 조합 중이 아니라는 것이 명확하고

조합이 완료되었다면 그 글자를 받게 되니 imeString은 빈 문자열로 만들어줘야 됩니다.

 

출력 가능한 문자는 key code가 32(= ' ')부터 시작하는 것 같더군요.

그 외의 예외만 추가해주면 됩니다.

backspace ('\b' = 8)는 그 중에서도 글자를 지우는 기능을 해야하니 또 따로 빼줬습니다.

key code가 32 미만인 글자 중 출력 가능한 문자는 Enter ('\n' = 10)과 Tab ('\t' = 8)이 있습니다.

 

여기서, Enter는 Windows에서는 실제로 Carrige ('\r' = 13)으로 입력이 됩니다.

그러니 TextInput에서 입력받을 때 또는 pollEvent 등에서 이벤트를 처리할 때 '\n'으로 입력되도록 해줘야 합니다.

while (window.pollEvent(e))
{
    switch (e.type)
    {
        case sf::Event::TextEntered:
            if (e.text.unicode == '\r')
                e.text.unicode = '\n';
            break;
    }
    
    //...
}

 

이제 IME 입력을 처리해봅시다.

Composition 이벤트에서 글자를 받아오기만 하면 됩니다.

void TextInput::getInput(sf::String e, IMEObject ime)
{
    if (ime.type == IMEObject::Composition)
    {
        imeString = sf::String(ime.string);
        updateText();
    }
}

 

텍스트와 관련된 커맨드 (ctrl+A, ctrl+C, ctrl+V, 화살표로 커서 이동 등)는

Text가 입력되는 게 아니니 KeyPressed 이벤트로 처리해줘야 됩니다.

전체 선택 (ctrl +A)처럼 드래그 처리를 해야되는 커맨드가 있으니 이것도 나중에 합시다.

 

나중에 알게 된 거긴 합니다만, 여기서 난감한 문제가 하나 발생합니다.

#1에서 다뤘듯이, 이벤트 처리와 렌더링을 한 쓰레드 안에서 다루게 되면

이벤트 입력이 바쁠 때 (윈도우를 움직이는 등) 렌더링이 되지 않는 문제가 발생합니다.

그래서 이벤트 처리와 렌더링을 다른 쓰레드에서 처리하도록 분리했었죠.

 

그런데 여기서는 TextEntered, KeyPressed 등의 이벤트 입력을 처리하면서 setString을 호출해서 렌더링할 Text 객체를 수정합니다.

이게 무슨 버그를 일으키는지, 문자를 빠르게 입력할 때 글자가 깨지는 일이 잦더군요.

지금 상황에서 실제로 테스트해보면 정작 버그가 발생할 일은 드물 겁니다.

 

나중에 알게 되었다고 한 것이, 제가 이 버그를 발견한 게 커서를 구현하는 도중이었거든요.

커서의 위치를 찾을 때 findCharacterPos 함수를 써야되는데 (RichText에는 미구현된 기능이고, 제가 따로 구현하기도 어려워서 대신 Text 객체를 쓰기로 한 것이기도 합니다) 이 함수가 연산이 오래 걸리는지 글자가 자꾸 깨졌습니다.

 

뭐 그래서, Text 객체가 렌더링되는 동안에 보호할 방법이 필요했습니다.

처음 시도한 것은 mutex입니다. draw 함수가 const라서 Text 객체나 TextInput의 변수를 어떻게 수정할 수가 없었기 때문에 일단 근본적인 문제부터 해결해보는 겁니다.

std::mutex render_mutex;

while (window.pollEvent(e))
{
    //...
    
    render_mutex.lock();
    for (auto& p : receivers)
        if (p.second) p.first->call(e); //Event Manager call
        
   render_mutex.unlock();
   
   if (pollIMEEvent(ime))
   {
       render_mutex.lock();
       for (auto& p : receivers)
        if (p.second) p.first->call(ime);
       
       render_mutex.unlock();
   }
}

 

render 하는 곳에서도 mutex 처리를 해줘야 합니다.

void Window::render()
{
    render_mutex.lock();
    //do drawing here
    
    render_mutex.unlock();
}

 

...라고 했는데 여전하더군요. 그래서 좀 땜빵을 해야했습니다.

간단하게 생각해서, 지금 수정하고 있는 Text 객체가 text이니, text의 사본 text_to_render를 만드는 겁니다.

사본은 수정이 끝난 시점에서만 업데이트해줘서 수정하는 동안에 draw되더라도 글자가 깨지지 않게 하는 거죠.

 

void TextInput::updateText()
{
    sf::String str = currentString;
    
    if (!imeString.isEmpty())
        str = currentString.substring(0, cursorIndex) + imeString + currentString.substring(cursorIndex);
        
    text.setString(str);
    
    text_to_render = text;
}

draw 메서드에서는 text_to_render를 그려주면 됩니다.


3. Drawing Cursor

 

이제부터 지옥의 그래픽 구현 파트입니다.

커서 그리기는 '마우스로 클릭한 위치와 가장 가까운 글자의 위치에 가로 1px의 사각형을 그리면 된다' 라고

간단하게 요약이 되지만, 이 위치를 찾는 것이 어렵습니다.

그리고 폰트의 크기가 제각각이라서 정확한 위치를 찾는 게 너무 어렵습니다.

대충 테스트하면서 bias를 characterSize / 5.0으로 잡아놓긴 했는데, 뭔가 수학적으로 완벽한 그런 건 아니라서 좀 불편합니다.

 

index에 따라 그 글자의 위치를 찾는 건 findCharacterPos로 되지만, 위치에 따라 index를 찾는 건 직접 구현해야 합니다.

뭐... 일일이 찾아보는 수밖에 없겠네요. 일단 y값을 기준으로 이분 탐색을 하고, x값으로는 하나씩 비교하도록 구현했습니다.

 

size_t TextInput::getCharacterIndex(sf::Vector2f position) const
{
    size_t left = 0, right = text.getString().getSize();
    size_t strSize = right;

    float sz = text.getCharacterSize();
    
    while (left + 1 < right)
    {
        size_t mid = (left + right) >> 1;
        sf::Vector2f pos = text.findCharacterPos(mid);
        if (pos.y <= position.y && position.y <= pos.y + sz)
        {
            left = mid;
            break;
        }
        else if (pos.y < position.y) left = mid;
        else right = mid;
    }

    while (text.findCharacterPos(left).x > position.x + sz / 2 && left > 0) left--;
    while (text.findCharacterPos(left).x < position.x - sz / 2 && left < strSize) left++;

    return left;
}

 

만약 실제로 보이는 텍스트 영역이 제한되어 있다면, (텍스트는 3줄인데 렌더링 영역은 최대 2줄일 때 등) 이분 탐색 전에 position 값을 바꿔줘야 됩니다.

 

렌더링 영역 크기를 renderSize라고 합시다. renderSize 크기에 따라 클리핑하는 건 나중에 하겠습니다.

렌더링 되는 영역을 (0, 0, renderSize.x, renderSize.y)라고 할 수 있으니 여기에 따라 값을 보정해주면 됩니다.

 

if (position.x < 0) position.x = 0;
if (position.x > renderSize.x) position.x = renderSize.x;
if (position.y < 0) position.y = 0;
if (position.y > renderSize.y) position.y = renderSize.y;

여기서 가만 보면, 텍스트를 드래그하는 동안에는 렌더링 영역 바깥으로 마우스가 나가면 자동으로 줄이 이동하는 걸 볼 수 있습니다. 그러니 이것까지 구현하려면 dragging 변수가 false일 때만 보정을 해줘야 됩니다.

 

if (!isDragging)
{
    if (position.x < 0) position.x = 0;
    if (position.x > renderSize.x) position.x = renderSize.x;
    if (position.y < 0) position.y = 0;
    if (position.y > renderSize.y) position.y = renderSize.y;
}

이런 식으로 구한 index를 cursorIndex에 넣어주고, renderCursor로 커서를 구현해봅시다.

cursorIndex = getCharacterIndex(position);
renderCursor();

커서는 가로 1px, 세로 텍스트 사이즈 (text.getCharacterSize())인 하얀색 사각형입니다.

sf::RectangleShape cursor;
cursor.setSize(sf::Vector2f(1, text.getCharacterSize());
cursor.setFillColor(sf::Color::White);

renderCursor에서는 현재 cursorIndex에 해당하는 글자의 위치에 cursor을 옮겨줘야합니다.

이건 findCharacterPos로 해결할 수 있습니다.

IME 조합 중에는 cursorIndex 위치에 조합 중인 글자가 렌더링되고, 일반적으로 커서가 그 뒤에 위치하므로

조건부로 +1한 위치로 옮겨줘야 됩니다. text string이 한창 변하는 와중에 findCharacterPos를 호출하고 있기 때문에 이거랑 쓰레드 특유의 버그가 겹쳐서 글자가 깨지게 됩니다.

 

blink는 real time으로 시간을 재고 변수를 세팅해줘야 됩니다. 로직 자체는 blink를 true/false로 flip해주면 되고

true면 렌더링하고, false면 하지 않으면 됩니다. 저는 500ms를 주기로 잡았습니다.

const double BLINK_TIME = 0.5;

void TextInput::checkRealTime(sf::String e, double delta)
{
    blinker += delta;
    
    if (blinker >= BLINK_TIME)
    {
        blinker -= BLINK_TIME;
        blink = !blink;
    }
}

 

void TextInput::renderCursor()
{
    size_t index = cursorIndex + !imeString.isEmpty();
    auto pos = text.findCharacterPos(index);
    
    pos.y += text.getCharacterSize() / 5.0; //bias
    cursor.setPosition(pos);
    
    //reset blink
    blink = true;
    blinker = 0;
}

text.getCharacterSize() / 5.0은 bias입니다. 근데 저도 정확한 수치를 몰라서 대충 정한 겁니다.

이거 어떻게 수치를 보정하는 지 알고 싶습니다.

 

renderCursor는 마우스 클릭 등으로 커서 위치를 옮겼을 때 호출하므로,

커서를 바로 렌더링해서 보여줄 필요가 있습니다. 그러니 blink를 true로 리셋해줬습니다.

 

renderCursor는 이외에도 글자를 입력할 때 (updateText)나 키보드의 화살표 키로 커서를 옮길 때도 호출해줘야 됩니다.


4. Drag Bounds

드래그 또한 손이 많이 가는 작업입니다.

시작 위치와 끝 위치가 필요하고, 이 범위에 따라 사각형 여러 개를 렌더링해줘야 합니다.

 

이전 포스팅에서 BlendMode나 Shader를 써서 파란색 배경에 흰색 글자를 구현했었습니다.

거기서는 그냥 Text 객체 하나 복사해서 그리는 게 훨 낫겠다고 했었는데,

생각해보니 그것도 줄이 하나일 때나 되는 거지, 여러 줄이면 위치가 달라져서 안되겠더군요.

 

그러면 BlendMode를 섞어서 써야되나? 싶어서 해봤더니 구립니다.

효과가 너무 구려요.

 

RenderTexture에 Text를 그리면 아무래도 비트맵화된 영향 때문에 화질이 구리게 나옵니다.

효과도 깔끔하지 못해서 영 지저분하게 보입니다.

 

alpha mask 등의 효과는 SFML 2.6.0에서나 볼 수 있을 것 같습니다. 아니 그래서 언제 나옴???

 

뭐, 그래서 이 효과 자체를 구현하지 않기로 했습니다. 효과가 구려서 마음에 안 들면, 아예 하지 말고 비용이나 아낍시다.

여기서는 Drag Bound를 파란색의 사각형으로만 구성해서 text 뒤에 그려줄 겁니다.

 

드래그 시작 위치와 끝 위치는 MouseButtonPressed, MouseButtonReleased에서 각각 정해주면 됩니다.

두 값을 start, stop이라고 했을 때 무조건 start < stop인 것은 아니므로 유의해주세요.

 

Drag Bound는 줄의 개수에 따라 사각형 개수가 달라집니다.

줄이 하나인 경우에는 start 위치부터 stop 위치까지 사각형 하나만 그리면 됩니다.

두 개 아싱인 경우에는, 중간 줄은 처음 글자부터 끝 글자까지의 사각형을 그리고 마지막 줄은 처음 글자부터 stop 위치까지의 사각형을 그려야 합니다.

 

std::vector<sf::RectangleShape> dragBound;

void TextInput::renderDragBounds()
{
    dragBound.clear();
    if (dragRange.first == dragRange.second) return;
    
    size_t from = dragRange.first, to = dragRange.second;
    
    if (from > to) std::swap(from, to);
    
    sf::Vector2f start = text.findCharacterPos(from);

    sf::Vector2f stop = text.findCharacterPos(to);

    unsigned int sz = text.getCharacterSize();

    size_t lines = 0;
    size_t next_end = from;
    sf::String str = text.getString();
    
    //count lines
    for (size_t i = from; i <= to; i++)
        lines += (str[i] == '\n');
    
    if (lines == 0) //only one line
    {
        sf::RectangleShape bound(sf::Vector2f(stop.x - start.x, sz));
        bound.setPosition(start);
        bound.setFillColor(Values::SELECTED_BACKGROUND_COLOR);
        dragBound.push_back(bound);

        return;
    }
    else //start to end of the character
    {
        next_end = from;
        next_end = str.find("\n", next_end);
		
        sf::Vector2f first_stop = text.findCharacterPos(next_end);
        sf::RectangleShape bound(sf::Vector2f(first_stop.x - start.x + text.getLetterSpacing(), sz));
        bound.setPosition(start);
        bound.setFillColor(Values::SELECTED_BACKGROUND_COLOR);
        dragBound.push_back(bound);
    }

    for (size_t mid = 1; mid < lines; mid++)
    {
        next_end++; //go to next line
        next_end = str.find("\n", next_end);

        sf::Vector2f next_stop = text.findCharacterPos(next_end);
        sf::RectangleShape bound(sf::Vector2f(next_stop.x + text.getLetterSpacing(), sz));
        bound.setPosition(0, next_stop.y);
        bound.setFillColor(Values::SELECTED_BACKGROUND_COLOR);
        dragBound.push_back(bound);
	}

    //last line
    sf::RectangleShape bound(sf::Vector2f(stop.x, sz));
    bound.setPosition(0, stop.y);
    bound.setFillColor(Values::SELECTED_BACKGROUND_COLOR);
    dragBound.push_back(bound);
}

 

드래그 영역이 생기면서 이전에 구현했던 기능들도 수정할 게 많이 불어납니다.

이제 키보드 커맨드도 구현해야 되고,

드래그한 영역이 있을 때 backspace를 누르거나 글자를 입력하면 그 영역이 다 지워져야 합니다.

 

커맨드부터 봅시다.

ctrl + A : 드래그 영역을 처음부터 끝까지로 지정하고 렌더링하면 됩니다.

ctrl + X : 드래그된 영역의 텍스트를 구하고, 클립보드에 복사한 다음 지워줘야 합니다.

ctrl + C : ctrl + X와 복사하는 것까지는 똑같지만, 지우지는 않습니다.

ctrl + V : 클립보드의 내용을 가져와서 커서 위치에 붙여줘야 합니다. 기존에 드래그된 영역이 있을 경우 지워야 합니다.

 

ctrl + Z나 ctrl + Y도 있겠지만, 저는 히스토리까지 구현하는 건 귀찮으니 그냥 안하겠습니다.

 

드래그한 텍스트는 드래그 영역의 min, max를 구하고, substring으로 가져오면 됩니다.

지우는 것도 마찬가지로, min, max를 구하고 min부터 max - min 만큼 erase해주면 됩니다.

 

지우는 것의 경우에는 dragBounds가 사라지게 되니 renderDragBounds를 호출해줬고,

cursor가 min의 위치로 지정되니 renderCursor도 호출해줬습니다.

sf::String TextInput::getDraggedString() const
{
    size_t start = std::min(dragRange.first, dragRange.second);
    sf::String str = text.getString().substring(start, std::max(dragRange.first, dragRange.second) - start);

    return str;
}

void TextInput::removeDraggedString()
{
    size_t start = std::min(dragRange.first, dragRange.second);
    size_t sz = std::max(dragRange.first, dragRange.second) - start;

    currentString.erase(start, sz);
    cursorIndex = start;

    dragRange = { start, start };
    renderDragBounds();
    renderCursor();
}

 

이제 키보드 커맨드를 구현해봅시다.

if (e.key.control)
{
    if (e.key.code == sf::Keyboard::A) //ctrl + A : select all
    {
        size_t last = text.getString().getSize();
        setDragRange(0, last);
        setCursorIndex(last);
    }
    else if (e.key.code == sf::Keyboard::X) //ctrl + X : cut
    {
        if (dragRange.first != dragRange.second)
        {
            sf::Clipboard::setString(getDraggedString());
            removeDraggedString();

            updateText();
        }
    }
    else if (e.key.code == sf::Keyboard::C) //ctrl + C : copy
    {
        if (dragRange.first != dragRange.second)
            sf::Clipboard::setString(getDraggedString());
    }
    else if (e.key.code == sf::Keyboard::V) //ctrl + V : paste
    {
        if (dragRange.first != dragRange.second)
            removeDraggedString();

        sf::String str = sf::Clipboard::getString();
        currentString += str;
        updateText();
        setCursorIndex(cursorIndex + str.getSize());
        setDragRange(cursorIndex, cursorIndex);
    }
}

자세한 설명은... 생략하겠습니다.

 

여기 있는 커맨드는 모두 컨트롤 키가 필요하므로, e.key.control이 true일 때만 동작해야 합니다.

드래그한 영역이 있는지의 여부를 dragRange의 first와 second가 같은지로 판단했습니다.

그러니 드래그하지 않는 상황에서는 두 값을 임의의 값 하나로 설정해줘야 합니다.

 

다음으로, 컨트롤 키가 없는 커맨드입니다. 화살표 키와 Home, End키만 구현했습니다.

 

화살표 키는 커서를 옮깁니다. 그러니 커서 위치가 텍스트 밖으로 벗어나지는 않는지를 먼저 확인해줘야 합니다.

 

왼쪽/오른쪽의 경우에는 드래그 영역이 있다면 각각 min/max 값으로 커서를 옮겨줘야 합니다.

그 외에는 그냥 --/++만 하면 됩니다.

if (!e.key.control)
{
    if (e.key.code == sf::Keyboard::Right)
    {
        if (dragRange.first != dragRange.second)
            cursorIndex = std::max(dragRange.first, dragRange.second);
        else if (cursorIndex < text.getString().getSize())
            cursorIndex++;
        
        setDragRange(cursorIndex, cursorIndex); //remove drag bounds
        renderCursor();
    }
    else if (e.key.code == sf::Keyboard::Left)
    {
        if (dragRange.first != dragRange.second)
            cursorIndex = std::min(dragRange.first, dragRange.second);
        else if (cursorIndex > 0)
            cursorIndex--;

        setDragRange(cursorIndex, cursorIndex);
        renderCursor();
    }
}

 

위/아래의 경우에는 위 줄/아래 줄에서 현재 커서의 위치에 가장 가까운 글자를 찾아야 합니다.

그런데 폰트에 따라 한글과 영문자, 숫자, 특수 문자의 크기가 제각각인 경우가 많습니다.

그래서 글리프 (Glyph)에서 직접 그 글자의 크기를 구해야 커서 위치를 비교적 정확하게 찾을 수 있습니다.

 

드래그한 영역이 있을 경우, 위 키는 min을 기준으로 아래 키는 max를 기준으로 커서 위치를 찾습니다.

if (e.key.code == sf::Keyboard::Up)
{
    if (dragRange.first != dragRange.second)
        cursorIndex = dragRange.first;

    sf::Vector2f pos = text.findCharacterPos(cursorIndex);

    if (pos.y <= text.getPosition().y)
    {
        setDragRange(cursorIndex, cursorIndex);
        renderCursor();
        return;
    }

    size_t index = cursorIndex;
    while (index > 0 && text.findCharacterPos(index).y >= pos.y) index--;

    //upper line
    while (index > 0 && text.findCharacterPos(index).x > pos.x) index--;

    if (text.findCharacterPos(index).x + text.getFont()->getGlyph(text.getString()[index], text.getCharacterSize(), text.getStyle() & sf::Text::Style::Bold, text.getOutlineThickness()).bounds.width / 2 < pos.x && text.getString()[index] != '\n')
        index++;

    setCursorIndex(index);
    setDragRange(cursorIndex, cursorIndex);
}

if (pos.y <= text.getPosition().y) 부분은 현재 위치가 맨 윗 줄인 경우입니다.

이때는 커서를 옮길 필요가 없으니 바로 끝냅니다.

 

while 문은 y 값을 기준으로 바로 윗 줄을 찾은 후, x 값을 기준으로 바로 왼쪽 글자를 찾습니다.

width / 2 (red line) < pos

이때 이 글자의 크기 절반보다 현재 위치가 더 오른쪽에 있으면, 상대적으로 그 다음 글자에 가깝다는 이야기이니

index를 1 증가시켜줍니다. 다만 개행 문자의 경우에는 width가 0인 것도 있고, 그 다음 글자가 다음 줄로 넘어가게 되므로 예외 처리를 해줍니다.

 

아래 키도 비슷하게 해주면 됩니다.

if (e.key.code == sf::Keyboard::Down)
{
    if (dragRange.first != dragRange.second)
        cursorIndex = dragRange.first;

    sf::Vector2f pos = text.findCharacterPos(cursorIndex);

    size_t index = cursorIndex;
    size_t sz = text.getString().getSize();

    while (index < sz && text.findCharacterPos(index).y <= pos.y) index++;

    if (text.findCharacterPos(index).y <= pos.y)
    {
        setDragRange(cursorIndex, cursorIndex);
        renderCursor();
        return;
    }
    
    //down line
    while (index < sz && text.findCharacterPos(index).x <= pos.x && text.getString()[index] != '\n') index++;

    if (text.findCharacterPos(index).x + text.getFont()->getGlyph(text.getString()[index], text.getCharacterSize(), text.getStyle() & sf::Text::Style::Bold, text.getOutlineThickness()).bounds.width / 2 >= pos.x && index != sz && text.getString()[index] != '\n')
        index--;

    setCursorIndex(index);
    setDragRange(cursorIndex, cursorIndex);
}

얘는 마지막 줄 체크를 이렇게 하는 이유가 뭘까요? 제가 짠 코드인데 제가 이유를 모르겠습니다.

 

일단... string size를 벗어나지 않는 선에서 다음 줄이 나올 때까지 index를 증가시켰고

그럼에도 불구하고 y값이 변하지 않았다면 마지막 줄이라는 건 알 수 있습니다.

Up 키와 같은 방식으로 짰다면, pos.y + text.getCharacterSize() >= text.getPosition().y + text.getSize().y

정도가 조건이 되었을 겁니다. 폰트 크기가 지맘대로니까 얘도 영 쓰기가 찜찜하네요.

 

Home/End는 커서를 맨 처음, 맨 끝으로 지정해주기만 하면 됩니다.

if (e.key.code == sf::Keyboard::Home)
{
    setCursorIndex(0);
    setDragRange(0, 0);
}
else if (e.key.code == sf::Keyboard::End)
{
    setCursorIndex(text.getString().getSize());
    setDragRange(cursorIndex, cursorIndex);
}

 

글자를 입력할 때도 수정합시다.

void TextInput::getInput(sf::Event e)
{
    if (e.text.unicode == '\b')
    {
        if (!currentString.isEmpty())
        {
            if (dragRange.first != dragRange.second)
                removeDraggedString();
            else if (cursorIndex > 0)
                currentString.erase(--cursorIndex, 1);
        }
    }
    else if (e.text.unicode == '\n' || e.text.unicode == '\t' || e.text.unicode > 31)
    {
        if (dragRange.first != dragRange.second)
            removeDraggedString();

        currentString.insert(cursorIndex++, e.text.unicode);
    }

    imeString = "";

    updateText();
}
void TextInput::getInput(sf::String e, IMEObject ime)
{
    if (ime.type == IMEObject::Composition)
    {
        if (dragRange.first != dragRange.second)
            removeDraggedString();

        imeString = sf::String(ime.string);
        updateText();
    }
}

 

이제 드래그까지는 구현했군요. ...맞겠죠?


5. Clipping

이제 텍스트, 커서, 드래그 영역을 렌더링해야 합니다.

그냥 렌더링한다면 드래그 영역 -> 텍스트 -> (blink 여부에 따라) 커서를 렌더링하면 되지만

이전에 renderSize를 만들고 렌더링 영역을 따로 지정했었습니다.

 

SFML에 클리핑 관련해서 구현된 건 없지만, openGL에는 관련 함수가 있습니다.

glScissor입니다. glEnable(GL_SCISSOR_TEST)를 호출한 이후에 사용할 수 있습니다.

다만 얘는 matrix에 뭔가 적용하는 게 아니므로 SFML에서 pushGLState/popGLState를 호출한다고 얘가 저장되거나 롤백되지 않습니다. 그래서 사용자가 직접 enable/disable 해줘야 합니다.

 

사용자가 클리핑을 적용하고 싶지 않을 수도 있으니, 저는 renderSize.x와 renderSize.y 모두 음수면 적용하지 않게 했습니다.

void TextInput::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    states.transform *= getTransform();

    if (!(renderSize.x < 0 && renderSize.y < 0))
    {
        auto pos = sf::Vector2f(0, 0);
        auto size = renderSize;

        pos = states.transform.transformPoint(pos);
        size - states.transform.transformPoint(size);

        auto height = target.getSize().y;

        pos.y = height - pos.y - size.y;

        glEnable(GL_SCISSOR_TEST);
        glScissor((int)pos.x, (int)pos.y, (int)size.x, (int)size.y);
    }

    if (dragBound.size())
    {
        for (auto bound : dragBound)
            target.draw(bound, states);
    }

    target.draw(text_to_render, states);

    sf::RenderStates inverser = states;
    inverser.blendMode = Values::INVERT;
    target.draw(cursor, inverser);

    if (!(renderSize.x < 0 && renderSize.y < 0))
        glDisable(GL_SCISSOR_TEST);
}

클리핑할 영역은 global coordinate 기준이므로, 위치와 크기 모두 transform을 적용시켜줘야 합니다.

또 openGL에서의 y좌표는 RenderTexture에서 그랬던 것처럼, 위 아래가 뒤집혀있기 때문에

y좌표 또한 그에 맞춰서 계산해줘야 합니다.

 

커서는 invert mode로 그리도록 했습니다.

RenderTexture 위에 그릴 경우에는 제대로 된 효과가 나타나지 않을 수 있으니 이것까지 고려하려면

관련 조건문을 작성해줘야 합니다.

저 같은 경우에는 이때는 invert mode 없이 그냥 그리도록 했습니다.


6. Scroll

클리핑이 구현됨에 따라 스크롤도 필요하게 되었습니다.

찾아보니 가로 스크롤도 있는 마우스가 있는 것 같네요. 스크롤 enum이 두 개더라구요.

 

마우스 스크롤을 우선순위로 따르고, 그 다음은 세로 스크롤을 우선으로 처리하기로 했습니다.

보통 그렇게 처리되는 것 같네요.

 

스크롤에 따라 text의 position을 옮겨주면 됩니다. text의 상대적인 position이 바뀌는 거니 text input 자체의 position이 바뀌지는 않습니다.

void TextInput::checkScroll(sf::Event e)
{
    if (renderSize.x < 0 && renderSize.y < 0) return; //no scroll needed

    sf::Vector2f mousePos = sf::Vector2f(e.mouseWheelScroll.x, e.mouseWheelScroll.y);

    sf::FloatRect textSize = text.getLocalBounds();
    sf::Vector2f position = text.getPosition();
    float chSize = text.getCharacterSize();

    if (renderSize.y < textSize.height && renderSize.x < textSize.width)
    {
        if (e.mouseWheel.wheel == sf::Mouse::Wheel::VerticalWheel)
        {
            if (e.mouseWheelScroll.delta < 0)
            {
                if (pos.y + textSize.height > renderSize.y)
                    pos.y -= chSize;

                if (pos.y + textSize.height < renderSize.y)
                    pos.y = renderSize.y - textSize.height;
            }
            else if (e.mouseWheelScroll.delta > 0)
            {
                if (pos.y < 0)
                    pos.y += chSize;

                if (pos.y > 0)
                    pos.y = 0;
            }
        }
        else //Horizontal
        {
            if (e.mouseWheelScroll.delta < 0)
            {
                if (pos.x + textSize.width > renderSize.x)
                    pos.x -= chSize;

                if (pos.x + textSize.width < renderSize.x)
                    pos.x = renderSize.x - textSize.width;
            }
            else if (e.mouseWheelScroll.delta > 0)
            {
                if (pos.x < 0)
                    pos.x += chSizeX;

                if (pos.x > 0)
                    pos.x = 0;
            }
        }

        text.setPosition(pos);
        text_to_render = text;
    }
    else if (renderSize.y < textSize.height)
    {
        if (e.mouseWheelScroll.delta < 0)
        {
            if (pos.y + textSize.height > renderSize.y)
                pos.y -= chSize;

            if (pos.y + textSize.height < renderSize.y)
                pos.y = renderSize.y - textSize.height;
        }
        else if (e.mouseWheelScroll.delta > 0)
        {
            if (pos.y < 0)
                pos.y += chSizeY;

            if (pos.y > 0)
                pos.y = 0;
        }

        text.setPosition(pos);
        text_to_render = text;
    }
    else if (renderSize.x < textSize.width)
    {
        if (e.mouseWheelScroll.delta < 0)
        {
            if (pos.x + textSize.width > renderSize.x)
                pos.x -= chSize;

            if (pos.x + textSize.width < renderSize.x)
                pos.x = renderSize.x - textSize.width;
        }
        else if (e.mouseWheelScroll.delta > 0)
        {
            if (pos.x < 0)
                pos.x += chSizeX;

            if (pos.x > 0)
                pos.x = 0;
        }

        text.setPosition(pos);
        text_to_render = text;
    }
}

text의 local bound와 renderSize를 비교해서 text가 더 크면 스크롤이 가능하다는 것을 알 수 있습니다.

그 밖에는... 별 거 없네요. 조건에 따라 텍스트를 움직여주고, 허용치를 넘어가면 위치를 고정해줬습니다.

 

그런데 휠 동작으로만 텍스트가 스크롤되는 것이 아닙니다.

커서를 옮기거나 글자를 입력하면서 텍스트가 크기를 넘어서면 자동으로 위치가 넘어가는 것을 볼 수 있습니다.

 

그래서 이럴 때마다 스크롤을 업데이트해줘야 합니다.

void TextInput::updateScroll(bool textChanged)
{
    auto pos = text.getPosition();
    auto textSize = getLocalBounds();
    auto cursorPos = text.findCharacterPos(cursorIndex);

    if (cursorPos.x < 0)
        pos.x -= cursorPos.x;
    else if (cursorPos.x > renderSize.x)
        pos.x -= cursorPos.x - renderSize.x;

    if (cursorPos.y < 0)
        pos.y -= cursorPos.y;
    else if (cursorPos.y > renderSize.y)
        pos.y -= cursorPos.y - renderSize.y + text.getCharacterSize();

    if (textChanged)
    {
        if (textSize.width <= renderSize.x)
            pos.x = 0;
        else if (pos.x + textSize.width <= renderSize.x)
            pos.x = renderSize.x - textSize.width;

        if (textSize.height <= renderSize.y)
            pos.y = 0;
        else if (pos.y + textSize.height < renderSize.y)
            pos.y = renderSize.y - textSize.height;

    }

    text.setPosition(pos);
    renderCursor();
    update();
}

텍스트가 바뀐 경우에는, text bound 크기가 충분히 작으면 리셋을 시켜줘야하기 때문에 따로 if문으로 작성했습니다.

안 그러니까 뭔가 자꾸 어그러지더군요.

 

if (pos.y + textSize.height < renderSize.y) 이 부분은 작동이 매끄럽지가 않습니다.

글자를 지워서 그 line이 비게 되었을 때 textSize가 변동이 있는 것 같더라구요.

테스트 중인 폰트 기준으로 3px 정도 변해서 위치가 살짝 변합니다.

문제는 이거 외에 size를 적당하게 계산할 방법을 못 찾았습니다. 이전까지는 계속 textSize를 쓰다가 여기서만 다른 걸 쓰게 하니까 그런 것 같습니다.

line 수에 따라 character size를 곱해서 쓸까... 생각도 해봤는데 오차가 심하더군요.

 

그래서 알아서 하겠지 시전하고 그냥 냅뒀습니다.


7. 끝

 

이제 구현이 끝났습니다. 아마도요.

이 포스팅을 지금 2주째 조금씩 쓰고 있어서 뭘 안 했는지 기억이 안납니다.

 

기능 구현만 포스팅으로 썼고, 다른 건 생략했습니다. 예를 들어 마우스 클릭을 했을 때만 포커스를 켜서 텍스트를 입력받을 수 있도록 한다든지, 입력/드래그/복사 가능 여부를 지정할 수 있게 한다든지 같은 거요.

 

 

텍스트 입력
텍스트 선택

보기엔 괜찮은 것 같네요.

그래도 별로 쓰고 싶진 않습니다. 디버그 콘솔 같은 데가 아닌 이상은 되도록 말이죠.