質問をすることでしか得られない、回答やアドバイスがある。

15分調べてもわからないことは、質問しよう!

ただいまの
回答率

88.93%

Python3 Tkinter ダイアログを表示したい2

解決済

回答 1

投稿 編集

  • 評価
  • クリップ 0
  • VIEW 282

person

score 81

少し前に、
Python3 Tkinter ダイアログ表示したい
にてダイアログを表示し、そこで選んだボタンの文字列(OK, キャンセルなど)やTrue, Falseを戻り値として取得したいという質問をしました。

そこで
teamikl様にsimpledialog.SimpleDialog をカスタマイズして使う提案をしていただいたのですが、以下のようにソースコードを書き換えたところ、RuntimeErrorが発生してしまいました。

スレッドを使っていない時は問題なく動作したので、スレッドの使い方やスレッドとの相性だと思うのですが、具体的な理由がわかりません。

対処方法を教えていただきたいです。

アプリケーションの仕様としては、ボタンを押した時にエントリが空だったらダイアログを表示する。ダイアログの続行ボタンを押した場合はエントリがから出なかった時と同じ処理(App.func())を実行、キャンセルを押した時は何もしない(returnする)ような感じです。

  • main.py
import tkinter as tk
import tkinter.ttk as ttk
import threading
import time
from datetime import datetime

import mydialog

class App:
    def __init__(self, win):
        self.flag = True
        self.win = win
        self.create()

    def create(self):
        self.clock = tk.Label(self.win)
        self.clock.grid()
        self.entry = ttk.Entry(win)
        self.entry.grid()
        self.button = ttk.Button(win, text="click")
        self.button.grid()
        self.thread = threading.Thread(target=self.update)
        self.thread.start()
        self.bind_event()

    def update(self):
        while self.flag:
            self.clock["text"] = datetime.now().strftime("%Y/%m/%d %H:%M:%S.%f")
            time.sleep(0.001)

    def bind_event(self):
        self.button.bind("<ButtonRelease>", self.pushed)

    def pushed(self, ev):
        self.str = self.entry.get()
        print("str:" + self.str)
        if self.str == "":
            ret = mydialog.askyesno(message="no str...", buttons=["続行", "キャンセル"], parent=self.win)
            print(ret)
            if ret == "続行":
                pass
            else:
                return
        self.func()

    def func(self):
        # 続行処理
        pass


def close(app):
    app.flag = False
    app.win.destroy()

if __name__ == "__main__":
    win = tk.Tk()
    app = App(win)
    win.protocol("WM_DELETE_WINDOW", lambda:close(app))
    win.mainloop()
  • mydialog.py
import tkinter as tk
from tkinter import ttk, simpledialog
from functools import partial

# https://teratail.com/questions/274362
# 押されたボタンの文字列を戻り値とする

def askyesno(title="", message="", buttons=["OK", "Cancel"], parent=None):
    # messageのフォント変更
    parent.option_add("*Message.font", "MSPゴシック 50")
    # ボタンのフォント変更
    font = ("", 20)
    style = ttk.Style()
    style.configure("MyDialog.TLabel", font=font)
    style.configure("MyDialog.TButton", font=font)
    simpledialog.Frame = partial(ttk.Frame, style="MyDialog.TFrame")
    simpledialog.Label = partial(ttk.Label, style="MyDialog.TLabel")
    simpledialog.Button = partial(ttk.Button, style="MyDialog.TButton")
    dialog = simpledialog.SimpleDialog(
        master=parent,
        title=title,
        text=message,
        buttons=buttons
    )
    return buttons[dialog.go()]
  • 実行結果(続行ボタンを押した時のものだが、キャンセルボタンも同様のエラー).
