授業でPygameを触り、グループワーク時にタイピングゲームを作ったので、その時に面倒だったテキスト入力の備忘録。

Pygame 1.9.5(SDL2.0.0)以降のテキスト入力

Pygame1.9.5以降、新たなイベントが加わり、テキスト入力が比較的楽になったようです(多分)。とはいえ、Pygameの日本語のドキュメントにそれがまだ載ってない上に、公式のテキスト入力のサンプル(この記事の執筆時の最新コミット)も分かりづらかったので、改めて書くことにしました。テキスト入力系で追加されたのは、TEXTINPUTとTEXTEDITINGの2つです。TEXTINPUTは直接入力モード時に受け取るイベント、TEXTEDITINGがIMEを使っている時に受け取るイベントらしいです。また、SDLのバグの関係で、IMEの予測変換は使えないので今回はそれを扱っていません。

プログラム

ライブラリ化したものを上げておきました。ご活用ください。
DYGV/pygame_ime_textinput - GitHub 以下がテキストの処理をするロジック(text.py)

from typing import List


class Text:
    """
    PygameのINPUT、EDITINGイベントで使うクラス
    カーソル操作や文字列処理に使う
    """

    def __init__(self) -> None:
        self.text = ["|"]  # 入力されたテキストを格納していく変数
        self.editing: List[str] = []  # 全角の文字編集中(変換前)の文字を格納するための変数
        self.is_editing = False  # 編集中文字列の有無(全角入力時に使用)
        self.cursor_pos = 0  # 文字入力のカーソル(パイプ|)の位置

    def __str__(self) -> str:
        """self.textリストを文字列にして返す"""
        return "".join(self.text)

    def edit(self, text: str, editing_cursor_pos: int) -> str:
        """
        edit(編集中)であるときに呼ばれるメソッド
        全角かつ漢字変換前の確定していないときに呼ばれる
        """
        if text:  # テキストがあるなら
            self.is_editing = True
            for x in text:
                self.editing.append(x)  # 編集中の文字列をリストに格納していく
            self.editing.insert(editing_cursor_pos, "|")  # カーソル位置にカーソルを追加
            disp = "[" + "".join(self.editing) + "]"
        else:
            self.is_editing = False  # テキストが空の時はFalse
            disp = "|"
        self.editing = []  # 次のeditで使うために空にする
        # self.cursorを読み飛ばして結合する
        return (
            format(self)[0 : self.cursor_pos]
            + disp
            + format(self)[self.cursor_pos + 1 :]
        )

    def input(self, text: str) -> str:
        """半角文字が打たれたとき、もしくは全角で変換が確定したときに呼ばれるメソッド"""
        self.is_editing = False  # 編集中ではなくなったのでFalseにする
        for x in text:
            self.text.insert(self.cursor_pos, x)  # カーソル位置にテキストを追加
            # 現在のカーソル位置にテキストを追加したので、カーソル位置を後ろにずらす
            self.cursor_pos += 1
        return format(self)

    def delete_left_of_cursor(self) -> str:
        """カーソルの左の文字を削除するためのメソッド"""
        # カーソル位置が0であるとき
        if self.cursor_pos == 0:
            return format(self)
        self.text.pop(self.cursor_pos - 1)  # カーソル位置の一個前(左)を消す
        self.cursor_pos -= 1  # カーソル位置を前にずらす
        return format(self)

    def delete_right_of_cursor(self) -> str:
        """カーソルの右の文字を削除するためのメソッド"""
        # カーソル位置より後ろに文字がないとき
        if len(self.text[self.cursor_pos+1:]) == 0:
            return format(self)
        self.text.pop(self.cursor_pos + 1)  # カーソル位置の一個後(右)を消す
        return format(self)

    def enter(self) -> str:
        """入力文字が確定したときに呼ばれるメソッド"""
        # カーソルを読み飛ばす
        entered = (
            format(self)[0 : self.cursor_pos] + format(self)[self.cursor_pos + 1 :]
        )
        self.text = ["|"]  # 次回の入力で使うためにself.textを空にする
        self.cursor_pos = 0  # self.text[0] == "|"となる
        return entered

    def move_cursor_left(self) -> str:
        """inputされた文字のカーソル(パイプ|)の位置を左に動かすメソッド"""
        if self.cursor_pos > 0:
            # カーソル位置をカーソル位置の前の文字と交換する
            self.text[self.cursor_pos], self.text[self.cursor_pos - 1] = (
                self.text[self.cursor_pos - 1],
                self.text[self.cursor_pos],
            )
            self.cursor_pos -= 1  # カーソルが1つ前に行ったのでデクリメント
        return format(self)

    def move_cursor_right(self) -> str:
        """inputされた文字のカーソル(パイプ|)の位置を右に動かすメソッド"""
        if len(self.text) - 1 > self.cursor_pos:
            # カーソル位置をカーソル位置の後ろの文字と交換する
            self.text[self.cursor_pos], self.text[self.cursor_pos + 1] = (
                self.text[self.cursor_pos + 1],
                self.text[self.cursor_pos],
            )
            self.cursor_pos += 1  # カーソルが1つ後ろに行ったのでインクリメント
        return format(self)

