Tkinter CookBook ～文字列置換アプリケーション～

# 概要

単純な**文字列置換アプリケーション**をTkinterで作成します。
最終的な外観や操作は以下のような感じになります。

![replace_string.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302006/4f6a8e35-55d0-b5fb-ed50-d30ea1f50add.gif)

# 前提

## 実行環境

本ページで記載しているコードは**Windows10, python3.7**で実行できることを確認しております。

## 必要なpythonモジュール

デフォルトでインストールされているモジュールのみを使用します。

## ソースコードについて

GitHubで公開しております。
https://github.com/The-town/ReplaceString

記事内のコードは若干手を加えているため、GitHub上のコードと差異があります。

# それでは、はじめましょう。

## STEP1 GUIオブジェクトの設定

```python:gui_component.py

import tkinter.scrolledtext as scrolled_text
import tkinter as tk


class Frame(tk.Frame):
    def __init__(self, master=None):
        tk.Frame.__init__(self, master)
        self["width"] = 100
        self["height"] = 100
        self["bg"] = "white"


class InputReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 50
        self["height"] = 20


class RuleReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 25
        self["height"] = 20


class OutputReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 50
        self["height"] = 20


class Button(tk.Button):
    def __init__(self, master=None):
        tk.Button.__init__(self, master)

        self["height"] = 1
        self["width"] = 10


class ReplaceButton(Button):
    def __init__(self, master=None):
        Button.__init__(self, master)

        self["text"] = "置換"


class CheckButton(tk.Checkbutton):
    def __init__(self, master=None):
        tk.Checkbutton.__init__(self, master)

        self["bg"] = "white"

        self["onvalue"] = "yes"
        self["offvalue"] = "no"


class StringTypeCheckButton(CheckButton):
    def __init__(self, master=None):
        CheckButton.__init__(self, master)

        self["text"] = "改行文字列\\nを使い、1行の文字列として置換する"
```

ここでは、基本的なウィジェット（Widget）を定義しています。
各ウィジェットがGUIのどこで使用されるかについては、以下の画像で表されます。

![Class名と場所の関係.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302006/0d86f63f-dc5d-28b6-429b-888d830b202a.png)

**tk.Frame**や**tk.Button**などのTkinterオブジェクトを、自分が作ったClassオブジェクトで継承しています。
これには以下のような理由があります。

* Tkinterオブジェクト側の仕様を **自分のClassオブジェクトで覆う** ことで、Tkinter側の仕様変更に備える
* 各ウィジェットに共通する背景色などを設定し統一化を図る


## STEP2 GUIオブジェクトの配置

```python:gui_component.py

import tkinter.scrolledtext as scrolled_text
import tkinter as tk


class Main:
    def __init__(self) -> None:
        root: tk.Tk = tk.Tk()
        root.title("文字列置換")

        text_frame: Frame = Frame(root)
        text_frame.grid(column=0, row=1)

        self.input_replace_text: InputReplaceText = InputReplaceText(master=text_frame)
        self.input_replace_text.grid(column=0, row=0)

        self.rule_replace_text: RuleReplaceText = RuleReplaceText(master=text_frame)
        self.rule_replace_text.grid(column=1, row=0)

        self.output_replace_text: OutputReplaceText = OutputReplaceText(master=text_frame)
        self.output_replace_text.grid(column=2, row=0)

        function_frame: Frame = Frame(root)
        function_frame.grid(column=0, row=0)

        replace_button: ReplaceButton = ReplaceButton(function_frame)
        replace_button.grid(column=0, row=1)

        self.string_type_check_button: StringTypeCheckButton = StringTypeCheckButton(function_frame)
        self.string_type_check_button.grid(column=0, row=0)

        root.mainloop()


class Frame
...

```

ここでは**Main**クラスを追加して、各ウィジェットを配置しています。
注目したいのは、各ウィジェットの**master**です。整理すると以下の表のとおりになります。

