初心者向け No.057

ミニペイントアプリ

Canvasにマウスで自由に線を描けるシンプルなお絵描きアプリ。ブラシサイズ・色の変更、消しゴム、保存機能付き。Canvasイベント処理を学ぶ。

🎯 難易度: ★★☆ 普通 📦 ライブラリ: tkinter ⏱️ 制作時間: 30〜90分

1. アプリ概要

Canvasにマウスで自由に線を描けるシンプルなお絵描きアプリ。ブラシサイズ・色の変更、消しゴム、保存機能付き。Canvasイベント処理を学ぶ。

このアプリはtoolsカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter、難易度は ★★☆ 普通 です。

このアプリは「ツール」カテゴリです。日々の作業を自動化する実用ツールは Python が最も得意とする領域です。短いコードで実用性のある成果物が作れる点が魅力です。tkinter を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

ミニペイントアプリ 実行画面(Windows)
実行画面(Windows)
ミニペイントアプリ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を _set_color() に接続
  • 「 」ボタン(pick_color())・「消しゴム」ボタン(toggle_eraser())・「全消去」ボタン(clear())・「保存(.ps)」ボタン(save())で操作
  • マウス操作 <Button-1> から _on_press() を実行
  • マウス操作 <B1-Motion> から _on_drag() を実行
  • マウス操作 <ButtonRelease-1> から _on_release() を実行
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showerror()messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「ミニペイントアプリ」・サイズ 820x580 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app057.py
import tkinter as tk
from tkinter import colorchooser, filedialog, messagebox, ttk


