PR

CustomTkinterでオリジナルのメニューバー(File・表示・設定・Help)を自作する

Python

CUSTOMTKINTER GUIDE VOL.2

スポンサーリンク

CustomTkinterでオリジナルのメニューバー(File・表示・設定・Help)を自作する

こんにちは。ニンジンです🥕

前回、CustomTkinterの基本の使い方を紹介しました。CTkLabel・CTkButton・CTkEntry・CTkFrameという基本部品と、pack/gridのレイアウトが分かると、簡単な入力フォームまではすぐに作れるようになります。

今回は一歩進んで、Windowsアプリでおなじみの「File・表示・設定・Help」のようなメニューバー(ウィンドウ上部の横並びメニュー)を、CustomTkinterで自作します。実は、CustomTkinterには標準でこの見た目のメニューバー部品が用意されていません。この記事では、その理由と、CTkFrame・CTkButtonを組み合わせて自作する方法を、段階ごとのサンプルコードとダウンロード付きで解説します。

この記事で分かること

  • CustomTkinterにメニューバー専用の部品が無い理由
  • CTkFrame+CTkButtonでメニューバーの土台を作る方法
  • customtkinter ドロップダウンメニューの表示/非表示を切り替える実装
  • メニューの外側をクリックしたら閉じる制御
  • 複数メニューの排他制御(1つ開いたら他は自動的に閉じる)
  • 自分のアプリにそのまま組み込める完成テンプレート(ダウンロード可能)
スポンサーリンク

こんな人におすすめ

  • CustomTkinterでツールを作っていて、File/設定などのメニューを追加したい人
  • customtkinterのmenuやmenubarで検索してもうまく情報が見つからなかった人
  • 完成品だけでなく、実装の考え方も理解したうえで自分のアプリに組み込みたい人

スポンサーリンク

CustomTkinterにメニューバーが無い理由(tk.Menuとの違い)

Tkinter標準のtk.Menuを使えば、app.config(menu=menu)という書き方でOSネイティブのメニューバーを付けることができます。ただし、このtk.MenuはCustomTkinterが提供する部品ではなく、あくまでTkinterの標準機能です。そのため、見た目がOS標準のまま描画され、CustomTkinterのフラットデザインやダークモード配色にはまったく追従しません。

CustomTkinterのアプリにテーマに馴染むメニューバーを付けたい場合、CTkFrameCTkButtonを組み合わせて自分で再現する必要があります。この記事で作るのは、まさにその「CustomTkinterのテーマに馴染む自作メニューバー」です。


スポンサーリンク

完成イメージとダウンロード

先に完成形の動きを説明すると、次のような挙動になります。

  • ウィンドウ上部に「File / 表示 / 設定 / Help」のボタンが横並びになっている
  • ボタンをクリックすると、その下にドロップダウンでメニュー項目が表示される
  • メニューの外側をクリックすると自動的に閉じる
  • 別のメニューボタンをクリックすると、開いていたメニューは閉じて新しいメニューが開く(同時に2つ開かない)

この記事では、この動きを5段階のサンプルコードに分けて、1つずつ機能を追加しながら組み立てていきます。各段階のsample.pyは以下からまとめてダウンロードできます。

サンプル一式(zip)をダウンロード

スポンサーリンク

ステップ1: メニューバーの土台を作る(CTkFrame+CTkButton)

対応ファイルsample1_base.py

まずは、メニューの中身は考えずに「横一列に並んだボタン」というメニューバーの土台だけを作ります。

import customtkinter as ctk

app = ctk.CTk()
app.geometry("500x350")
app.title("CustomTkinterメニューバー入門")

# メニューバー(横一列のバー)
menubar = ctk.CTkFrame(app, height=36, corner_radius=0)
menubar.pack(side="top", fill="x")

file_btn = ctk.CTkButton(menubar, text="File", width=70, fg_color="transparent", text_color="#1e1a16", hover_color="#e8c4a0", corner_radius=0)
file_btn.pack(side="left")

view_btn = ctk.CTkButton(menubar, text="表示", width=70, fg_color="transparent", text_color="#1e1a16", hover_color="#e8c4a0", corner_radius=0)
view_btn.pack(side="left")