| ウィジェット | master |
| ---- | ---- |
| text_frame | root |
| input_replace_text | text_frame |
| rule_replace_text | text_frame |
| output_replace_text | text_frame |
| function_frame | root |
| replace_button | function_frame |
| string_type_check_button | function_frame |

ここで**master**となっているウィジェットは3つあります。
**root**と**text_frame**と**function_frame**です。

rootはTkinterを実行した際に出力されるウィンドウ全体のことを表します。
そのため、必ずrootがあらゆるウィジェットの最終的なmasterになります。

このコードでは、それ以外に2つのFrameウィジェットをmasterとして間に挟んでいます。

先程お見せした画像へFrameウィジェットの情報を加えてみます。

![Frame.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302006/fb153e26-933d-f0ad-0201-dac4aceb9bb9.png)

このように、rootと各ウィジェット（例えばButtonやText）の間に、Frameウィジェットを挟む目的は、各ウィジェットを配置しやすくするためです。
また、後で変更しやすくするためでもあります。

過去の自分の記事から引用します。

> 「root」つまり画面のオブジェクト上に直接「Label」オブジェクトや「Entry」オブジェクトを配置しない理由は様々でしょう。このコードでそのようにしない理由は後々の配置換えを容易にするためです。

> 直接「root」上にオブジェクトを乗せ、「grid()」で配置する方法はオブジェクト数が少なければ非常に有効です。
ただし、オブジェクトの数が多くなると相対的な位置関係は複雑になります。また、あるオブジェクトAをあるオブジェクトBのとなりに配置したくなった場合、オブジェクト数が多いとほとんどすべてのオブジェクトの配置を見直す必要が出て来ます。

> これは面倒なので、関連するオブジェクトを「Frame」オブジェクト上にまとめてしまい、「root」の上に乗るオブジェクトは「Frame」オブジェクトのみとするのです。このようにすることで、後々オブジェクトを追加したい場合はそのオブジェクトを配置するのに適切な「Frame」オブジェクトを見つけ、その「Frame」オブジェクト上に乗っているオブジェクトのみ配置を変えればよいのです。

> この考え方は私たちが普段から何気なく行っていることなのです。WindowsでもLinuxでもファイルを作成した後は、適切なフォルダへと配置するでしょう。あらゆるファイルを一つのフォルダへ放り込んでいる人は少ないのではないでしょうか。

