PySide 

PySide - QFileDialogでファイル・フォルダパスを両方取得する

2024/12/24

Qtでエクスプローラーを実装するにはQFileDialogが便利です。
しかし、QFileDialogはデフォルトではファイル・フォルダ両方のパスが取得できるように作られていません。
その機能を独自に実装しようとしたときにつまづいたことがあったので、備忘録を残しておこうと思います。

ファイル・ディレクトリ両方に対応していない

エクスプローラーを表示してファイルパスを取得するには QFileDialoggetOpenFileName() メソッドを使います。
エクスプローラーでファイルを選択して「開く」をクリックすると、ファイルパスの文字列が返ってきます。▼

ファイルパスを取得するにはこれで良いのですが、問題は フォルダのパスが取得できない ことです。
getOpenFileName() では、フォルダを選択して開くを押すとそのフォルダの中身が表示されるだけで、フォルダ自体のパスを取得することができません。

getExistingDirectory() を使えばフォルダのパスのみを返すエクスプローラーを起動できますが、今度はファイルのパスを取得することができません。
つまり、ファイル・フォルダ両方に対応できない のが、QFileDialog の難点です。

やりたいこと

ChatGPTにサンプルのアプリを作ってもらいました。▼
エクスプローラーで選択したファイルパスをリストに追加していくだけのアプリです。

class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

    def get_file_path(self):
        file_path, _ = self.getOpenFileName(self, "Select a File")
        return file_path

class ScrollAreaApp(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
    
    ・
    ・
    ・

    def add_path(self):
        file_dialog = FileDialog(self)
        file_path = file_dialog.get_file_path()
        if file_path:  # Only add if a file is selected
            # Create a new label for the path
            label = QtWidgets.QLabel(file_path)
            label.setAlignment(Qt.AlignLeft)

            # Add the label to the layout
            self.scroll_layout.addWidget(label)
            self.path_labels.append(label)

ファイルパスを取得する処理には getOpenFileName() を使っているので、前述の通りこれではフォルダパスを取得することはできません。

どうすればいいか

デフォルトで実装されているメソッドが使えないのであれば、QFileDialogのサブクラスを作り、選択しているファイル・フォルダのパスを返すメソッド を自作すれば良さそうです。

ざっくりとした要件は以下の通りです。▼

エクスプローラーを起動する

エクスプローラーを起動するには、exec() メソッドを呼び出します。
exec() はエクスプローラーが閉じた時の状態によって、以下の戻り値を返します。

「開く」 ボタンが押されたかどうかを戻り値で判定することもできます。

import sys
from PySide6 import QtWidgets

class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    file_dialog = FileDialog()

    result = file_dialog.exec() # エクスプローラーを起動

    if result == QtWidgets.QDialog.Accepted: # 1
        print("File selected.")
    elif result == QtWidgets.QDialog.Rejected: # 0
        print("No file selected.")

選択しているファイル・フォルダパスを取得する

選択しているファイル・フォルダのパスを取得するには、getFileNames() メソッドを使います。
選択したファイル・フォルダのパスがリストとして返ってきます。

file_dialog = FileDialog()

if file_dialog.exec() == QtWidgets.QDialog.Accepted:
    selected_file = file_dialog.selectedFiles()[0]

「開く」ボタンを取得する

「開く」ボタンを押したときの挙動をカスタマイズしたいので、開くボタンのウィジェットを取得する必要があります。

class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.btn_open = self.get_open_button() # 「開く」ボタン
    
    def get_open_button(self):
        """開くボタンを取得する"""
        button_box = self.findChild(QtWidgets.QDialogButtonBox)
        if button_box:
            for button in button_box.buttons():
                if button_box.buttonRole(
                    button) == QtWidgets.QDialogButtonBox.AcceptRole:
                    return button
        return None

まず、findChild() で QFileDialog の子ウィジェットから QDialogButtonBox クラスのウィジェットを取得します。
そして、QDialogButtonBox の中から AcceptRole が割り当てられているボタンを参照することで「開く」ボタンのウィジェットを取得することができます。

環境によっては、QFileDialogの構成がプラットフォームに依存しており、ボタンなどのウィジェットがQtの標準クラスで取得できない場合があります。
その場合、QFileDialog.DontUseNativeDialog オプションを有効にすることで問題を回避できます。

class FileDialog(QtWidgets.QFileDialog):
    super().__init__(parent)
    # ネイティブダイアログを無効化
    self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)

フォルダパスを返してエクスプローラーを閉じる

フォルダを選択した状態で「開く」ボタンを押すと フォルダの中が表示されてしまう ので、下記の手順を再現できません。

  1. フォルダを選択
  2. 「開く」ボタンを押す
  3. エクスプローラーが閉じる
  4. 選択されたフォルダパスを返す


accept() を呼び出すことで、正常終了のステータス(QFileDialog.Accepted)を返して エクスプローラーを閉じることができます。
reject() を使うと、異常終了のステータス(QFileDialog.Rejected)を返して エクスプローラーが閉じます。

close() メソッドでもエクスプローラーを閉じることはできますが、エクスプローラーが閉じられた時のステータスを返さないので注意が必要です。

