2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CustomTkinterでExcel風スプレッドシートを実装する

Posted at

はじめに

image.png

PythonのGUIライブラリ「CustomTkinter」を使用して、Excel風のスプレッドシート画面を実装します。モダンなダークテーマUIで、データ入力・編集・保存が可能なシンプルで実用的なツールを作成します。

完成イメージ

image.png

仕様

基本機能

グリッドは30行×15列の固定サイズで構成されています。セルはクリックで選択でき、青枠で強調表示されます。セル情報バーで入力すると即座にセルに反映され、Enterキーで下方向、Tabキーで右方向に移動できます。

データ編集

コピー、切り取り、貼り付けなどの標準的な編集操作に対応しています。選択中のセルのみをクリアする機能や、全セルを一括でクリアする機能があります。入力済みのセル数は自動的にカウントされ、画面に表示されます。

ファイル操作

Excel(.xlsx)やCSV(.csv)の読み込み・保存が可能です。新規作成や上書きの際には確認ダイアログが表示されます。ファイル読み込み時には、グリッドサイズの範囲内のみデータを表示します。

UI

モダンで目に優しいダークモードに対応しています。操作状況やデータ統計がステータスバーに表示され、大きなグリッドでもスムーズにスクロールできます。選択中のセルは視覚的に強調されるため、操作中の位置がわかりやすくなっています。

必要なライブラリのインストール

pip install customtkinter
pip install openpyxl

実装

以下のコードをコピーしてexcel_app.pyとして保存し、実行してください。

import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog, messagebox
import openpyxl
import re