settings_btn = ctk.CTkButton(menubar, text="設定", width=70, fg_color="transparent", text_color="#1e1a16", hover_color="#e8c4a0", corner_radius=0)
settings_btn.pack(side="left")

help_btn = ctk.CTkButton(menubar, text="Help", width=70, fg_color="transparent", text_color="#1e1a16", hover_color="#e8c4a0", corner_radius=0)
help_btn.pack(side="left")

body_label = ctk.CTkLabel(app, text="ここが本体エリアです")
body_label.pack(expand=True)

app.mainloop()

ポイントは、各ボタンのfg_color="transparent"です。ボタンの背景をメニューバーと同じ色に透明化することで、「四角いボタンが並んでいる」のではなく「メニューバーに文字が並んでいる」ように見えます。hover_colorだけ薄いオレンジを指定しているので、マウスを乗せたときだけ反応が分かります。

メニューバーのボタンが並んだ状態
▲ ステップ1の時点。ボタンは並んでいるが、クリックしてもまだ何も起きない

✓ 動作確認方法(sample1_base.py)

  1. python sample1_base.py で起動する
  2. File・表示・設定・Helpのボタンが横一列に並んで表示される
  3. ボタンにマウスを乗せると、薄いオレンジ色にハイライトされる
  4. ボタンをクリックしても、何も起きない(ドロップダウンは出ない) ← これが正しい動作です

この段階ではまだクリック処理(command)を割り当てていないので、「何も起きない」のがステップ1の完成形です。バグではありません。


スポンサーリンク

ステップ2: customtkinter ドロップダウンメニューの表示/非表示を実装する

対応ファイルsample2_toggle.py

次に、Fileボタンをクリックしたときに、その下にパネルが表示/非表示になる仕組みを作ります。メニューの中身(項目)はまだ空のままです。

file_menu = ctk.CTkFrame(app, corner_radius=6, border_width=1, border_color="#c05208")
ctk.CTkLabel(file_menu, text="メニュー項目がここに入ります").pack(padx=16, pady=12)

def toggle_file_menu():
    if file_menu.winfo_ismapped():
        file_menu.place_forget()
    else:
        x = file_btn.winfo_x()
        y = menubar.winfo_height()
        file_menu.place(x=x, y=y)
        file_menu.lift()

file_btn.configure(command=toggle_file_menu)

ドロップダウンの正体は、place()で位置を指定して重ねて表示するだけのCTkFrameです。pack()grid()ではなくplace()を使うことで、他の部品のレイアウトに影響を与えずに、好きな座標へ「浮かせて」表示できます。

  • winfo_ismapped(): そのウィジェットが現在画面に表示されているかどうかを調べる
  • place_forget(): place()で表示していたウィジェットを非表示にする
  • winfo_x() / winfo_height(): ボタンやメニューバーの位置・サイズをピクセル単位で取得する。これを使ってドロップダウンをボタンの真下に配置している
  • lift(): 他のウィジェットより手前(上のレイヤー)に表示する
起動直後で非表示のメニューバー
▲ 起動直後は非表示
Fileボタンを押すとドロップダウンパネルが表示された状態
▲ Fileボタンを押すと、赤枠のドロップダウンパネルが表示される(中身はまだプレースホルダー)

✓ 動作確認方法(sample2_toggle.py)

  1. python sample2_toggle.py で起動する
  2. Fileをクリック → 「メニュー項目がここに入ります」というプレースホルダーが表示される
  3. 本体エリアの空白(外側)をクリック → 何も起きない(閉じない) ← この段階ではまだ正しい動作です
  4. もう一度Fileをクリック → パネルが閉じる(同じボタンの再クリックでの開閉トグルのみ実装済み)

「外側クリックで閉じる」機能は次のステップ3で追加します。この段階で外側クリックに反応しなくても不具合ではありません。


スポンサーリンク

ステップ3: メニュー項目と「外側クリックで閉じる」を実装する

対応ファイルsample3_items.py

空だったドロップダウンに、実際の項目(開く・保存・終了)を追加し、それぞれにクリック処理を割り当てます。また、メニューの外側をクリックしたときに自動で閉じる処理も加えます。

def close_file_menu():
    file_menu.place_forget()