class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)
        self.btn_open = self.get_open_button() # 「開く」ボタン
        self.btn_open.clicked.connect(self.on_btn_open_clicked)
    
    def get_open_button(self):
        """開くボタンを取得する"""
        button_box = self.findChild(QtWidgets.QDialogButtonBox)
        if button_box:
            for button in button_box.buttons():
                if button_box.buttonRole(
                    button) == QtWidgets.QDialogButtonBox.AcceptRole:
                    return button
        return None
    
    def on_btn_open_clicked(self):
        """開くボタンが押されたときの処理"""
        selected_file = self.selectedFiles()[0]
        if os.path.isdir(selected_file):
            # ファイルモードをフォルダ用に変更
            self.setFileMode(QtWidgets.QFileDialog.Directory)
        self.accept() # ダイアログを閉じる

QFileDialog のファイルモードが QFileDialog.Directory でないと、 フォルダを選択状態で「開く」ボタンを押してもエクスプローラーが閉じずにフォルダの中身が表示されてしまうので、 選択されたアイテムがフォルダだった場合にファイルモードを変更する必要があります。

ファイルがダブルクリックされた場合

ユーザーが「開く」ボタンを押さず、ファイルをダブルクリックした場合の挙動も実装する必要があります。

ダブルクリック時のシグナルを送信したビューを sender() で取得して、ビューにセットされているモデルを model() で取得します。
そのモデルの filePath() メソッドにアイテムのインデックスを渡すことで、アイテムのファイル・ディレクトリパスを取得することができます。

概念図は以下の通りです。▼

また、フォルダをダブルクリックした場合は処理を中断し、デフォルトの挙動のみが行われるようにします。

class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)

        # ビューを取得
        for widget in (QtWidgets.QListView, QtWidgets.QTreeView):
            children = self.findChildren(widget)
            if children:
                for view in children:
                    # ビューのダブルクリック時のシグナル
                    view.doubleClicked.connect(self.on_item_double_clicked)
            else:
                continue

    def on_item_double_clicked(self, index):
        """ダブルクリック時の処理"""
        model = self.sender().model()
        file_path = model.filePath(index)
        if os.path.isdir(file_path): # フォルダの場合はスルー
            return
        print(f"Double-clicked file: {file_path}")
        # ファイルを選択してダイアログを閉じる
        self.selectFile(file_path)
        self.accept()

実装してみる

以上の内容をもとに、機能を実装してみました。▼

import sys
import os

from PySide6 import QtWidgets
from PySide6.QtCore import Qt


class FileDialog(QtWidgets.QFileDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)
        self.setFileMode(QtWidgets.QFileDialog.AnyFile)

        self.btn_open = self.get_open_btn() # 開くボタン
        self.btn_open.clicked.connect(self.on_open_btn_clicked)

        for widget in (QtWidgets.QListView, QtWidgets.QTreeView):
            children = self.findChildren(widget)
            if children:
                for view in children:
                    view.doubleClicked.connect(self.on_item_double_clicked)
    
    def on_item_double_clicked(self, index):
        """ダブルクリック時の処理"""
        model = self.sender().model()
        file_path = model.filePath(index)
        if os.path.isdir(file_path):
            return
        self.selectFile(file_path)
        self.accept()
    
    def on_open_btn_clicked(self):
        """開くボタンが押された時の処理"""
        selected_file = self.selectedFiles()[0]
        if os.path.isdir(selected_file):
            self.setFileMode(QtWidgets.QFileDialog.Directory)
        self.accept()

    def get_open_btn(self):
        """開くボタンを取得"""
        btn_box = self.findChild(QtWidgets.QDialogButtonBox)
        if btn_box:
            for btn in btn_box.buttons():
                if btn_box.buttonRole(
                    btn) == QtWidgets.QDialogButtonBox.AcceptRole:
                    return btn
        return None
    
    def get_path(self) -> str:
        """エクスプローラーを起動し、選択されたファイル・ディレクトリパスを返す"""
        if self.exec() == QtWidgets.QFileDialog.Accepted:
            return self.selectedFiles()[0]
        else:
            return ""


class ScrollAreaApp(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Scroll Area Example")
        self.setGeometry(100, 100, 400, 300)

        self.main_layout = QtWidgets.QVBoxLayout()
        self.setLayout(self.main_layout)

        # ボタン
        self.add_button = QtWidgets.QPushButton("Add Path")
        self.add_button.clicked.connect(self.add_path)
        self.main_layout.addWidget(self.add_button)

        # スクロールエリア
        self.scroll_area = QtWidgets.QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.main_layout.addWidget(self.scroll_area)
        self.scroll_content = QtWidgets.QWidget()
        self.scroll_layout = QtWidgets.QVBoxLayout()
        self.scroll_content.setLayout(self.scroll_layout)
        self.scroll_area.setWidget(self.scroll_content)

    def add_path(self):
        """エクスプローラーを起動し、選択されたファイル・ディレクトリパスを
        スクロールエリアに追加"""
        file_dialog = FileDialog(self)
        file_path = file_dialog.get_path()
        if file_path:
            label = QtWidgets.QLabel(file_path)
            label.setAlignment(Qt.AlignLeft)
            self.scroll_layout.addWidget(label)

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    window = ScrollAreaApp()
    window.show()
    sys.exit(app.exec())

アプリを起動して挙動を検証してみると、以下のようにファイル・フォルダパス両方をスクロールエリアに追加できました。▼