> [Tkinter CookBook 1. 新しい「Frame」オブジェクトを作成](https://qiita.com/The-town/items/3bd1fcf5ee26242ed877#1-%E6%96%B0%E3%81%97%E3%81%84frame%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90)

## STEP3 置換関数の作成

```python:gui_component.py
class Main:
    ...

    def execute_replace_rule(self, event=None) -> None:
        input_text: str = self.input_replace_text.get(1.0, tk.END)[:-1]
        rule_replace_text: str = self.rule_replace_text.get(1.0, tk.END)[:-1]

        strings_before_change, strings_after_change = analyze_replace_rules(rule_replace_text)

        self.output_replace_text.delete(1.0, tk.END)
        output_text = change_string(input_text, tuple(strings_before_change), tuple(strings_after_change))
        self.output_replace_text.insert(tk.END, output_text)
```

ここでは**input_replace_text**に入力された文字列を、**rule_replace_text**に入力された規則に従って変換し、**output_replace_text**へ出力するという処理を行っています。

入力された置換規則を解析する**analyze\_replace\_rules**関数、入力された文字列を置換する**change\_string**関数は後ほど解説します。

途中で**self.output_replace_text.delete(1.0, tk.END)**をしているのは、出力された文字列を消すためです。
以前に置換した文字列を削除した上で出力することで、何度も置換ボタンを押した際に同一の挙動をするようにしています。

```
# deleteなしの場合

1回目の置換：
    置換後の文字列です。

2回目の置換：
    置換後の文字列です。
    置換後の文字列です。
...

# deleteありの場合

1回目の置換：
    置換後の文字列です。

2回目の置換：
    置換後の文字列です。
...
```

### analyze\_replace\_rules

```python:replace_rule.py

from typing import Tuple, List


def analyze_replace_rules(rule_replace_text: str) -> Tuple[List, List]:
    """
    入力された置換ルールを解析して、置換前の文字列と置換後の文字列を分ける関数
    Parameters
    ----------
    rule_replace_text: str
        置換ルール

    Returns
    -------
    strings_before_change, strings_after_change: tuple
        置換前の文字列と置換後の文字列のリスト
    """
    split_string: str = " -> "

    strings_before_change: List[str] = []
    strings_after_change: List[str] = []

    for rule in rule_replace_text.split("\n"):
        if split_string in rule:
            strings_before_change.append(rule.split(split_string)[0])
            strings_after_change.append(rule.split(split_string)[1])

    return strings_before_change, strings_after_change
```

置換ルールは置換前の文字列と置換後の文字列、そして` -> `という区切り文字列で構成されます。

```text:置換ルール
hogehoge -> hugahuga
hogehogeという文字列をhugahugaという文字列へ置換
```

また、置換ルールは複数記入することが可能です。
そのため、置換前の文字列を改行文字列`\n`で各行に分けた後、` -> `で置換前と置換後の文字列に分割します。

### change_string

```python:replace_rule.py

import re
from typing import Tuple, List


def analyze_replace_rules(rule_replace_text: str) -> Tuple[List, List]:
    ...


def change_string(text: str, strings_before_change: Tuple[str], strings_after_change: Tuple[str]) -> str:
    """
    文章中にある、複数の指定した文字列を、複数の指定した文字列へ変換する関数

    Parameters
    ----------
    text: str
        置換対象の文章
    strings_before_change: Tuple[str]
        置換する文字列のタプル
    strings_after_change: Tuple[str]
        置換後の文字列のタプル

    Returns
    --------
    text: str
        置換後の文章
    """
    for i in range(len(strings_after_change)):
        re_obj_change_string: re.Pattern = re.compile(rf"{strings_before_change[i]}")
        text = re.sub(re_obj_change_string, strings_after_change[i], text)
    return text
```

正規表現に対応した置換としたいため**re**モジュールを使用します。

参考：[re --- 正規表現操作](https://docs.python.org/ja/3.7/library/re.html)

## STEP4 置換関数とボタンの紐付け

```python:gui_component.py
class Main:
    def __init__(self) -> None:
        ...

        replace_button: ReplaceButton = ReplaceButton(function_frame)
        replace_button.grid(column=0, row=1)
        replace_button["command"] = self.execute_replace_rule # 追加
        
        ...
```

**replace_button**の**command**属性へ、置換関数**execute\_replace\_rule**を設定します。
これでボタンが押された際に置換関数が実行されます。

## STEP5 実行

以下の**main.py**よりここまでのコードを実行してみます。

```python:main.py
from gui_component import Main


if __name__ == '__main__':
    gui_main: Main = Main()
```

フォルダ構成は以下のようになっているかと思います。

* main.py
* gui_component.py
* replace_rule.py

![replaceString_STEP5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302006/22f25a86-79bd-affc-ec19-509bc352440f.png)

置換ボタンを押せば文字列の置換を行います。

## 【追加STEP】STEP6 見た目を整える

```python:gui_component.py
import tkinter.scrolledtext as scrolled_text
import tkinter as tk

from replaceRule import analyze_replace_rules, change_string, change_string_list


class Main:
    def __init__(self) -> None:
        root.configure(background='white')
    ...

class Frame(tk.Frame):
    def __init__(self, master=None):
        tk.Frame.__init__(self, master)
        self.grid(column=0, row=0)
        self["width"] = 100
        self["height"] = 100
        self["padx"] = 10
        self["pady"] = 10
        self["bg"] = "white"


class InputReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 50
        self["height"] = 20
        self["font"] = ("メイリオ", 12)


class RuleReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 25
        self["height"] = 20
        self["font"] = ("メイリオ", 12)


class OutputReplaceText(scrolled_text.ScrolledText):
    def __init__(self, master=None):
        scrolled_text.ScrolledText.__init__(self, master)
        self["width"] = 50
        self["height"] = 20
        self["font"] = ("メイリオ", 12)


class Button(tk.Button):
    def __init__(self, master=None):
        tk.Button.__init__(self, master)

        self["height"] = 1
        self["width"] = 10


class ReplaceButton(Button):
    def __init__(self, master=None):
        Button.__init__(self, master)

        self["text"] = "置換"
        self["font"] = ("メイリオ", 15)


class CheckButton(tk.Checkbutton):
    def __init__(self, master=None):
        tk.Checkbutton.__init__(self, master)

        self["bg"] = "white"
        self["text"] = "test"
        self["font"] = ("メイリオ", 15)


class StringTypeCheckButton(CheckButton):
    def __init__(self, master=None):
        CheckButton.__init__(self, master)

        self["text"] = "改行文字列\\nを使い、1行の文字列として置換する"
```

背景色、フォントを整えました。
ここは自由にカスタマイズしましょう。

![replaceString_STEP6](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302006/bc5b0243-2ada-7086-e193-b7bbb27c1a39.png)

## 【追加STEP】STEP7 チェックボタンを使う

```python:gui_component.py

class Main:
    def execute_replace_rule(self, event=None) -> None:
        ...
        output_text: str = ""
        if self.string_type_check_button.state.get() == "one_line":
            output_text = change_string(input_text, tuple(strings_before_change), tuple(strings_after_change))
        elif self.string_type_check_button.state.get() == "many_lines":
            output_text = change_string_list(tuple(input_text.split("\n")), tuple(strings_before_change),
                                             tuple(strings_after_change))
        self.output_replace_text.insert(tk.END, output_text)


class CheckButton(tk.Checkbutton):
    def __init__(self, master=None):
        tk.Checkbutton.__init__(self, master)

        self["bg"] = "white"
        self["text"] = "test"
        self["font"] = ("メイリオ", 15)

        self.state: tk.StringVar = tk.StringVar()
        self.state.set("no")
        self["variable"] = self.state
        self["onvalue"] = "yes"
        self["offvalue"] = "no"


class StringTypeCheckButton(CheckButton):
    def __init__(self, master=None):
        CheckButton.__init__(self, master)

        self["text"] = "改行文字列\\nを使い、1行の文字列として置換する"

        self.state.set("many_lines")
        self["onvalue"] = "one_line"
        self["offvalue"] = "many_lines"
```

```python:replaceRule.py
def change_string_list(texts: Tuple[str, ...], strings_before_change: Tuple[str, ...],
                       strings_after_change: Tuple[str, ...]) -> str:
    """
    リストとして受け取った文章中にある、複数の指定した文字列を、複数の指定した文字列へ変換する関数
    正規表現の「^」や「$」をリストの要素ごとに実行するために作成した。

    例；
    texts = [123, abc, 145c]
    ^1 -> a
    [a23, abc, a45c]

    Parameters
    ----------
    texts: Tuple[str, ...]
        置換対象の文章
    strings_before_change: Tuple[str, ...]
        置換する文字列のタプル
    strings_after_change: Tuple[str, ...]
        置換後の文字列のタプル

    Returns
    --------
    text: str
        置換後の文章
    """
    tmp_texts: List[str] = list(texts)
    for i in range(len(strings_after_change)):
        re_obj_change_string: re.Pattern = re.compile(rf"{strings_before_change[i]}")
        tmp_texts = [re.sub(re_obj_change_string, strings_after_change[i], text) for text in tmp_texts]
    return "\n".join(tmp_texts)

```

チェックボタンによって処理を切り替えられるようにします。

**StringTypeCheckButton**の**onvalue**・**offvalue**は、それぞれチェックボタンにチェックが入っているときと入っていないときの状態を表す文字列です。

状態を取り出すために**string_type_check_button.state.get()**を使います。

参考:[30. ttk.Checkbutton](https://tkdocs.com/shipman/ttk-Checkbutton.html)