str:
続行
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 932, in _bootstrap_inner
    self.run()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/USER/Desktop/main.py", line 28, in update
    self.clock["text"] = datetime.now().strftime("%Y/%m/%d %H:%M:%S.%f")
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/tkinter/__init__.py", line 1648, in __setitem__
    self.configure({key: value})
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/tkinter/__init__.py", line 1637, in configure
    return self._configure('configure', cnf, kw)
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/tkinter/__init__.py", line 1627, in _configure
    self.tk.call(_flatten((self._w, cmd)) + self._options(cnf))
RuntimeError: main thread is not in main loop
  • 気になる質問をクリップする

    クリップした質問は、後からいつでもマイページで確認できます。

    またクリップした質問に回答があった際、通知やメールを受け取ることができます。

    クリップを取り消します

  • 良い質問の評価を上げる

    以下のような質問は評価を上げましょう

    • 質問内容が明確
    • 自分も答えを知りたい
    • 質問者以外のユーザにも役立つ

    評価が高い質問は、TOPページの「注目」タブのフィードに表示されやすくなります。

    質問の評価を上げたことを取り消します

  • 評価を下げられる数の上限に達しました

    評価を下げることができません

    • 1日5回まで評価を下げられます
    • 1日に1ユーザに対して2回まで評価を下げられます

    質問の評価を下げる

    teratailでは下記のような質問を「具体的に困っていることがない質問」、「サイトポリシーに違反する質問」と定義し、推奨していません。

    • プログラミングに関係のない質問
    • やってほしいことだけを記載した丸投げの質問
    • 問題・課題が含まれていない質問
    • 意図的に内容が抹消された質問
    • 過去に投稿した質問と同じ内容の質問
    • 広告と受け取られるような投稿

    評価が下がると、TOPページの「アクティブ」「注目」タブのフィードに表示されにくくなります。

    質問の評価を下げたことを取り消します

    この機能は開放されていません

    評価を下げる条件を満たしてません

    評価を下げる理由を選択してください

    詳細な説明はこちら

    上記に当てはまらず、質問内容が明確になっていない質問には「情報の追加・修正依頼」機能からコメントをしてください。

    質問の評価を下げる機能の利用条件

    この機能を利用するためには、以下の事項を行う必要があります。

回答 1

checkベストアンサー

0

スレッドの使い方やスレッドとの相性だと思うのですが、具体的な理由がわかりません。

GUIでマルチスレッドにする場合、
GUI関係の操作は必ず 描画のイベントループを担当しているスレッド側
(tkinter の場合は mainloop() で、大抵の場合メインスレッド)で行う必要があります。

他のスレッドから同時にGUIへアクセスすると不都合が生じます。
一見うまく動いているように見えても、タイミング次第でエラーになる事もあるので、
どの操作がスレッドセーフなのか、そうでないのかは見極めが難しい箇所です。

以下は、3つのトピックがあるので注意してください。

  • GUIでのマルチスレッドで、スレッド側からGUIを更新する方法
  • SimpleDialogの実装の問題点
  • アプリ終了時に、スレッドが終了済みのGUIへアクセスする問題

問題点: 別スレッドからのGUI操作

self.clock["text"] = datetime.now().strftime("%Y/%m/%d %H:%M:%S.%f")

問題点2:

simpledialog.SimpleDialog がイベントループを終了してるようです。
simpledialog.Dialog や messagebox.askyesno では発生しません。
(アプリ終了時にエラーは出ますが、別件です。今回はダイアログを閉じた時のエラーのみを焦点)

問題点3:

スレッドとTkinter の生存期間について、
スレッド内からTkinter へ直接アクセスが発生しているが、
Tkinter のイベントループ終了後にもGUIへのアクセスが発生する。
対策A => WM_DELETE_WINDOW のハンドラで、tkのイベントループ終了前にスレッドを終了する。
対策B => 独自のqueue 経由して GUI 更新を処理する。


解決策1-1: メインスレッド側でGUIの更新をする

完全なの方法ではありませんが、一般的なケースで
tkinterの場合は、after_idle に関数・引数を渡す事で
mainloop() 内から呼び出してくれます。

  • event_generate
  • after_idle
  • after