def on_open():
    print("「開く」が押されました")
    close_file_menu()

def on_save():
    print("「保存」が押されました")
    close_file_menu()

def on_exit():
    app.destroy()

ctk.CTkButton(file_menu, text="開く", anchor="w", fg_color="transparent", text_color="#1e1a16", hover_color="#f0cba8", command=on_open).pack(fill="x", padx=4, pady=(4, 0))
ctk.CTkButton(file_menu, text="保存", anchor="w", fg_color="transparent", text_color="#1e1a16", hover_color="#f0cba8", command=on_save).pack(fill="x", padx=4)
ctk.CTkButton(file_menu, text="終了", anchor="w", fg_color="transparent", text_color="#1e1a16", hover_color="#f0cba8", command=on_exit).pack(fill="x", padx=4, pady=(0, 4))

項目ボタンをクリックしたら、それぞれの処理を実行したあとにclose_file_menu()でメニューを閉じています。「終了」だけはapp.destroy()でウィンドウそのものを閉じる実処理にしてあります。

外側クリックで閉じる処理は、少し工夫が必要です。

file_menu_open = False  # 開いているかどうかを自前で管理する

def close_file_menu():
    global file_menu_open
    file_menu.place_forget()
    file_menu_open = False

def toggle_file_menu():
    global file_menu_open
    if file_menu_open:
        close_file_menu()
    else:
        x = file_btn.winfo_x()
        y = menubar.winfo_height()
        file_menu.place(x=x, y=y)
        file_menu.lift()
        file_menu_open = True

file_btn.configure(command=toggle_file_menu)

def in_bbox(widget, event):
    x1 = widget.winfo_rootx()
    y1 = widget.winfo_rooty()
    x2 = x1 + widget.winfo_width()
    y2 = y1 + widget.winfo_height()
    return x1 <= event.x_root <= x2 and y1 <= event.y_root <= y2

def on_click_outside(event):
    if not file_menu_open:
        return
    if not in_bbox(file_menu, event) and not in_bbox(file_btn, event):
        close_file_menu()

app.bind_all("<Button-1>", on_click_outside, add="+")

app.bind_all("<Button-1>", ...)で、ウィンドウ内のどこをクリックしても呼ばれる処理を登録します。「開いているかどうか」の判定にfile_menu.winfo_ismapped()(Tkinterが管理する表示状態)をそのまま使いたくなりますが、place()を呼んだ直後は、実際の画面反映がまだ済んでいないタイミングでwinfo_ismapped()Falseを返すことがあります。commandのクリック処理とbind_allの外側クリック判定はどちらも同じ1回のクリックの中でほぼ同時に走るため、このタイミング次第では「開いた直後なのに、Tkinter的にはまだ開いていないと判定されて即座に閉じられてしまう」という事態が起こり得ます。

そこで、Tkinterの内部状態に頼らず、file_menu_openという自分で管理する変数で「開いているかどうか」を判定するようにしています。place()を呼ぶのと同じタイミングで自分でTrueにしているので、タイミングのズレが起きません。

ポイント:「クリックされた座標が、メニューの範囲内でもボタンの範囲内でもない場合だけ閉じる」という座標判定(in_bbox)にすることで、ボタン自身のcommand(トグル処理)と衝突しないようにしています。
開く・保存・終了の項目が表示されたドロップダウン
▲ 「開く・保存・終了」という実際の項目が表示され、外側をクリックすると閉じるようになった

✓ 動作確認方法(sample3_items.py)

  1. コマンドプロンプト(またはターミナル)から python sample3_items.py で起動する(コンソール出力を見るため)
  2. Fileをクリック → 「開く」「保存」「終了」の3項目が表示される
  3. 「開く」をクリック → コンソールに 「開く」が押されました と表示され、メニューが閉じる
  4. もう一度Fileを開いて「保存」をクリック → 同様にコンソール表示後、閉じる
  5. もう一度Fileを開いて、メニューの外側(本体エリアの空白)をクリック → 自動的に閉じる(ステップ2との違いはここ)
  6. 最後に「終了」をクリック → ウィンドウごと閉じる

スポンサーリンク

ステップ4: 4つのメニューに対応させ、排他制御を実装する

対応ファイルsample4_all_menus.py

