初心者向け No.057

ミニペイントアプリ

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

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

1. アプリ概要

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

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

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

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

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

2. 機能一覧

  • ミニペイントアプリのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • クリア機能付き

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

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クラスにアプリの全機能をまとめています。__init__でウィンドウ設定、_build_ui()でUI構築、process()でメイン処理を担当します。責任の分離により、コードが読みやすくなります。

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

UIレイアウトの構築

LabelFrameで入力エリアと結果エリアを視覚的に分けています。pack()で縦に並べ、expand=Trueで結果エリアが画面いっぱいに広がるよう設定しています。

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

イベント処理

ボタンのcommand引数でクリックイベントを、bind('')でEnterキーイベントを処理します。どちらの操作でも同じprocess()が呼ばれ、コードの重複を避けられます。

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

Textウィジェットでの結果表示

tk.Textウィジェットをstate=DISABLED(読み取り専用)で作成し、更新時はNORMALに変更してinsert()で内容を書き込み、再びDISABLEDに戻します。

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

例外処理とエラーハンドリング

try-exceptでValueErrorとExceptionを捕捉し、messagebox.showerror()でエラーメッセージを表示します。予期しないエラーも処理することで、アプリの堅牢性が向上します。

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

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

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

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

    新しいファイルを作成して app057.py と保存します。

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

    App057クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _execute()メソッドにメインロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

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

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

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

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

💡 データの保存機能

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

💡 設定ダイアログ

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

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

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ライブラリのインポートエラー

原因:必要なライブラリがインストールされていません。

解決法:pip install コマンドで必要なライブラリをインストールしてください。

❌ ウィンドウサイズが合わない

原因:画面解像度や表示スケールによって異なる場合があります。

解決法:root.geometry()で適切なサイズに調整してください。

9. 練習問題

アプリの理解を深めるための練習問題です。

  1. 課題1:機能拡張

    ミニペイントアプリに新しい機能を1つ追加してみましょう。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしましょう。

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

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

🚀
次に挑戦するアプリ

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