次に、プログラム本体(text_input.py)ですが、日本語が扱えるフォントを用意しておく必要があります(Windowsならコピペでも動くかもしれません)。追記をご参照ください。

↓プログラム本体

import sys
import pygame
from pygame.locals import *

from text import Text


def draw_text(text: str) -> None:
    """
    入力文字を描画するための関数
    """
    text_surface = font.render(text, True, (0, 0, 0))
    screen.fill((112, 225, 112))
    # テキストに応じて上下左右中央揃えにする
    center_w = (800 / 2) - (text_surface.get_width() / 2)
    center_h = (600 / 2) - (text_surface.get_height() / 2)
    screen.blit(text_surface, (center_w, center_h))
    pygame.display.update()


def event_loop():
    # テキスト入力時のキーとそれに対応するイベント
    event_trigger = {
        K_BACKSPACE: text.delete_left_of_cursor,
        K_DELETE: text.delete_right_of_cursor,
        K_LEFT: text.move_cursor_left,
        K_RIGHT: text.move_cursor_right,
        K_RETURN: text.enter,
    }
    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit(0)
            # キーダウンかつ、全角のテキスト編集中でない
            elif event.type == KEYDOWN and not text.is_editing:
                if event.key in event_trigger.keys():
                    input_text = event_trigger[event.key]()
                # 入力の確定
                if event.unicode in ("\r", "") and event.key == K_RETURN:
                    print(input_text)  # 確定した文字列を表示
                    draw_text(format(text))  # テキストボックスに"|"を表示
                    input_text = format(text)  # "|"に戻す
                    break
            elif event.type == TEXTEDITING:  # 全角入力
                input_text = text.edit(event.text, event.start)
            elif event.type == TEXTINPUT:  # 半角入力、もしくは全角入力時にenterを押したとき
                input_text = text.input(event.text)
            # 描画しなおす必要があるとき
            if event.type in [KEYDOWN, TEXTEDITING, TEXTINPUT]:
                draw_text(input_text)


if __name__ == "__main__":
    pygame.init()
    screen = pygame.display.set_mode((800, 600))
    font = pygame.font.SysFont("yumincho", 30)
    text = Text()  # テキスト処理のロジックTextクラスをインスタンス化
    pygame.key.start_text_input()  # input, editingイベントをキャッチするようにする
    draw_text(format(text))  # 起動時にカーソルを表示するようにする
    event_loop()

実行結果

テキストを入力し、エンターを押すと入力を確定します。確定すると、コンソール画面に確定した文字が表示される単純な作りにしています。また、日本語入力モード中では一般的に使われるアンダーラインではなく、[]を使って日本語入力モード中であることを示しています。アンダーライン版の動作は追記に載っています。

デモ

追記

フォントについて

pygameの初期化をした後

font = pygame.font.SysFont("yumincho", 30)

としていますが、環境によってはyuminchoが無く、文字化けしますので注意ください。(そもそもフォントがない状態は文字化けとは言いませんが…) おすすめはIPAexフォントなど適当な日本語フォントを自身の環境に落とし、

font = pygame.font.Font("落としたフォントまでのパス", フォントサイズ)

とするのがいいでしょう。

一般的な日本語入力

今回は日本語入力中は括弧[]でそれを表していましたが、freetypeを使って少しコードを弄ると普通のアンダーラインを使った入力ができるようになります。 日本語入力

TEXTEDITINGの文字数について

SDLの実装上、TEXTEDITINGが受け取れる(保持できる)サイズが決まっており、現在のSDLのドキュメントを見ると

char[32] text the null-terminated editing text in UTF-8 encoding

と書かれています。

char(1バイト) × 32であり、NULL終端文字を除いて、使えるのは31バイトであると考えられます。UTF-8の日本語は3バイトですので、TEXTEDITING(編集中の文字)が扱える日本語はせいぜい10文字が限界だと思います。

これは簡単なプログラムで確かめることができます。 適当に全角入力で「あいうえおかきくけこさしすせそ」と打っても編集中の文字列は「あいうえおかきくけこ」までしか表示されないかと思います。エンターキーを押して入力を確定させた後もTEXTINPUTで受け取るのは「あいうえおかきくけこ」となっていると思います。この仕様で困ることは個人的にはありませんでしたが、10文字程度入力したのちに変換等をする人は注意が必要になります。

import sys

import pygame
from pygame.locals import *


if __name__ == "__main__":
    pygame.init()
    pygame.display.set_mode((50, 50))
    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit(0)
            elif event.type == TEXTEDITING and len(event.text) > 0:
                print("編集中の文字: {}".format(event.text))
            elif event.type == TEXTINPUT and len(event.text) > 0:
                print("入力された文字: {}".format(event.text))

機能追加✨・バグ修正🐛

2021/05/02

  • 🐛 カーソルの位置が先頭であるのにも関わらず、その後ろの文字を消そうとしていた部分を修正

2021/05/03

  • ✨ デリートキーによる文字削除をする機能を追加