ここまではFileメニュー1つだけの実装でした。最後に、表示・設定・Helpメニューにも同じ仕組みを広げつつ、「1つのメニューを開いたら、他は自動的に閉じる」排他制御を加えます。

最初は、ステップ3と同じようにmenu.winfo_ismapped()で開閉状態を判定する形で実装しました。ところが実際に動かしてみると、Fileボタンをクリックしても、メニューが一瞬たりとも表示されないという現象が起きました。エラーは出ません。ただ何も起きないように見えるのです。

原因を1つずつ処理を追って調べたところ、次のような順番で処理が実行されていることが分かりました。

  • Fileボタンを押すと、まずボタン自身のcommand(トグル処理)が呼ばれ、file_menu.place()でメニューが配置される
  • ところが、この直後の時点ではwinfo_ismapped()はまだFalseを返す(Tkinter内部の表示状態への反映が追いついていない)
  • その直後、同じクリックに対してapp.bind_all("<Button-1>", ...)で登録した外側クリック判定処理も呼ばれる
  • この処理は「どのメニューもwinfo_ismapped()Trueでない」と判定し、「開いているメニューは無い」と誤解してclose_all_menus()を呼んでしまう
  • 結果、開いたはずのメニューがその場で消され、画面が再描画される前に閉じてしまう

ステップ3では「どのメニューも開いていなければ何もしない(returnして終了)」という作りだったため、この問題が表面化しませんでした。ステップ4で「デフォルトでは閉じる」という設計に変えたことで、この潜在的なタイミング問題が顕在化した形です。

教訓:place()pack()を呼んだ直後のwinfo_ismapped()は、画面への反映タイミングによっては期待通りの値を返さないことがあります。「今どの状態か」を判定する必要がある処理では、Tkinter側の内部状態を都度問い合わせるのではなく、自分のコードで状態を持って管理したほうが確実です。

そこで、「今どのメニューが開いているか」をopen_menu_widgetという変数で自前管理する形に直しました。

menus = [
    (file_btn, file_menu),
    (view_btn, view_menu),
    (settings_btn, settings_menu),
    (help_btn, help_menu),
]

# 今どのメニューが開いているかを自前で管理する(winfo_ismapped()には頼らない)
open_menu_widget = None
open_btn_widget = None

def close_all_menus():
    global open_menu_widget, open_btn_widget
    for _, menu in menus:
        menu.place_forget()
    open_menu_widget = None
    open_btn_widget = None

def open_menu(btn, menu):
    global open_menu_widget, open_btn_widget
    close_all_menus()
    x = btn.winfo_x()
    y = menubar.winfo_height()
    menu.place(x=x, y=y)
    menu.lift()
    open_menu_widget = menu
    open_btn_widget = btn

def make_toggle(btn, menu):
    def toggle():
        if open_menu_widget is menu:
            close_all_menus()
        else:
            open_menu(btn, menu)
    return toggle

file_btn.configure(command=make_toggle(file_btn, file_menu))
view_btn.configure(command=make_toggle(view_btn, view_menu))
settings_btn.configure(command=make_toggle(settings_btn, settings_menu))
help_btn.configure(command=make_toggle(help_btn, help_menu))

ポイントはopen_menu()の中で、新しいメニューを表示する前に必ずclose_all_menus()を呼んでいることです。これにより、どのメニューボタンを押しても「まず全部閉じてから、押されたメニューだけ開く」という動きになり、複数のメニューが同時に開くことがなくなります。open_menu_widgetへの代入もplace()と同じタイミングで行っているので、先ほどのようなタイミングのズレは起きません。

make_toggle()は、ボタンとメニューの組み合わせごとに専用のトグル関数を作って返す関数です。File・表示・設定・Helpの4つで同じロジックを使い回すために、こういう書き方にしています。

外側クリックの判定(in_bbox)も、open_menu_widgetを見る形に書き換えます。

def on_click_outside(event):
    if open_menu_widget is None:
        return
    if not in_bbox(open_menu_widget, event) and not in_bbox(open_btn_widget, event):
        close_all_menus()