class App057:
    """ミニペイントアプリ"""

    def __init__(self, root):
        self.root = root
        self.root.title("ミニペイントアプリ")
        self.root.geometry("820x580")
        self.root.configure(bg="#f8f9fc")
        self.color = "#1f2933"
        self.brush_size = 4
        self.eraser = False
        self.last = None  # 直前のマウス座標
        self._build_ui()

    def _build_ui(self):
        """UI を構築する"""
        title = tk.Frame(self.root, bg="#3776ab", pady=10)
        title.pack(fill=tk.X)
        tk.Label(title, text="ミニペイントアプリ",
                 font=("Noto Sans JP", 16, "bold"),
                 bg="#3776ab", fg="white").pack()

        ctrl = ttk.LabelFrame(self.root, text="ツール", padding=8)
        ctrl.pack(fill=tk.X, padx=12, pady=8)

        self.color_btn = tk.Button(ctrl, text="     ", bg=self.color, width=6,
                                   command=self.pick_color)
        self.color_btn.pack(side=tk.LEFT, padx=4)

        ttk.Label(ctrl, text="太さ:").pack(side=tk.LEFT, padx=(10, 4))
        self.size_var = tk.IntVar(value=self.brush_size)
        ttk.Spinbox(ctrl, from_=1, to=40, textvariable=self.size_var,
                    width=4, command=self._sync_size).pack(side=tk.LEFT)

        self.eraser_btn = ttk.Button(ctrl, text="消しゴム",
                                      command=self.toggle_eraser)
        self.eraser_btn.pack(side=tk.LEFT, padx=8)

        ttk.Button(ctrl, text="全消去", command=self.clear).pack(side=tk.LEFT, padx=4)
        ttk.Button(ctrl, text="保存(.ps)", command=self.save).pack(side=tk.LEFT, padx=4)

        ttk.Label(ctrl, text="プリセット:").pack(side=tk.LEFT, padx=(20, 4))
        for c in ("#1f2933", "#e53935", "#43a047", "#1e88e5", "#fbc02d", "#ffffff"):
            tk.Button(ctrl, bg=c, width=2, relief=tk.RIDGE,
                      command=lambda col=c: self._set_color(col)).pack(side=tk.LEFT, padx=2)

        self.canvas = tk.Canvas(self.root, bg="white",
                                highlightthickness=1, highlightbackground="#ccc")
        self.canvas.pack(fill=tk.BOTH, expand=True, padx=12, pady=10)

        self.canvas.bind("<Button-1>", self._on_press)
        self.canvas.bind("<B1-Motion>", self._on_drag)
        self.canvas.bind("<ButtonRelease-1>", self._on_release)

        self.status = tk.Label(self.root, text="ドラッグでお絵描きできます",
                               bg="#f8f9fc", fg="#555")
        self.status.pack()

    def _sync_size(self):
        """Spinbox の値を内部に反映する"""
        try:
            self.brush_size = max(1, int(self.size_var.get()))
        except (ValueError, tk.TclError):
            self.brush_size = 4

    def _set_color(self, c):
        """色を設定する (消しゴム解除)"""
        self.color = c
        self.color_btn.config(bg=c)
        self.eraser = False
        self.eraser_btn.config(text="消しゴム")

    def pick_color(self):
        """カラーピッカーで色を選ぶ"""
        _rgb, hexv = colorchooser.askcolor(color=self.color, title="色を選択")
        if hexv:
            self._set_color(hexv)

    def toggle_eraser(self):
        """消しゴムモードを切替える"""
        self.eraser = not self.eraser
        self.eraser_btn.config(text="ペン" if self.eraser else "消しゴム")
        self.status.config(text="消しゴムモード" if self.eraser else "ペンモード")

    def _current_color(self):
        """現在の描画色(消しゴム時は白)"""
        return "white" if self.eraser else self.color

    def _on_press(self, event):
        """マウス押下: 開始点を記録"""
        self._sync_size()
        self.last = (event.x, event.y)
        # 単発クリックでも点を打つ
        r = self.brush_size
        self.canvas.create_oval(event.x - r, event.y - r, event.x + r, event.y + r,
                                fill=self._current_color(), outline="")

    def _on_drag(self, event):
        """ドラッグで線を引く"""
        if self.last is None:
            self.last = (event.x, event.y)
            return
        x0, y0 = self.last
        self.canvas.create_line(x0, y0, event.x, event.y,
                                fill=self._current_color(),
                                width=self.brush_size, capstyle=tk.ROUND,
                                smooth=True)
        self.last = (event.x, event.y)

    def _on_release(self, _event):
        """マウスリリース: 履歴をリセット"""
        self.last = None

    def clear(self):
        """キャンバスを白紙に戻す"""
        if messagebox.askyesno("確認", "描画を全消去しますか?"):
            self.canvas.delete("all")

    def save(self):
        """PostScript として保存する (tkinter 標準機能)"""
        path = filedialog.asksaveasfilename(defaultextension=".ps",
                                            filetypes=[("PostScript", "*.ps")])
        if not path:
            return
        try:
            self.canvas.postscript(file=path, colormode="color")
            messagebox.showinfo("保存", f"保存しました:\n{path}")
        except Exception as e:
            messagebox.showerror("エラー", f"保存失敗: {e}")


if __name__ == "__main__":
    root = tk.Tk()
    app = App057(root)
    root.mainloop()

5. コード解説

ミニペイントアプリのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App057 クラスにアプリの全機能をまとめています(メソッド12個・全139行)。__init__ ではタイトル「ミニペイントアプリ」とウィンドウサイズ 820x580 を設定します。あわせて状態を保持する変数(self.colorself.brush_sizeself.eraserself.last)を初期化し、最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frametk.Label×2・ttk.LabelFrametk.Button(ループで生成)・ttk.Label×2・ttk.Spinboxttk.Button×3・tk.Canvas で構成しています。見出し付きの枠(LabelFrame)は「ツール」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「 」ボタン(pick_color())・「消しゴム」ボタン(toggle_eraser())・「全消去」ボタン(clear())・「保存(.ps)」ボタン(save())を command= で接続しています。ボタンはループ内で command=lambda col=c: self._set_color(col) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<Button-1>", ...) から _on_press()bind("<B1-Motion>", ...) から _on_drag()bind("<ButtonRelease-1>", ...) から _on_release() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionValueErrortk.TclError を捕捉しています。あわせて messagebox.askyesno()messagebox.showerror()messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app057.py と保存します。使うのは tkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App057 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("ミニペイントアプリ")geometry("820x580")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frametk.Label×2・ttk.LabelFrametk.Button(ループで生成)・ttk.Label×2・ttk.Spinboxttk.Button×3・tk.Canvas を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(pick_color()toggle_eraser()clear()save())につなぎます。ループ生成のボタンは command=lambda col=c: self._set_color(col) の形で接続します。bind("<Button-1>", ...)bind("<B1-Motion>", ...)bind("<ButtonRelease-1>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _on_drag()(11行)・save()(11行)・_on_press()(8行)など を実装します。

  7. 7
    動作確認する

    python app057.py で起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 データの保存機能