辺りのメソッドを使い、mainloop側でGUIの処理は行います。

ラベルの表示変更だけならスレッド側で行っても大丈夫なことはあります、
…が実際に安全かどうかは解りません(詳しく調べてない)

但し今回(SimpleDialog利用)の場合、これでは不足で
独自にqueue等を用いて確実にメインスレッド側の win.mainloop() で処理する必要があるかもしれません。(ここは推測)
SimpleDialog内部でも独自のイベントループを持ち、
ダイアログ表示中はそちらで肩代わりしてますが、ダイアログが閉じされたときに終了してしまうので、
上記のエラーの原因となっているようでした。

対応するコードは割愛。少し大掛かりな変更が必要です。
要: queue を使ったスレッド間通信。

解決策2: simpledialog.Dialog の実装を使う。

解決策3:
因みに、時刻の更新表示のようなものならスレッドを使わなくても
after()でのタイマーでも可能で、after_cancel()で停止もできます。

スレッドを他の用途に使う必要がなければ、タイマーが一番無難な解決策です。

追記: タイマーでの実装。(※スレッドで扱う場合の問題解決にはなりません)

#!/usr/bin/env python3.8

import datetime
import tkinter as tk
from tkinter import ttk
import mydialog # https://teratail.com/questions/275549 質問文から借用


def getTimestamp(_time_format="%Y/%m/%d %H:%M:%S.%f"):
    return datetime.datetime.now().strftime(_time_format)


class App:
    def __init__(self, win):
        self.win = win
        self._running = False
        self._timer = None
        self._init_widgets()
        self._init_events()

    def _init_widgets(self):
        time_text = self._time_text = tk.StringVar(self.win)
        input_text = self._input_text = tk.StringVar(self.win)
        frame = self._frame = ttk.Frame(self.win)
        label = self._label = ttk.Label(frame, textvar=time_text)
        entry = self._entry = ttk.Entry(frame, textvar=input_text)
        stop_button = self._stop_button = ttk.Button(frame, text="Stop")
        start_button = self._start_button = ttk.Button(frame, text="Start")

        frame.pack(fill=tk.BOTH, expand=True)
        label.pack()
        entry.pack()
        stop_button.pack()
        start_button.pack()

    def start_timer(self):
        assert not self._running
        self._running = True
        self._timer = self.win.after_idle(self._on_timer)

    def _init_events(self):
        self._stop_button.bind("<Button-1>", self._on_stop_timer)
        self._start_button.bind("<Button-1>", self._on_start_timer)

    def _on_timer(self):
        if not self._running and self._timer:
            self.win.after_cancel(self._timer)
            self._timer = None
            return

        self._time_text.set(getTimestamp())

        # NOTE: 60FPS: 16.666 (1000/60)
        # あまり小さくし過ぎても無効な値になります。
        self._timer = self.win.after(15, self._on_timer)

    def _on_start_timer(self, event):
        if not self._running:
            self.start_timer()

    def _on_stop_timer(self, event):
        import mydialog
        if not self._input_text.get():
            ret = mydialog.askyesno(message="中断します", parent=self.win)
            if ret == "OK":
                self._running = False


def main():
    win = tk.Tk()
    app = App(win)
    app.start_timer()
    win.mainloop()


if __name__ == '__main__':
    main()

「スレッド側でダイアログを開いて更にモーダルにしたい」
みたいな時は、更にスレッドに配慮した工夫が必要になります。

参考: 
PythonでTkinterを使ってDiscordのRichPresenceを使えるようにしたい
質問自体は全然異なりますが、スレッド内ループの停止・再開や
スレッド側でダイアログ表示&queue,Event を用いたスレッド間通信のサンプル
[source on repl.it]

投稿

編集

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

15分調べてもわからないことは、teratailで質問しよう!

  • ただいまの回答率 88.93%
  • 質問をまとめることで、思考を整理して素早く解決
  • テンプレート機能で、簡単に質問をまとめられる

関連した質問

同じタグがついた質問を見る