app.bind_all("<Button-1>", on_click_outside, add="+")
Fileメニューを開いた状態
▲ Fileメニューを開いた状態
表示メニューを開いた状態、Fileメニューは閉じている
▲ 続けて表示メニューをクリックすると、Fileメニューは自動的に閉じて表示メニューだけが開く

✓ 動作確認方法(sample4_all_menus.py)

  1. コマンドプロンプトから python sample4_all_menus.py で起動する
  2. Fileをクリック → 「開く/保存/終了」が表示される
  3. Fileを開いたまま、続けて「表示」をクリック(Fileを閉じる操作をせずに直接) → Fileのメニューが自動的に閉じて、代わりに「表示」のメニューが開く ← 排他制御の一番のポイント
  4. 「表示」の「ダークモード」をクリック → 画面全体が実際にダーク配色に切り替わり、メニューが閉じる
  5. 再度「表示」を開いて「ライトモード」をクリック → 元の配色に戻る
  6. 「設定」→「環境設定」をクリック → コンソールに 「環境設定」が押されました と表示されメニューが閉じる
  7. 「Help」→「バージョン情報」をクリック → コンソールに 「バージョン情報」が押されました と表示され閉じる
  8. どれか1つ開いた状態で外側をクリック → 自動的に閉じる

手順3(排他制御)が今回いちばんハマった箇所です。ここが正しく動けば、他のメニューも同じ仕組みで動いています。


スポンサーリンク

完成版(sample_final.py):自分のアプリに組み込むテンプレート

対応ファイルsample5_final.py

ステップ4までの内容(open_menu_widgetで開閉状態を自前管理する排他制御を含む)を1つのファイルにまとめたものが、ダウンロードできるsample5_final.pyです。実装自体はステップ4と同じですが、ダウンロードしてすぐ使えるように、各メニュー項目のクリック処理にひと工夫を加えています。

def on_open():
    print("「開く」が押されました")
    # ここに「開く」を押したときの処理を書く
    # 例: filedialog.askopenfilename() でファイル選択ダイアログを開く など
    close_all_menus()

このように、「クリックしたらコンソールにメッセージが表示される」という最低限の動作は最初から用意しつつ、実際の処理を書く場所をコメントで明示しています。ダウンロードした直後に実行してもエラーにならず、メニューがきちんと反応することを確認したうえで、自分の処理を追加していけます。

なお、「表示」メニューの「ダークモード」「ライトモード」と、「File」メニューの「終了」だけは、コメントではなく実際に動く処理(ctk.set_appearance_mode()app.destroy())にしてあります。ダウンロードしてそのまま実行するだけで、テーマの切り替えとアプリの終了は体験できます。

完成版を起動した直後の状態
▲ 完成版を起動した直後の状態
ダークモードに切り替わった完成版アプリ
▲ 表示メニューから「ダークモード」を選んだ直後。実際にテーマが切り替わる

✓ 動作確認方法(sample5_final.py)

  1. コマンドプロンプトから python sample5_final.py で起動する
  2. 動作の確認手順はステップ4(sample4_all_menus.py)とまったく同じです(File→表示への切り替え、ダークモード、外側クリックで閉じるなど)
  3. 違いはウィンドウタイトルの表記と、「開く」「保存」「環境設定」「バージョン情報」に処理を追加するためのコメントが入っている点だけです

sample4とsample5で動作に差が出た場合は、どちらか一方だけが古いバージョンのままになっている可能性があります。両方とも最新のzipから展開し直してから比較してください。


スポンサーリンク

よくある質問

QCustomTkinterにmenuのような専用ウィジェットは無いのですか?

Aありません。TkinterのMenuBar/Menu機能(tk.Menu)はそのまま使えますが、CustomTkinterのテーマとは見た目が一致しません。テーマに馴染むメニューバーが欲しい場合は、この記事のようにCTkFrameCTkButtonで自作する必要があります。

Qplace()ではなくpack()やgrid()でドロップダウンを作ってはいけないのですか?

Apack()grid()は、同じ親の中にある他の部品の配置にも影響を与えてしまいます。ドロップダウンのように「他の部品の上に浮かせて、必要なときだけ重ねて表示する」用途には、独立して座標指定できるplace()が向いています。

Qメニューの項目数を増やしたい場合はどうすればいいですか?