処理結果をCSV・TXTファイルに保存する機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda col=c: self._set_color(col) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("820x580") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。描いた線はウィンドウの中にあるだけで、「保存(.ps)」(123〜133行)を押さない限りどこにも残りません。保存できるのは PostScript(.ps)だけで、PNG や JPEG には対応していません(130行の canvas.postscript() の制約です)。「全消去」(118〜121行)は確認のあとキャンバスを空にします。取り消す機能は無いので、消したくない絵は先に保存してください。

  1. 課題1:クリックした点の太さを線とそろえる

    「太さ」を 20 にしてキャンバスを1回クリックすると、同じ太さで引いた線より太い点が付きます。_on_press() が半径に self.brush_size をそのまま渡していて(98〜100行)、直径が線の太さ(110行の width)の2倍になるためです。

    期待結果:太さ 20 で1回クリックした点の大きさが、同じ太さで引いた線の太さと同じになる

    合格条件

    • 太さ 20 のクリックで付く点が、いまの半分の大きさになる
    • ドラッグで引く線(108〜111行)の太さは変わらない
    • 消しゴムのときも同じ大きさで消える(_on_press() は色だけ _current_color() で切り替えるため)

    ヒント①(どこを触るか): 触るのは1か所です。半径を決める98行です。
    ヒント②(使うもの): r = max(1, self.brush_size // 2) にすると、直径が self.brush_size と同じになります。太さ4なら r は2、太さ 20 なら 10 です。
    つまずきやすい点: // だけで書くと、太さ1のときに r が0になります。すると99〜100行の create_oval() へ渡す4つの座標がクリック位置と同じ値になり、大きさのない楕円になります。max(1, ...) で下限を1にしてください。95行の _sync_size() は Spinbox の値を self.brush_size へ入れ直す処理なので、そのままにします。

  2. 課題2:色を選んだときに状態表示もペンへ戻す

    「消しゴム」を押すと下の表示が「消しゴムモード」に変わります(83〜87行)。そのあとプリセットの色を押すと、_set_color() が消しゴムを解除してペンに戻しますが(70〜75行)、表示は「消しゴムモード」のまま残ります。

    期待結果:「消しゴム」を押したあとに赤のプリセットを押すと、下の表示が「ペンモード」へ変わる

    合格条件

    • プリセットの色でもカラーピッカー(77〜81行)でも「ペンモード」に戻る
    • 「消しゴム」ボタンの文字が「ペン」から「消しゴム」に戻る動き(75行)は今までどおり
    • 「消しゴム」を押した直後の表示は「消しゴムモード」のまま(87行)

    ヒント①(どこを触るか): 触るのは1か所です。_set_color() の75行の下に、表示を書き換える1行を足します。
    ヒント②(使うもの): self.status.config(text="ペンモード") を足します。self.status は59〜61行で作ったラベルで、87行でも使っている書き方です。プリセットの6つのボタンは47〜49行のループで作られ、どれも同じ _set_color() を通るので、1か所直せば6つとも直ります。
    つまずきやすい点: toggle_eraser()(83〜87行)へ同じ行を足すと、消しゴムに切り替えた直後まで「ペンモード」と出てしまいます。触るのは _set_color() だけです。pick_color()(77〜81行)は最後に _set_color() を呼ぶので、こちらへ足す必要はありません。色の選択をキャンセルしたときは hexvNone になり、80行の判定で _set_color() 自体が呼ばれません。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。