class ExcelLikeApp:
    def __init__(self):
        # ウィンドウ設定
        self.root = ctk.CTk()
        self.root.title("Excel風スプレッドシート - CustomTkinter")
        self.root.geometry("1200x700")
        
        # テーマ設定
        ctk.set_appearance_mode("dark")
        ctk.set_default_color_theme("blue")
        
        # 変数初期化
        self.rows = 30
        self.cols = 15
        self.cells = {}
        self.selected_cell = None
        self.current_file = None
        self.clipboard_value = None
        
        # UI構築
        self.setup_ui()
        self.create_grid()
        
        # ウィンドウを実行
        self.root.mainloop()
    
    def setup_ui(self):
        """UIコンポーネントの設定"""
        # メインコンテナ
        main_container = ctk.CTkFrame(self.root)
        main_container.pack(fill="both", expand=True, padx=10, pady=10)
        
        # ツールバー
        self.create_toolbar(main_container)
        
        # セル情報バー
        self.create_cell_info_bar(main_container)
        
        # スプレッドシートエリア(スクロール可能)
        self.create_spreadsheet_area(main_container)
        
        # ステータスバー
        self.create_status_bar(main_container)
        
        # キーボードショートカット設定
        self.root.bind('<Control-s>', lambda e: self.save_file())
        self.root.bind('<Control-o>', lambda e: self.open_file())
        self.root.bind('<Control-n>', lambda e: self.new_file())
        self.root.bind('<Control-c>', lambda e: self.copy_cell())
        self.root.bind('<Control-v>', lambda e: self.paste_cell())
        self.root.bind('<Control-x>', lambda e: self.cut_cell())
        self.root.bind('<Delete>', lambda e: self.clear_current_cell())
    
    def create_toolbar(self, parent):
        """ツールバーの作成"""
        toolbar = ctk.CTkFrame(parent, height=40)
        toolbar.pack(fill="x", padx=5, pady=(5, 0))
        
        # ファイル操作ボタン
        ctk.CTkButton(
            toolbar, text="📄 新規", width=80, height=32,
            command=self.new_file, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="📂 開く", width=80, height=32,
            command=self.open_file, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="💾 保存", width=80, height=32,
            command=self.save_file, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="📝 名前を付けて保存", width=140, height=32,
            command=self.save_as_file, font=("", 12)
        ).pack(side="left", padx=3)
        
        # セパレーター
        ctk.CTkLabel(toolbar, text="|", width=20, text_color=("gray50", "gray50")).pack(side="left", padx=5)
        
        # 編集ボタン
        ctk.CTkButton(
            toolbar, text="📋 コピー", width=85, height=32,
            command=self.copy_cell, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="📌 貼り付け", width=85, height=32,
            command=self.paste_cell, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="✂️ 切り取り", width=85, height=32,
            command=self.cut_cell, font=("", 12)
        ).pack(side="left", padx=3)
        
        ctk.CTkButton(
            toolbar, text="🗑️ クリア", width=85, height=32,
            command=self.clear_current_cell, font=("", 12)
        ).pack(side="left", padx=3)
        
        # セパレーター
        ctk.CTkLabel(toolbar, text="|", width=20, text_color=("gray50", "gray50")).pack(side="left", padx=5)
        
        # 全クリアボタン
        ctk.CTkButton(
            toolbar, text="🔄 全クリア", width=90, height=32,
            command=self.clear_all_cells, font=("", 12),
            fg_color=("gray70", "gray30")
        ).pack(side="left", padx=3)
    
    def create_cell_info_bar(self, parent):
        """セル情報バーの作成"""
        info_frame = ctk.CTkFrame(parent, height=35)
        info_frame.pack(fill="x", padx=5, pady=5)
        
        # セル位置表示
        self.cell_label = ctk.CTkLabel(
            info_frame, text="A1", width=80, height=30,
            fg_color=("gray80", "gray20"), corner_radius=5,
            font=("", 14, "bold")
        )
        self.cell_label.pack(side="left", padx=10)
        
        # セル内容表示
        ctk.CTkLabel(info_frame, text="内容:", width=50).pack(side="left", padx=5)
        
        self.cell_content_entry = ctk.CTkEntry(info_frame, height=30)
        self.cell_content_entry.pack(side="left", fill="x", expand=True, padx=5)
        self.cell_content_entry.bind("<Return>", self.apply_cell_content)
        self.cell_content_entry.bind("<KeyRelease>", self.on_content_edit)
    
    def create_spreadsheet_area(self, parent):
        """スプレッドシートエリアの作成"""
        # スクロール可能なフレーム
        self.canvas = tk.Canvas(parent, bg="#212121", highlightthickness=0)
        v_scrollbar = ctk.CTkScrollbar(parent, orientation="vertical", command=self.canvas.yview)
        h_scrollbar = ctk.CTkScrollbar(parent, orientation="horizontal", command=self.canvas.xview)
        
        self.scrollable_frame = ctk.CTkFrame(self.canvas)
        self.scrollable_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
        )
        
        self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        self.canvas.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set)
        
        # 配置(順序を修正)
        h_scrollbar.pack(side="bottom", fill="x")
        v_scrollbar.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)
    
    def create_status_bar(self, parent):
        """ステータスバーの作成"""
        status_bar = ctk.CTkFrame(parent, height=30)
        status_bar.pack(fill="x", side="bottom", padx=5, pady=(0, 5))
        
        self.status_label = ctk.CTkLabel(
            status_bar, text="準備完了", anchor="w"
        )
        self.status_label.pack(side="left", padx=10)
        
        self.data_count_label = ctk.CTkLabel(
            status_bar, text="データセル数: 0", anchor="e"
        )
        self.data_count_label.pack(side="right", padx=20)
        
        self.grid_size_label = ctk.CTkLabel(
            status_bar, text=f"グリッドサイズ: {self.rows}行 × {self.cols}", anchor="e"
        )
        self.grid_size_label.pack(side="right", padx=20)
    
    def create_grid(self):
        """グリッドの作成"""
        # 既存のウィジェットをクリア
        for widget in self.scrollable_frame.winfo_children():
            widget.destroy()
        self.cells.clear()
        
        # 空のコーナーセル
        corner = ctk.CTkLabel(
            self.scrollable_frame, text="", width=50, height=30,
            fg_color=("gray70", "gray30"), corner_radius=0
        )
        corner.grid(row=0, column=0, sticky="nsew", padx=1, pady=1)
        
        # 列ヘッダー(A, B, C...)
        for col in range(self.cols):
            header = ctk.CTkLabel(
                self.scrollable_frame,
                text=self.get_column_letter(col),
                width=100, height=30,
                fg_color=("gray80", "gray20"),
                corner_radius=0,
                font=("", 12, "bold")
            )
            header.grid(row=0, column=col+1, sticky="ew", padx=1, pady=1)
        
        # 行ヘッダー(1, 2, 3...)
        for row in range(1, self.rows + 1):
            header = ctk.CTkLabel(
                self.scrollable_frame,
                text=str(row),
                width=50, height=25,
                fg_color=("gray80", "gray20"),
                corner_radius=0,
                font=("", 12, "bold")
            )
            header.grid(row=row, column=0, sticky="ew", padx=1, pady=1)
        
        # データセル
        for row in range(1, self.rows + 1):
            for col in range(self.cols):
                cell = ctk.CTkEntry(
                    self.scrollable_frame,
                    width=100, height=25,
                    corner_radius=0,
                    border_width=1,
                    fg_color=("white", "gray15"),
                    border_color=("gray60", "gray40")
                )
                cell.grid(row=row, column=col+1, sticky="ew", padx=1, pady=1)
                
                # セル参照を保存
                cell_ref = f"{self.get_column_letter(col)}{row}"
                self.cells[cell_ref] = cell
                
                # イベントバインド
                cell.bind("<FocusIn>", lambda e, ref=cell_ref: self.on_cell_select(ref))
                cell.bind("<Return>", self.on_enter_pressed)
                cell.bind("<Tab>", self.on_tab_pressed)
                cell.bind("<KeyRelease>", lambda e: self.update_data_count())
        
        self.update_data_count()
    
    def get_column_letter(self, col_index):
        """列インデックスを文字に変換(0->A, 1->B, 25->Z, 26->AA)"""
        result = ""
        while col_index >= 0:
            result = chr(col_index % 26 + ord('A')) + result
            col_index = col_index // 26 - 1
            if col_index < 0:
                break
        return result
    
    def get_cell_position(self, cell_ref):
        """セル参照から行列位置を取得"""
        match = re.match(r'([A-Z]+)(\d+)', cell_ref)
        if match:
            col_str, row_str = match.groups()
            col = 0
            for char in col_str:
                col = col * 26 + (ord(char) - 64)
            return int(row_str), col - 1
        return None, None
    
    def on_cell_select(self, cell_ref):
        """セル選択時の処理"""
        self.selected_cell = cell_ref
        self.cell_label.configure(text=cell_ref)
        
        # セル内容を表示
        cell_value = self.cells[cell_ref].get()
        self.cell_content_entry.delete(0, tk.END)
        self.cell_content_entry.insert(0, cell_value)
        
        # 選択セルをハイライト(視覚的フィードバック強化)
        for ref, cell in self.cells.items():
            if ref == cell_ref:
                cell.configure(border_color=("#1f6aa5", "#4a9eff"), border_width=2)
            else:
                cell.configure(border_color=("gray60", "gray40"), border_width=1)
        
        # ステータス更新
        self.status_label.configure(text=f"セル {cell_ref} を選択中")
    
    def on_content_edit(self, event=None):
        """セル内容編集時の処理(リアルタイム同期)"""
        if self.selected_cell and self.selected_cell in self.cells:
            content = self.cell_content_entry.get()
            self.cells[self.selected_cell].delete(0, tk.END)
            self.cells[self.selected_cell].insert(0, content)
    
    def apply_cell_content(self, event=None):
        """セル内容を適用"""
        if self.selected_cell:
            content = self.cell_content_entry.get()
            self.cells[self.selected_cell].delete(0, tk.END)
            self.cells[self.selected_cell].insert(0, content)
            
            # 次のセルに移動
            row, col = self.get_cell_position(self.selected_cell)
            if row < self.rows:
                next_cell = f"{self.get_column_letter(col)}{row + 1}"
                if next_cell in self.cells:
                    self.cells[next_cell].focus_set()
    
    def on_enter_pressed(self, event):
        """Enterキー押下時の処理"""
        current = self.selected_cell
        if current:
            row, col = self.get_cell_position(current)
            if row < self.rows:
                next_cell = f"{self.get_column_letter(col)}{row + 1}"
                if next_cell in self.cells:
                    self.cells[next_cell].focus_set()
        return "break"
    
    def on_tab_pressed(self, event):
        """Tabキー押下時の処理"""
        current = self.selected_cell
        if current:
            row, col = self.get_cell_position(current)
            if col < self.cols - 1:
                next_cell = f"{self.get_column_letter(col + 1)}{row}"
                if next_cell in self.cells:
                    self.cells[next_cell].focus_set()
        return "break"
    
    def update_data_count(self):
        """データが入力されているセル数を更新"""
        count = sum(1 for cell in self.cells.values() if cell.get())
        self.data_count_label.configure(text=f"データセル数: {count}")
    
    def copy_cell(self):
        """選択セルをコピー"""
        if self.selected_cell:
            self.clipboard_value = self.cells[self.selected_cell].get()
            self.status_label.configure(text=f"セル {self.selected_cell} をコピーしました")
    
    def cut_cell(self):
        """選択セルを切り取り"""
        if self.selected_cell:
            self.clipboard_value = self.cells[self.selected_cell].get()
            self.cells[self.selected_cell].delete(0, tk.END)
            self.status_label.configure(text=f"セル {self.selected_cell} を切り取りました")
            self.update_data_count()
    
    def paste_cell(self):
        """クリップボードから貼り付け"""
        if self.selected_cell and self.clipboard_value is not None:
            self.cells[self.selected_cell].delete(0, tk.END)
            self.cells[self.selected_cell].insert(0, self.clipboard_value)
            self.cell_content_entry.delete(0, tk.END)
            self.cell_content_entry.insert(0, self.clipboard_value)
            self.status_label.configure(text=f"セル {self.selected_cell} に貼り付けました")
            self.update_data_count()
    
    def clear_current_cell(self):
        """現在選択中のセルをクリア"""
        if self.selected_cell:
            self.cells[self.selected_cell].delete(0, tk.END)
            self.cell_content_entry.delete(0, tk.END)
            self.status_label.configure(text=f"セル {self.selected_cell} をクリアしました")
            self.update_data_count()
    
    def clear_all_cells(self):
        """すべてのセルをクリア"""
        if messagebox.askyesno("確認", "すべてのセルをクリアしますか?"):
            for cell in self.cells.values():
                cell.delete(0, tk.END)
            self.cell_content_entry.delete(0, tk.END)
            self.status_label.configure(text="すべてのセルをクリアしました")
            self.update_data_count()
    
    def new_file(self):
        """新規ファイル"""
        if self.has_data():
            if not messagebox.askyesno("確認", "現在の内容を破棄して新規ファイルを作成しますか?"):
                return
        
        for cell in self.cells.values():
            cell.delete(0, tk.END)
        self.current_file = None
        self.root.title("Excel風スプレッドシート - CustomTkinter")
        self.status_label.configure(text="新規ファイルを作成しました")
        self.update_data_count()
    
    def has_data(self):
        """データが入力されているか確認"""
        for cell in self.cells.values():
            if cell.get():
                return True
        return False
    
    def open_file(self):
        """ファイルを開く"""
        filename = filedialog.askopenfilename(
            title="ファイルを開く",
            filetypes=[("Excel files", "*.xlsx"), ("CSV files", "*.csv"), ("All files", "*.*")]
        )
        
        if filename:
            try:
                if filename.endswith('.xlsx'):
                    self.open_excel_file(filename)
                elif filename.endswith('.csv'):
                    self.open_csv_file(filename)
                
                self.current_file = filename
                self.root.title(f"Excel風スプレッドシート - {filename}")
                self.status_label.configure(text=f"ファイルを開きました: {filename}")
                self.update_data_count()
                
            except Exception as e:
                messagebox.showerror("エラー", f"ファイルを開けませんでした: {str(e)}")
    
    def open_excel_file(self, filename):
        """Excelファイルを開く(エラーハンドリング強化)"""
        try:
            workbook = openpyxl.load_workbook(filename, data_only=True)
            sheet = workbook.active
            
            # クリア
            for cell in self.cells.values():
                cell.delete(0, tk.END)
            
            # データ読み込み(グリッドサイズの範囲内で)
            for row in sheet.iter_rows(max_row=self.rows, max_col=self.cols):
                for cell in row:
                    if cell.value is not None:
                        cell_ref = f"{cell.column_letter}{cell.row}"
                        if cell_ref in self.cells:
                            # 数値や日付も文字列に変換して表示
                            self.cells[cell_ref].insert(0, str(cell.value))
            
            workbook.close()
            
        except openpyxl.utils.exceptions.InvalidFileException:
            messagebox.showerror("エラー", "無効なExcelファイルです。ファイルが破損している可能性があります。")
            raise
        except PermissionError:
            messagebox.showerror("エラー", "ファイルが他のプログラムで開かれています。")
            raise
        except Exception as e:
            messagebox.showerror("エラー", f"ファイル読み込みエラー: {str(e)}")
            raise
    
    def open_csv_file(self, filename):
        """CSVファイルを開く(エンコーディング対応強化)"""
        import csv
        
        # 複数のエンコーディングを試す
        encodings = ['utf-8-sig', 'utf-8', 'shift-jis', 'cp932', 'iso-8859-1']
        data = None
        
        for encoding in encodings:
            try:
                with open(filename, 'r', encoding=encoding) as file:
                    reader = csv.reader(file)
                    data = list(reader)
                break
            except UnicodeDecodeError:
                continue
            except Exception:
                continue
        
        if data is None:
            messagebox.showerror("エラー", "ファイルの文字エンコーディングを認識できませんでした。")
            raise ValueError("Unsupported encoding")
        
        # クリア
        for cell in self.cells.values():
            cell.delete(0, tk.END)
        
        # データ読み込み(グリッドサイズの範囲内で)
        for row_idx, row_data in enumerate(data[:self.rows], 1):
            for col_idx, value in enumerate(row_data[:self.cols]):
                cell_ref = f"{self.get_column_letter(col_idx)}{row_idx}"
                if cell_ref in self.cells:
                    self.cells[cell_ref].insert(0, str(value))
    
    def save_file(self):
        """ファイルを保存"""
        if self.current_file:
            if self.current_file.endswith('.xlsx'):
                self.save_excel_file(self.current_file)
            elif self.current_file.endswith('.csv'):
                self.save_csv_file(self.current_file)
        else:
            self.save_as_file()
    
    def save_as_file(self):
        """名前を付けて保存"""
        filename = filedialog.asksaveasfilename(
            title="名前を付けて保存",
            defaultextension=".xlsx",
            filetypes=[("Excel files", "*.xlsx"), ("CSV files", "*.csv"), ("All files", "*.*")]
        )
        
        if filename:
            if filename.endswith('.xlsx'):
                self.save_excel_file(filename)
            elif filename.endswith('.csv'):
                self.save_csv_file(filename)
            else:
                # デフォルトでExcel形式
                filename += '.xlsx'
                self.save_excel_file(filename)
            
            self.current_file = filename
            self.root.title(f"Excel風スプレッドシート - {filename}")
    
    def save_excel_file(self, filename):
        """Excel形式で保存"""
        try:
            workbook = openpyxl.Workbook()
            sheet = workbook.active
            
            # データを書き込み
            for cell_ref, entry in self.cells.items():
                value = entry.get()
                if value:
                    row, col = self.get_cell_position(cell_ref)
                    sheet.cell(row=row, column=col+1, value=value)
            
            workbook.save(filename)
            self.status_label.configure(text=f"保存しました: {filename}")
            
        except Exception as e:
            messagebox.showerror("エラー", f"保存に失敗しました: {str(e)}")
    
    def save_csv_file(self, filename):
        """CSV形式で保存"""
        import csv
        
        try:
            # データを行列形式に整理
            data = []
            for row in range(1, self.rows + 1):
                row_data = []
                for col in range(self.cols):
                    cell_ref = f"{self.get_column_letter(col)}{row}"
                    value = self.cells[cell_ref].get() if cell_ref in self.cells else ""
                    row_data.append(value)
                # 空行でも最後の行まで保存
                if any(row_data) or row == 1:
                    data.append(row_data)
            
            # CSVファイルに書き込み
            with open(filename, 'w', newline='', encoding='utf-8-sig') as file:
                writer = csv.writer(file)
                writer.writerows(data)
            
            self.status_label.configure(text=f"保存しました: {filename}")
            
        except Exception as e:
            messagebox.showerror("エラー", f"保存に失敗しました: {str(e)}")

# アプリケーション実行
if __name__ == "__main__":
    app = ExcelLikeApp()

まとめ

image.png

CustomTkinterを使用することで、シンプルながら実用的なExcel風スプレッドシートアプリケーションを実装できました。モダンなダークテーマUIと基本的な編集機能を備え、データ入力や簡単な表作成に適したツールとなっています。

ただし、セル数が多くなると描画や操作が徐々に遅くなるため、大規模なデータにはあまり向きません。データ量がそれほど多くなく、グリッド形式でExcel風のUIをモダンな雰囲気で実装したい場合には有効な選択肢となるでしょう。

このプロジェクトは、PythonのGUIライブラリでどこまで本格的なアプリケーションが作れるかを探る実験的な試みでもあります。CustomTkinterの洗練されたデザインとTkinterの柔軟性を組み合わせることで、デスクトップアプリケーションの新しい可能性を示しています。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?