A各メニューのCTkFrameに対して、ctk.CTkButton(...).pack(fill="x", padx=4)を追加するだけです。メニューの開閉やレイアウトの仕組みは変更する必要がありません。

Qサブメニュー(項目にカーソルを合わせるとさらにメニューが出る形式)にも対応できますか?

Aこの記事の範囲では扱っていませんが、考え方は同じです。項目のボタンに対して、さらにplace()で別のドロップダウンを表示する処理を追加すれば実現できます。階層が深くなるほど外側クリック判定が複雑になるため、まずは今回の1階層メニューで仕組みに慣れることをおすすめします。

Qなぜ「終了」以外の項目は処理が空(print文だけ)なのですか?

A「開く」「保存」などの処理はアプリごとに内容が大きく異なるためです。空のまま配布して、ダウンロードした人が自分のアプリに合わせて処理を追加できるようにしています。「終了」「テーマ切り替え」は、どんなアプリでも同じ書き方で動く処理なので、あらかじめ実装済みにしてあります。

Q開閉状態の判定にwinfo_ismapped()を使わないのはなぜですか?

Aplace()pack()を呼んだ直後は、Tkinter内部の表示状態への反映が間に合わず、winfo_ismapped()が一瞬Falseのままになることがあるためです。この記事でも、複数メニューの排他制御を実装した際に、このタイミングのズレが原因でメニューを開いた瞬間に自分自身を閉じてしまうバグに遭遇しました。open_menu_widgetのような自前の変数で状態を管理すれば、このズレを避けられます。


スポンサーリンク

まとめ

この記事では、CustomTkinterに標準搭載されていない「File・表示・設定・Help」形式のメニューバーを、CTkFrame・CTkButton・place()を組み合わせて自作する方法を、5段階に分けて紹介しました。

  • CustomTkinterにはテーマに馴染むメニューバー専用部品が無いため、CTkFrame+CTkButtonで自作する
  • ドロップダウンはplace()で浮かせて表示し、place_forget()で非表示にする
  • 外側クリックで閉じる処理は、クリック座標がメニュー・ボタンの範囲内かどうかで判定する
  • 複数メニューの排他制御は、新しいメニューを開く前に必ず全メニューを閉じることで実現する

前回の記事

CustomTkinterとは?インストールから基本の使い方まで(pip導入・GUI部品の配置)

記事を読む →

ニンジン🥕アイコン

Follow me on X

本ブログの中の人「ニンジン🥕」はXやってます。新着記事はいち早くポストでお知らせしているので、見逃したくない方は @ninjin_py_vba をフォローしてみてください🥕

@ninjin_py_vba をフォロー

この記事が役に立ったら、シェアしてもらえると嬉しいです🥕

Xでポスト はてブ LINE Pocket

PROJECT LOG — SERIES INDEX
スポンサーリンク

シリーズ記事一覧

電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。


Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】
FastAPI+openpyxlでTapo P110Mの消費電力をExcelレポート化。15秒間隔の生ログを日次集計する設計や、31円/kWhでの電気代試算、外部公開を見送った理由まで解説します。
Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】
Tapo P110MのデータをSQLiteに記録し、FastAPI+Uvicornでリアルタイム電力監視ダッシュボードを作る方法を解説。通信失敗時の自動復旧など堅牢化のポイントも紹介します。
Tapo P110M × python-kasaで自作PDUを作る【環境構築・Unsupported device(TPAP)エラー解決編】
Tapo P110Mをpython-kasaで自作PDU化。「Unsupported device」TPAPエラーの原因と対処法、MACアドレスでIP変動に強くする設計、個別/一括ON-OFFの実装コードまで解説します。
Raspberry Pi Pico Wで作る熱中症警戒アラート表示器
Raspberry Pi Pico WとDHT11、0.96インチOLEDで熱中症警戒アラート表示器を自作。気温・湿度から簡易的な暑さ指数を計算し、5段階の顔アイコンで危険度を表示する作り方をコード付きで解説します。
CustomTkinterでオリジナルのメニューバー(File・表示・設定・Help)を自作する
CustomTkinterには無いメニューバー(File・表示・設定・Help)を、CTkFrameとCTkButtonで自作する方法を、5段階のサンプルコードとダウンロード付きで解説します。

コメント