1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[備忘録] PyQt5でExcel風グリッドアプリを実装してみた

Posted at

image.png

1. PyQt5の基本概念と特徴

PyQt5はQtフレームワークのPythonバインディングで、クロスプラットフォームのGUIアプリケーション開発に適しています。

特徴:

  • クロスプラットフォーム(Windows、macOS、Linux)
  • シグナル/スロットメカニズムによるイベント処理
  • 豊富なウィジェットライブラリ
  • MVCアーキテクチャのサポート
  • スタイリングオプション(QSS:Qt Style Sheets)

Excel風アプリケーションの実装には、特にQTableViewQTableWidgetクラスが重要です。QTableWidgetは高レベルのインターフェースを提供し初心者向け、QTableViewはMVCパターンを活用し柔軟性が高いです。今回は基本的な機能実装のためQTableWidgetを使用します。

2. 開発環境のセットアップ方法

必要なパッケージのインストール

pip install PyQt5

これだけで必要なコンポーネントはすべてインストールされます。

最小限のアプリケーションコード

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QTableWidget

app = QApplication(sys.argv)
window = QMainWindow()
table = QTableWidget(10, 5, window)  # 10行5列のテーブルを作成
window.setCentralWidget(table)
window.show()
sys.exit(app.exec_())

image.png

3. 基本的なグリッドアプリケーションの実装例

以下は基本的なExcel風アプリケーションの実装です。拡張性を考慮してクラスベースで作成します。

import sys
from PyQt5.QtWidgets import (QApplication, QMainWindow, QTableWidget, 
                             QTableWidgetItem, QHeaderView, QToolBar, 
                             QAction, QFileDialog, QMessageBox)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
import csv

class ExcelLikeApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()
        
    def initUI(self):
        # ウィンドウの基本設定
        self.setWindowTitle('PyQt Excel風アプリ')
        self.setGeometry(100, 100, 800, 600)
        
        # テーブルウィジェットの設定
        self.table = QTableWidget()
        self.setCentralWidget(self.table)
        
        # 初期テーブルサイズ設定(20行10列)
        self.table.setRowCount(20)
        self.table.setColumnCount(10)
        
        # セルの選択モードを設定
        self.table.setSelectionMode(QTableWidget.ContiguousSelection)
        
        # ツールバーの追加
        self.createToolBar()
        
        # テーブルを初期化
        self.initializeTable()
        
        # ステータスバーを追加
        self.statusBar().showMessage('Ready')
        
    def initializeTable(self):
        # 列幅を調整
        header = self.table.horizontalHeader()       
        for i in range(self.table.columnCount()):
            header.setSectionResizeMode(i, QHeaderView.Stretch)
            
        # 列ヘッダーにExcel風のラベルを設定(A, B, C...)
        columns = [chr(65 + i) for i in range(self.table.columnCount())]
        self.table.setHorizontalHeaderLabels(columns)
        
        # セルの変更を検知するシグナルを接続
        self.table.itemChanged.connect(self.onItemChanged)
        
    def createToolBar(self):
        toolbar = QToolBar("Main Toolbar")
        self.addToolBar(toolbar)
        
        # ファイル操作アクション
        openAction = QAction('開く', self)
        openAction.triggered.connect(self.openFile)
        toolbar.addAction(openAction)
        
        saveAction = QAction('保存', self)
        saveAction.triggered.connect(self.saveFile)
        toolbar.addAction(saveAction)
        
        # 編集アクション
        clearAction = QAction('クリア', self)
        clearAction.triggered.connect(self.clearSelection)
        toolbar.addAction(clearAction)
        
    def openFile(self):
        fileName, _ = QFileDialog.getOpenFileName(
            self, "CSVファイルを開く", "", "CSV Files (*.csv);;All Files (*)")
        
        if fileName:
            try:
                with open(fileName, 'r', encoding='utf-8') as file:
                    reader = csv.reader(file)
                    data = list(reader)
                    
                    # テーブルサイズを調整
                    if data:
                        rows = len(data)
                        cols = max(len(row) for row in data)
                        self.table.setRowCount(max(rows, 20))  # 最低20行
                        self.table.setColumnCount(max(cols, 10))  # 最低10列
                        
                        # 列ヘッダー再設定
                        columns = [chr(65 + i) if i < 26 else chr(64 + i//26) + chr(65 + i%26) 
                                  for i in range(self.table.columnCount())]
                        self.table.setHorizontalHeaderLabels(columns)
                    
                    # データをテーブルに設定
                    for i, row in enumerate(data):
                        for j, cell in enumerate(row):
                            self.table.setItem(i, j, QTableWidgetItem(cell))
                    
                    self.statusBar().showMessage(f'ファイルを読み込みました: {fileName}')
            except Exception as e:
                QMessageBox.critical(self, "エラー", f"ファイルの読み込み中にエラーが発生しました: {str(e)}")
        
    def saveFile(self):
        fileName, _ = QFileDialog.getSaveFileName(
            self, "CSVファイルとして保存", "", "CSV Files (*.csv);;All Files (*)")
        
        if fileName:
            try:
                with open(fileName, 'w', newline='', encoding='utf-8') as file:
                    writer = csv.writer(file)
                    rows = self.table.rowCount()
                    cols = self.table.columnCount()
                    
                    for i in range(rows):
                        row_data = []
                        for j in range(cols):
                            item = self.table.item(i, j)
                            if item is not None:
                                row_data.append(item.text())
                            else:
                                row_data.append('')
                        writer.writerow(row_data)
                    
                    self.statusBar().showMessage(f'ファイルを保存しました: {fileName}')
            except Exception as e:
                QMessageBox.critical(self, "エラー", f"ファイルの保存中にエラーが発生しました: {str(e)}")
    
    def clearSelection(self):
        for item in self.table.selectedItems():
            item.setText('')
    
    def onItemChanged(self, item):
        # アイテムが変更されたときの処理
        self.statusBar().showMessage(f'セルが更新されました: ({item.row()+1}, {chr(65+item.column())})')

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = ExcelLikeApp()
    ex.show()
    sys.exit(app.exec_())

このコードはフル機能のExcel風アプリケーションの基礎を提供します。主な機能は:

  • 初期の20行10列のグリッド
  • CSVファイルの読み込みと保存
  • 選択したセルのクリア
  • Excel風の列ヘッダー(A, B, C...)

image.png

4. 行/列ヘッダーとセル編集の基本実装方法

ヘッダーのカスタマイズ

QTableWidgetのヘッダーをカスタマイズする方法をさらに詳しく見ていきましょう。

def setupHeaders(self):
    # 列ヘッダーカスタマイズ(Excel風のA,B,C...)
    columns = []
    for i in range(self.table.columnCount()):
        if i < 26:  # A-Z
            columns.append(chr(65 + i))
        else:  # AA, AB, AC...
            columns.append(chr(64 + i//26) + chr(65 + i%26))
    self.table.setHorizontalHeaderLabels(columns)
    
    # 行ヘッダーカスタマイズ(1,2,3...)
    rows = [str(i+1) for i in range(self.table.rowCount())]
    self.table.setVerticalHeaderLabels(rows)
    
    # ヘッダーのスタイル設定
    header_style = "QHeaderView::section { background-color: #f0f0f0; }"
    self.table.horizontalHeader().setStyleSheet(header_style)
    self.table.verticalHeader().setStyleSheet(header_style)
    
    # ヘッダーのリサイズモード設定
    self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
    self.table.horizontalHeader().setStretchLastSection(True)
    self.table.verticalHeader().setSectionResizeMode(QHeaderView.Interactive)

このコードをメインクラスに追加してinitUIメソッドから呼び出すことで、ヘッダーのセットアップを行えます。

セル編集の拡張

セル編集に関する機能を拡張するには、以下のようなメソッドをクラスに追加します。

def setupCellEditing(self):
    # ダブルクリックでセル編集を開始
    self.table.setEditTriggers(QTableWidget.DoubleClicked | 
                               QTableWidget.SelectedClicked)
    
    # コンテキストメニューの追加
    self.table.setContextMenuPolicy(Qt.CustomContextMenu)
    self.table.customContextMenuRequested.connect(self.showContextMenu)
    
    # Enterキーで次のセルへ移動
    self.table.keyPressEvent = self.tableKeyPressEvent

def tableKeyPressEvent(self, event):
    # 元のイベントハンドラを保存
    original_handler = self.table.keyPressEvent
    
    # Enterキーで下のセルに移動
    if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
        current = self.table.currentIndex()
        self.table.setCurrentCell(current.row() + 1, current.column())
    else:
        # その他のキーイベントは元のハンドラに渡す
        QTableWidget.keyPressEvent(self.table, event)

def showContextMenu(self, position):
    menu = QMenu()
    cutAction = menu.addAction("切り取り")
    copyAction = menu.addAction("コピー")
    pasteAction = menu.addAction("貼り付け")
    menu.addSeparator()
    clearAction = menu.addAction("クリア")
    
    # アクションと関数を接続
    cutAction.triggered.connect(self.cutSelection)
    copyAction.triggered.connect(self.copySelection)
    pasteAction.triggered.connect(self.pasteSelection)
    clearAction.triggered.connect(self.clearSelection)
    
    # メニューを表示
    menu.exec_(self.table.viewport().mapToGlobal(position))

def cutSelection(self):
    self.copySelection()
    self.clearSelection()

def copySelection(self):
    items = self.table.selectedItems()
    if not items:
        return
        
    # 選択範囲の行と列を取得
    rows = set(item.row() for item in items)
    cols = set(item.column() for item in items)
    
    # 最小と最大のインデックスを見つける
    min_row, max_row = min(rows), max(rows)
    min_col, max_col = min(cols), max(cols)
    
    # タブ区切りテキストでクリップボードにコピー
    text = ""
    for r in range(min_row, max_row + 1):
        for c in range(min_col, max_col + 1):
            item = self.table.item(r, c)
            if item is not None:
                text += item.text()
            text += "\t" if c < max_col else ""
        text += "\n"
    
    clipboard = QApplication.clipboard()
    clipboard.setText(text)

def pasteSelection(self):
    clipboard = QApplication.clipboard()
    text = clipboard.text()
    
    current = self.table.currentIndex()
    start_row, start_col = current.row(), current.column()
    
    # タブとラインフィードで分割
    rows = text.split('\n')
    if rows and not rows[-1]:  # 末尾の空行を除去
        rows.pop()
    
    for r, row in enumerate(rows):
        columns = row.split('\t')
        for c, value in enumerate(columns):
            target_row, target_col = start_row + r, start_col + c
            
            # テーブルの範囲内かチェック
            if (target_row < self.table.rowCount() and 
                target_col < self.table.columnCount()):
                # 必要に応じてアイテムを作成
                if self.table.item(target_row, target_col) is None:
                    self.table.setItem(
                        target_row, target_col, QTableWidgetItem())
                
                # テキストを設定
                self.table.item(target_row, target_col).setText(value)

これらのメソッドを追加することで、コピー&ペースト機能やコンテキストメニュー、Enterキーでの移動など、より本格的なセル編集体験を提供できます。

5. サンプルデータの表示方法

アプリケーションにサンプルデータを表示するには、以下のような方法があります。

直接データをセットする方法

def loadSampleData(self):
    # サンプルデータ
    sample_data = [
        ["項目", "1月", "2月", "3月", "4月", "合計"],
        ["売上", "120", "150", "180", "140", "=SUM(B2:E2)"],
        ["費用", "80", "90", "100", "95", "=SUM(B3:E3)"],
        ["利益", "=B2-B3", "=C2-C3", "=D2-D3", "=E2-E3", "=SUM(B4:E4)"]
    ]
    
    # テーブルサイズを調整
    self.table.setRowCount(max(len(sample_data), self.table.rowCount()))
    max_cols = max(len(row) for row in sample_data)
    self.table.setColumnCount(max(max_cols, self.table.columnCount()))
    
    # データをテーブルに設定
    for r, row in enumerate(sample_data):
        for c, value in enumerate(row):
            self.table.setItem(r, c, QTableWidgetItem(value))
    
    # 数式の簡易処理
    self.processFormulas()

def processFormulas(self):
    # 非常に簡易的な数式処理(実際のアプリケーションではより複雑になります)
    for r in range(self.table.rowCount()):
        for c in range(self.table.columnCount()):
            item = self.table.item(r, c)
            if item and item.text().startswith('='):
                formula = item.text()[1:]  # '='を除去
                
                # SUM関数の簡易処理
                if formula.startswith('SUM('):
                    # 例: SUM(B2:E2)
                    range_str = formula[4:-1]  # 'B2:E2'を抽出
                    start, end = range_str.split(':')
                    
                    # 開始セルと終了セルの座標を解析
                    start_col = ord(start[0]) - 65  # 'B' -> 1
                    start_row = int(start[1:]) - 1  # '2' -> 1
                    
                    end_col = ord(end[0]) - 65  # 'E' -> 4
                    end_row = int(end[1:]) - 1  # '2' -> 1
                    
                    # 合計計算
                    total = 0
                    for i in range(start_row, end_row + 1):
                        for j in range(start_col, end_col + 1):
                            cell_item = self.table.item(i, j)
                            if cell_item and cell_item.text().isdigit():
                                total += int(cell_item.text())
                    
                    item.setText(str(total))
                
                # 簡易的な計算式処理(例: B2-B3)
                elif '-' in formula:
                    parts = formula.split('-')
                    if len(parts) == 2:
                        # セル参照を解析
                        cell1 = parts[0].strip()
                        cell2 = parts[1].strip()
                        
                        col1 = ord(cell1[0]) - 65
                        row1 = int(cell1[1:]) - 1
                        
                        col2 = ord(cell2[0]) - 65
                        row2 = int(cell2[1:]) - 1
                        
                        # 値を取得して計算
                        val1 = int(self.table.item(row1, col1).text()) if self.table.item(row1, col1) else 0
                        val2 = int(self.table.item(row2, col2).text()) if self.table.item(row2, col2) else 0
                        
                        item.setText(str(val1 - val2))

このサンプルデータ実装は非常に簡略化されており、本格的なExcelアプリケーションではより複雑な数式エンジンが必要です。しかし、基本的な概念を示すには十分です。

演習用データファイルを使用する方法

実際のプロジェクトでは、サンプルCSVファイルを用意しておき、それを読み込むことで簡単にデータを表示できます。

sample_data.csv:

項目,1月,2月,3月,4月,合計
売上,120,150,180,140,590
費用,80,90,100,95,365
利益,40,60,80,45,225

この場合、アプリケーション起動時に自動的にサンプルファイルを読み込む機能を追加します:

def loadDefaultSampleFile(self):
    # アプリケーションディレクトリ内のサンプルファイルパス
    sample_path = "sample_data.csv"
    
    # ファイルの存在確認
    import os
    if os.path.exists(sample_path):
        try:
            with open(sample_path, 'r', encoding='utf-8') as file:
                reader = csv.reader(file)
                data = list(reader)
                
                # テーブルサイズを調整
                if data:
                    rows = len(data)
                    cols = max(len(row) for row in data)
                    self.table.setRowCount(max(rows, 20))
                    self.table.setColumnCount(max(cols, 10))
                
                # データをテーブルに設定
                for i, row in enumerate(data):
                    for j, cell in enumerate(row):
                        self.table.setItem(i, j, QTableWidgetItem(cell))
                
                self.statusBar().showMessage('サンプルデータを読み込みました')
        except Exception as e:
            self.statusBar().showMessage(f'サンプルデータの読み込みに失敗しました: {str(e)}')

これを__init__またはinitUIメソッドの最後で呼び出すことで、アプリケーション起動時にサンプルデータを自動的に読み込めます。

まとめ

以上のコードと解説で、PyQt5を使用した基本的なExcel風グリッドアプリケーションを実装できます。
主なポイントは:

  1. 基本構造: QTableWidgetを使用して簡単にグリッドベースのUIを作成できる
  2. ファイル操作: CSVフォーマットを使った読み込み・保存機能の実装
  3. ヘッダーカスタマイズ: Excel風の列ヘッダー(A,B,C...)と行ヘッダー(1,2,3...)の設定
  4. セル編集: コピー&ペースト、コンテキストメニューなどの基本的なセル編集機能
  5. サンプルデータ: 初期データの設定と簡易的な数式処理の実装

実際のExcelアプリケーションにはさらに多くの機能(複雑な数式、グラフ、条件付き書式など)がありますが、このコードは拡張性の高い基盤となります。独自の機能を追加して、より高度なスプレッドシートアプリケーションに発展させることができます。

発展的なトピック(時間があれば挑戦してみましょう)

  1. 数式エンジン: 本格的な数式処理機能の実装
  2. スタイリング: セルの書式設定(色、フォント、整列など)の実装
  3. フィルタリング: データのフィルタリングと並べ替え機能
  4. グラフ機能: データの可視化機能の追加(PyQtGraphなどのライブラリを使用)
  5. 複数シート: タブによる複数シートの管理

これらの機能を追加することで、より本格的なスプレッドシートアプリケーションに近づけることができます。

実装例

image.png

import sys
import os
from PyQt5.QtWidgets import (QApplication, QMainWindow, QTableWidget, 
                             QTableWidgetItem, QHeaderView, QToolBar, 
                             QAction, QFileDialog, QMessageBox, QMenu,
                             QColorDialog, QFontDialog, QTabWidget, 
                             QWidget, QVBoxLayout, QComboBox, QLabel,
                             QHBoxLayout, QPushButton, QInputDialog)
from PyQt5.QtCore import Qt, QRegExp
from PyQt5.QtGui import QColor, QFont, QRegExpValidator
import csv
import re

class FormulaProcessor:
    """簡易的な数式処理エンジン"""
    
    def __init__(self, table):
        self.table = table
        
    def process_all_formulas(self):
        """テーブル内のすべての数式を処理"""
        # 依存関係の解決のため複数回処理
        for _ in range(3):  # 単純な依存関係のため3回で十分
            for r in range(self.table.rowCount()):
                for c in range(self.table.columnCount()):
                    item = self.table.item(r, c)
                    if item and item.text().startswith('='):
                        self.process_formula(item)
    
    def process_formula(self, item):
        """単一の数式を処理"""
        formula = item.text()[1:]  # '='を除去
        result = self.evaluate_formula(formula, item.row(), item.column())
        if result is not None:
            # 元の数式を保持(データ属性として)
            original_formula = item.text()
            item.setData(Qt.UserRole, original_formula)
            # 計算結果を表示
            item.setText(str(result))
            # 計算結果セルの背景色を変更
            item.setBackground(QColor(240, 248, 255))  # 薄い青色
    
    def evaluate_formula(self, formula, current_row, current_col):
        """数式を評価して結果を返す"""
        # SUM関数の処理
        sum_match = re.search(r'SUM\(([A-Z]+\d+):([A-Z]+\d+)\)', formula)
        if sum_match:
            range_start, range_end = sum_match.groups()
            return self.calculate_sum(range_start, range_end)
            
        # AVERAGE関数の処理
        avg_match = re.search(r'AVERAGE\(([A-Z]+\d+):([A-Z]+\d+)\)', formula)
        if avg_match:
            range_start, range_end = avg_match.groups()
            values = self.get_range_values(range_start, range_end)
            if values:
                return sum(values) / len(values)
            return 0
            
        # MAX関数の処理
        max_match = re.search(r'MAX\(([A-Z]+\d+):([A-Z]+\d+)\)', formula)
        if max_match:
            range_start, range_end = max_match.groups()
            values = self.get_range_values(range_start, range_end)
            if values:
                return max(values)
            return 0
            
        # MIN関数の処理
        min_match = re.search(r'MIN\(([A-Z]+\d+):([A-Z]+\d+)\)', formula)
        if min_match:
            range_start, range_end = min_match.groups()
            values = self.get_range_values(range_start, range_end)
            if values:
                return min(values)
            return 0
        
        # セル参照を含む四則演算
        try:
            # セル参照をその値に置換
            cell_pattern = r'([A-Z]+\d+)'
            cell_refs = re.findall(cell_pattern, formula)
            
            evaluated_formula = formula
            for cell_ref in cell_refs:
                cell_value = self.get_cell_value(cell_ref)
                evaluated_formula = evaluated_formula.replace(cell_ref, str(cell_value))
            
            # 安全な評価(限定的な式のみ)
            if re.match(r'^[\d\+\-\*\/\(\)\.\s]+$', evaluated_formula):
                return eval(evaluated_formula)
        except:
            pass
            
        return None
    
    def calculate_sum(self, start_ref, end_ref):
        """セル範囲の合計を計算"""
        values = self.get_range_values(start_ref, end_ref)
        return sum(values)
    
    def get_range_values(self, start_ref, end_ref):
        """セル範囲の値を取得"""
        # セル参照から行と列を抽出
        start_col, start_row = self.cell_ref_to_coords(start_ref)
        end_col, end_row = self.cell_ref_to_coords(end_ref)
        
        values = []
        for row in range(start_row, end_row + 1):
            for col in range(start_col, end_col + 1):
                value = self.get_numeric_cell_value(row, col)
                if value is not None:
                    values.append(value)
        
        return values
    
    def get_cell_value(self, cell_ref):
        """セル参照から値を取得"""
        col, row = self.cell_ref_to_coords(cell_ref)
        return self.get_numeric_cell_value(row, col)
    
    def get_numeric_cell_value(self, row, col):
        """セルから数値を取得(数値でない場合はNone)"""
        item = self.table.item(row, col)
        if item:
            try:
                # カンマを除去して数値に変換
                text = item.text().replace(',', '')
                return float(text)
            except ValueError:
                pass
        return 0  # デフォルト値
    
    def cell_ref_to_coords(self, cell_ref):
        """'A1'形式のセル参照を行と列のインデックスに変換"""
        # 列部分(文字)と行部分(数字)を分離
        match = re.match(r'([A-Z]+)(\d+)', cell_ref)
        if not match:
            return 0, 0
            
        col_str, row_str = match.groups()
        
        # 列文字をインデックスに変換(A=0, B=1, ..., Z=25, AA=26, ...)
        col = 0
        for char in col_str:
            col = col * 26 + (ord(char) - ord('A'))
            
        # 行は1始まりなので1を引く
        row = int(row_str) - 1
        
        return col, row


class Sheet(QTableWidget):
    """拡張QTableWidgetクラス"""
    
    def __init__(self, parent=None):
        super().__init__(parent)
        self.initUI()
        
    def initUI(self):
        # 初期サイズ設定
        self.setRowCount(100)
        self.setColumnCount(26)
        
        # ヘッダー設定
        self.setupHeaders()
        
        # セル編集設定
        self.setupCellEditing()
        
        # 数式プロセッサ
        self.formula_processor = FormulaProcessor(self)
        
        # セル変更検知
        self.itemChanged.connect(self.onItemChanged)
    
    def setupHeaders(self):
        # 列ヘッダー(A, B, C, ...)
        columns = []
        for i in range(self.columnCount()):
            if i < 26:  # A-Z
                columns.append(chr(65 + i))
            else:  # AA, AB, AC...
                columns.append(chr(64 + i//26) + chr(65 + i%26))
        self.setHorizontalHeaderLabels(columns)
        
        # 行ヘッダー(1, 2, 3, ...)
        rows = [str(i+1) for i in range(self.rowCount())]
        self.setVerticalHeaderLabels(rows)
        
        # ヘッダースタイル
        header_style = "QHeaderView::section { background-color: #f0f0f0; }"
        self.horizontalHeader().setStyleSheet(header_style)
        self.verticalHeader().setStyleSheet(header_style)
        
        # ヘッダーリサイズモード
        self.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
        self.horizontalHeader().setStretchLastSection(True)
        self.verticalHeader().setSectionResizeMode(QHeaderView.Interactive)
    
    def setupCellEditing(self):
        # 編集トリガー設定
        self.setEditTriggers(QTableWidget.DoubleClicked | 
                           QTableWidget.SelectedClicked | 
                           QTableWidget.AnyKeyPressed)
        
        # コンテキストメニュー
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.showContextMenu)
    
    def onItemChanged(self, item):
        # 編集モード中の場合は処理しない
        if self.state() == QTableWidget.EditingState:
            return
            
        # 数式の場合は処理
        if item and item.text().startswith('='):
            self.formula_processor.process_formula(item)
    
    def showContextMenu(self, position):
        menu = QMenu()
        
        # 基本編集アクション
        cutAction = menu.addAction("切り取り")
        copyAction = menu.addAction("コピー")
        pasteAction = menu.addAction("貼り付け")
        menu.addSeparator()
        
        # 書式設定アクション
        formatMenu = menu.addMenu("書式設定")
        bgColorAction = formatMenu.addAction("背景色...")
        fontAction = formatMenu.addAction("フォント...")
        
        menu.addSeparator()
        clearAction = menu.addAction("クリア")
        
        # アクションと関数を接続
        cutAction.triggered.connect(self.cutSelection)
        copyAction.triggered.connect(self.copySelection)
        pasteAction.triggered.connect(self.pasteSelection)
        bgColorAction.triggered.connect(self.setBackgroundColor)
        fontAction.triggered.connect(self.setFont)
        clearAction.triggered.connect(self.clearSelection)
        
        # メニューを表示
        menu.exec_(self.viewport().mapToGlobal(position))
    
    def cutSelection(self):
        self.copySelection()
        self.clearSelection()
    
    def copySelection(self):
        selected = self.selectedItems()
        if not selected:
            return
            
        # 選択範囲の行と列を取得
        rows = sorted(set(item.row() for item in selected))
        cols = sorted(set(item.column() for item in selected))
        
        # タブとラインフィードでテキストを構築
        text = ""
        for r in rows:
            row_data = []
            for c in cols:
                # その位置にアイテムがあるか確認
                item = None
                for selected_item in selected:
                    if selected_item.row() == r and selected_item.column() == c:
                        item = selected_item
                        break
                
                # 項目のテキストを追加(または空文字)
                row_data.append(item.text() if item else "")
            
            text += "\t".join(row_data)
            text += "\n"
        
        # クリップボードにコピー
        QApplication.clipboard().setText(text)
    
    def pasteSelection(self):
        text = QApplication.clipboard().text()
        if not text:
            return
            
        # 現在選択されているセルを取得
        selected = self.selectedIndexes()
        if not selected:
            return
            
        # 選択されているセルの中で最も左上のセルを開始位置とする
        start_row = min(index.row() for index in selected)
        start_col = min(index.column() for index in selected)
        
        # テキストを行と列に分割
        rows = text.strip().split("\n")
        
        # 各行を処理
        for i, row_text in enumerate(rows):
            # 行のテキストをタブで分割
            cells = row_text.split("\t")
            
            # 各セルのテキストを設定
            for j, cell_text in enumerate(cells):
                row, col = start_row + i, start_col + j
                
                # テーブル範囲内かチェック
                if row < self.rowCount() and col < self.columnCount():
                    # セルアイテムがなければ作成
                    if self.item(row, col) is None:
                        self.setItem(row, col, QTableWidgetItem())
                    
                    # テキストを設定
                    self.item(row, col).setText(cell_text)
    
    def clearSelection(self):
        for item in self.selectedItems():
            item.setText("")
    
    def setBackgroundColor(self):
        color = QColorDialog.getColor()
        if color.isValid():
            for item in self.selectedItems():
                item.setBackground(color)
    
    def setFont(self):
        font, ok = QFontDialog.getFont()
        if ok:
            for item in self.selectedItems():
                item.setFont(font)
    
    def processAllFormulas(self):
        """テーブル内のすべての数式を処理"""
        self.formula_processor.process_all_formulas()
    
    def keyPressEvent(self, event):
        # Tabキー処理
        if event.key() == Qt.Key_Tab:
            current = self.currentItem()
            if current:
                next_col = min(current.column() + 1, self.columnCount() - 1)
                self.setCurrentCell(current.row(), next_col)
                return
        
        # Enterキー処理
        elif event.key() in (Qt.Key_Return, Qt.Key_Enter):
            current = self.currentItem()
            if current:
                next_row = min(current.row() + 1, self.rowCount() - 1)
                self.setCurrentCell(next_row, current.column())
                return
        
        # 他のキーはデフォルト処理
        super().keyPressEvent(event)


class ExcelLikeApp(QMainWindow):
    """Excel風アプリケーションのメインウィンドウ"""
    
    def __init__(self):
        super().__init__()
        self.sheets = []  # シート管理用
        self.current_file = None  # 現在開いているファイル
        self.initUI()
        
    def initUI(self):
        # ウィンドウの基本設定
        self.setWindowTitle('PyQt Excel風アプリ')
        self.setGeometry(100, 100, 1000, 700)
        
        # タブウィジェットの設定
        self.tabs = QTabWidget()
        self.tabs.setTabsClosable(True)
        self.tabs.tabCloseRequested.connect(self.closeTab)
        self.setCentralWidget(self.tabs)
        
        # 最初のシートを追加
        self.addSheet("Sheet1")
        
        # ツールバーと機能の追加
        self.createMenuBar()
        self.createToolBar()
        self.createStatusBar()
        
        # サンプルデータの読み込み
        self.loadSampleData()
    
    def addSheet(self, name):
        """新しいシートを追加"""
        sheet = Sheet()
        self.sheets.append(sheet)
        self.tabs.addTab(sheet, name)
        self.tabs.setCurrentIndex(self.tabs.count() - 1)
        return sheet
    
    def closeTab(self, index):
        """タブを閉じる(最低1つは残す)"""
        if self.tabs.count() > 1:
            self.tabs.removeTab(index)
            self.sheets.pop(index)
    
    def createMenuBar(self):
        """メニューバーの作成"""
        menubar = self.menuBar()
        
        # ファイルメニュー
        fileMenu = menubar.addMenu('ファイル')
        
        newAction = QAction('新規', self)
        newAction.setShortcut('Ctrl+N')
        newAction.triggered.connect(self.newFile)
        fileMenu.addAction(newAction)
        
        openAction = QAction('開く...', self)
        openAction.setShortcut('Ctrl+O')
        openAction.triggered.connect(self.openFile)
        fileMenu.addAction(openAction)
        
        saveAction = QAction('保存', self)
        saveAction.setShortcut('Ctrl+S')
        saveAction.triggered.connect(self.saveFile)
        fileMenu.addAction(saveAction)
        
        saveAsAction = QAction('名前を付けて保存...', self)
        saveAsAction.setShortcut('Ctrl+Shift+S')
        saveAsAction.triggered.connect(self.saveFileAs)
        fileMenu.addAction(saveAsAction)
        
        fileMenu.addSeparator()
        
        exportAction = QAction('CSVエクスポート...', self)
        exportAction.triggered.connect(self.exportCSV)
        fileMenu.addAction(exportAction)
        
        fileMenu.addSeparator()
        
        exitAction = QAction('終了', self)
        exitAction.setShortcut('Ctrl+Q')
        exitAction.triggered.connect(self.close)
        fileMenu.addAction(exitAction)
        
        # 編集メニュー
        editMenu = menubar.addMenu('編集')
        
        cutAction = QAction('切り取り', self)
        cutAction.setShortcut('Ctrl+X')
        cutAction.triggered.connect(self.cutCells)
        editMenu.addAction(cutAction)
        
        copyAction = QAction('コピー', self)
        copyAction.setShortcut('Ctrl+C')
        copyAction.triggered.connect(self.copyCells)
        editMenu.addAction(copyAction)
        
        pasteAction = QAction('貼り付け', self)
        pasteAction.setShortcut('Ctrl+V')
        pasteAction.triggered.connect(self.pasteCells)
        editMenu.addAction(pasteAction)
        
        editMenu.addSeparator()
        
        clearAction = QAction('クリア', self)
        clearAction.setShortcut('Delete')
        clearAction.triggered.connect(self.clearCells)
        editMenu.addAction(clearAction)
        
        # シートメニュー
        sheetMenu = menubar.addMenu('シート')
        
        addSheetAction = QAction('シート追加', self)
        addSheetAction.triggered.connect(self.addNewSheet)
        sheetMenu.addAction(addSheetAction)
        
        renameSheetAction = QAction('シート名変更', self)
        renameSheetAction.triggered.connect(self.renameCurrentSheet)
        sheetMenu.addAction(renameSheetAction)
        
        # 数式メニュー
        formulaMenu = menubar.addMenu('数式')
        
        recalcAction = QAction('再計算', self)
        recalcAction.setShortcut('F9')
        recalcAction.triggered.connect(self.recalculateFormulas)
        formulaMenu.addAction(recalcAction)
    
    def createToolBar(self):
        """ツールバーの作成"""
        toolbar = QToolBar("メインツールバー")
        self.addToolBar(toolbar)
        
        # ファイル操作
        newAction = QAction('新規', self)
        newAction.triggered.connect(self.newFile)
        toolbar.addAction(newAction)
        
        openAction = QAction('開く', self)
        openAction.triggered.connect(self.openFile)
        toolbar.addAction(openAction)
        
        saveAction = QAction('保存', self)
        saveAction.triggered.connect(self.saveFile)
        toolbar.addAction(saveAction)
        
        toolbar.addSeparator()
        
        # 編集操作
        cutAction = QAction('切り取り', self)
        cutAction.triggered.connect(self.cutCells)
        toolbar.addAction(cutAction)
        
        copyAction = QAction('コピー', self)
        copyAction.triggered.connect(self.copyCells)
        toolbar.addAction(copyAction)
        
        pasteAction = QAction('貼り付け', self)
        pasteAction.triggered.connect(self.pasteCells)
        toolbar.addAction(pasteAction)
        
        toolbar.addSeparator()
        
        # 数式バー
        toolbar.addWidget(QLabel("数式: "))
        self.formulaEdit = QComboBox()
        self.formulaEdit.setEditable(True)
        self.formulaEdit.setMinimumWidth(300)
        toolbar.addWidget(self.formulaEdit)
        
        # 数式適用ボタン
        applyButton = QPushButton("適用")
        applyButton.clicked.connect(self.applyFormula)
        toolbar.addWidget(applyButton)
    
    def createStatusBar(self):
        """ステータスバーの作成"""
        self.statusBar().showMessage('準備完了')
        
        # セル位置表示用ラベル
        self.cellPositionLabel = QLabel()
        self.statusBar().addPermanentWidget(self.cellPositionLabel)
        
        # 現在のシートのセル選択を監視
        self.currentSheet().itemSelectionChanged.connect(self.updateCellPosition)
    
    def updateCellPosition(self):
        """選択されたセルの位置を更新"""
        sheet = self.currentSheet()
        selected = sheet.selectedItems()
        
        if selected:
            # 最初の選択アイテムの位置
            item = selected[0]
            col_letter = chr(65 + item.column()) if item.column() < 26 else chr(64 + item.column()//26) + chr(65 + item.column()%26)
            position = f"{col_letter}{item.row()+1}"
            self.cellPositionLabel.setText(f"セル: {position}")
            
            # 数式バーに現在のセル内容を表示
            original_formula = item.data(Qt.UserRole)
            if original_formula:
                # 数式がある場合はそれを表示
                self.formulaEdit.setCurrentText(original_formula)
            else:
                # 通常のテキストを表示
                self.formulaEdit.setCurrentText(item.text())
        else:
            self.cellPositionLabel.setText("")
            self.formulaEdit.setCurrentText("")
    
    def applyFormula(self):
        """数式バーの内容を現在のセルに適用"""
        sheet = self.currentSheet()
        selected = sheet.selectedItems()
        
        if selected and self.formulaEdit.currentText():
            text = self.formulaEdit.currentText()
            
            # 最近使用した数式を追加(重複を避ける)
            if text not in [self.formulaEdit.itemText(i) for i in range(self.formulaEdit.count())]:
                self.formulaEdit.addItem(text)
            
            # 選択されたすべてのセルに適用
            for item in selected:
                item.setText(text)
    
    def currentSheet(self):
        """現在のシートを取得"""
        return self.sheets[self.tabs.currentIndex()]
    
    def cutCells(self):
        """選択したセルを切り取り"""
        self.currentSheet().cutSelection()
    
    def copyCells(self):
        """選択したセルをコピー"""
        self.currentSheet().copySelection()
    
    def pasteCells(self):
        """クリップボードからセルに貼り付け"""
        self.currentSheet().pasteSelection()
    
    def clearCells(self):
        """選択したセルをクリア"""
        self.currentSheet().clearSelection()
    
    def newFile(self):
        """新規ファイル作成"""
        # 保存確認
        if self.current_file or any(sheet.item(0, 0) for sheet in self.sheets):
            reply = QMessageBox.question(
                self, '確認', 
                "変更を保存しますか?",
                QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
            )
            
            if reply == QMessageBox.Save:
                self.saveFile()
            elif reply == QMessageBox.Cancel:
                return
        
        # 全シートをクリア
        while self.tabs.count() > 0:
            self.tabs.removeTab(0)
        self.sheets.clear()
        
        # 新しいシートを追加
        self.addSheet("Sheet1")
        self.current_file = None
        self.setWindowTitle('PyQt Excel風アプリ')
    
    def openFile(self):
        """ファイルを開く"""
        options = QFileDialog.Options()
        fileName, _ = QFileDialog.getOpenFileName(
            self, "ファイルを開く", "", 
            "CSV Files (*.csv);;All Files (*)", 
            options=options
        )
        
        if fileName:
            self.loadFile(fileName)
    
    def loadFile(self, fileName):
        """ファイルを読み込む"""
        try:
            # 新規ファイル状態にする
            self.newFile()
            
            with open(fileName, 'r', encoding='utf-8') as file:
                reader = csv.reader(file)
                data = list(reader)
                
                if data:
                    sheet = self.currentSheet()
                    
                    # テーブルサイズを調整
                    rows = len(data)
                    cols = max(len(row) for row in data)
                    sheet.setRowCount(max(rows, 100))
                    sheet.setColumnCount(max(cols, 26))
                    
                    # データをセット
                    for r, row in enumerate(data):
                        for c, value in enumerate(row):
                            item = QTableWidgetItem(value)
                            sheet.setItem(r, c, item)
                    
                    # 数式を処理
                    sheet.processAllFormulas()
                    
                    # ファイル情報を更新
                    self.current_file = fileName
                    self.setWindowTitle(f'PyQt Excel風アプリ - {os.path.basename(fileName)}')
                    self.statusBar().showMessage(f'ファイルを読み込みました: {fileName}')
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"ファイルの読み込み中にエラーが発生しました: {str(e)}")
    
    def saveFile(self):
        """ファイルを保存"""
        if self.current_file:
            self.saveToFile(self.current_file)
        else:
            self.saveFileAs()
    
    def saveFileAs(self):
        """名前を付けて保存"""
        options = QFileDialog.Options()
        fileName, _ = QFileDialog.getSaveFileName(
            self, "名前を付けて保存", "", 
            "CSV Files (*.csv);;All Files (*)", 
            options=options
        )
        
        if fileName:
            self.saveToFile(fileName)
            self.current_file = fileName
            self.setWindowTitle(f'PyQt Excel風アプリ - {os.path.basename(fileName)}')
    
    def saveToFile(self, fileName):
        """指定されたファイルに保存"""
        try:
            sheet = self.currentSheet()
            
            with open(fileName, 'w', newline='', encoding='utf-8') as file:
                writer = csv.writer(file)
                
                # すべての行を書き込み
                for r in range(sheet.rowCount()):
                    row_data = []
                    for c in range(sheet.columnCount()):
                        item = sheet.item(r, c)
                        text = item.text() if item else ""
                        row_data.append(text)
                    
                    # 空行を除外(末尾の空白行)
                    if any(cell for cell in row_data):
                        writer.writerow(row_data)
            
            self.statusBar().showMessage(f'ファイルを保存しました: {fileName}')
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"ファイルの保存中にエラーが発生しました: {str(e)}")
    
    def exportCSV(self):
        """CSV形式でエクスポート"""
        options = QFileDialog.Options()
        fileName, _ = QFileDialog.getSaveFileName(
            self, "CSVエクスポート", "", 
            "CSV Files (*.csv);;All Files (*)", 
            options=options
        )
        
        if fileName:
            # 通常の保存メソッドを使用
            self.saveToFile(fileName)
    
    def addNewSheet(self):
        """新しいシートを追加"""
        sheetCount = self.tabs.count() + 1
        sheetName = f"Sheet{sheetCount}"
        self.addSheet(sheetName)
    
    def renameCurrentSheet(self):
        """現在のシートの名前を変更"""
        current_index = self.tabs.currentIndex()
        current_name = self.tabs.tabText(current_index)
        
        new_name, ok = QInputDialog.getText(
            self, "シート名変更", 
            "新しいシート名を入力してください:", 
            text=current_name
        )
        
        if ok and new_name:
            self.tabs.setTabText(current_index, new_name)
    
    def recalculateFormulas(self):
        """すべての数式を再計算"""
        sheet = self.currentSheet()
        sheet.processAllFormulas()
        self.statusBar().showMessage("数式を再計算しました")
    
    def loadSampleData(self):
        """サンプルデータをロード"""
        sheet = self.currentSheet()
        
        # サンプルデータの定義
        sample_data = [
            ["", "1月", "2月", "3月", "4月", "5月", "6月", "合計", "平均"],
            ["売上", "120000", "150000", "180000", "140000", "160000", "190000", "=SUM(B2:G2)", "=AVERAGE(B2:G2)"],
            ["費用", "80000", "90000", "100000", "95000", "98000", "110000", "=SUM(B3:G3)", "=AVERAGE(B3:G3)"],
            ["利益", "=B2-B3", "=C2-C3", "=D2-D3", "=E2-E3", "=F2-F3", "=G2-G3", "=SUM(B4:G4)", "=AVERAGE(B4:G4)"],
            ["利益率", "=B4/B2", "=C4/C2", "=D4/D2", "=E4/E2", "=F4/F2", "=G4/G2", "=H4/H2", ""]
        ]
        
        # データをセット
        for r, row in enumerate(sample_data):
            for c, value in enumerate(row):
                sheet.setItem(r, c, QTableWidgetItem(value))
        
        # セルの書式設定
        # タイトル行を太字に
        font = QFont()
        font.setBold(True)
        for c in range(len(sample_data[0])):
            if sheet.item(0, c):
                sheet.item(0, c).setFont(font)
                sheet.item(0, c).setBackground(QColor(240, 240, 240))
        
        # 項目列を太字に
        for r in range(len(sample_data)):
            if sheet.item(r, 0):
                sheet.item(r, 0).setFont(font)
                sheet.item(r, 0).setBackground(QColor(240, 240, 240))
        
        # 合計・平均列に背景色をつける
        for r in range(len(sample_data)):
            for c in [7, 8]:  # 合計と平均列
                if r > 0 and c < len(sample_data[0]) and sheet.item(r, c):
                    sheet.item(r, c).setBackground(QColor(230, 230, 250))
        
        # すべての数式を処理
        sheet.processAllFormulas()
        
        # 通貨書式を適用(シンプルな実装)
        for r in range(1, 4):  # 売上、費用、利益の行
            for c in range(1, 9):  # 数値を含む列
                item = sheet.item(r, c)
                if item and item.text().replace('.', '').replace(',', '').isdigit():
                    # 数値を整形(小数点以下なし、3桁区切りのカンマ)
                    try:
                        value = float(item.text().replace(',', ''))
                        formatted = f"{int(value):,}"
                        item.setText(formatted)
                    except:
                        pass
        
        # 利益率行にパーセント書式を適用
        for c in range(1, 8):  # 利益率の列
            item = sheet.item(4, c)
            if item and item.text():
                try:
                    value = float(item.text())
                    formatted = f"{value:.1%}"
                    item.setText(formatted)
                except:
                    pass


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = ExcelLikeApp()
    ex.show()
    sys.exit(app.exec_())
1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?