プログラムでおかえしできるかな

定年を過ぎて何かの役に立てないかなと始めた元SEのブログです

CSV viewerアプリの作り方(ドラッグアンドドロップ)【Python】

このエントリーをはてなブックマークに追加

f:id:juu7g:20211025100916p:plain:left:w120 PythonGUIアプリの勉強を兼ねてCSV viewer(CSVビューア)を作成しました。
GUIにはTkinterを、ドラッグアンドドロップ対応にTkinterDnD2を使用しました。
また、アプリも作成し、アプリにファイルをドラッグアンドドロップすると、アプリが起動しファイルを表示するようにしました。
TkinterDnD2 の使い方(簡単なパス情報の取得方法も)、アプリの作り方をサンプルコードで説明します。

目次

■アプリのサンプル画像

▼複数ファイルをタブ表示します
CSV viewer

▼タブごとに行の高さを自動調整します
CSV viewer

▼TSVも表示可能です(対象外のファイルをドラッグアンドドロップするとコメントが出ます)
CSV viewer

▼対象のファイルがない場合はコメントが出ます
CSV viewer

■機能・特長

ドラッグアンドドロップに対応

ファイルをドラッグアンドドロップして表示できます。
対応には、TkinterDnD2 を用います。
これは、Tkinterドラッグアンドドロップ機能を付加するライブラリです。
詳しくは「ドラッグアンドドロップの実装」を参照

□画面表示の特徴

画面表示には次の特徴があります。

Tkinterの使用ウィジェット

Tkinter がサポートする GUI の部品をウィジェット (widget) と呼びます。

ここでは次のウィジェットを使用しています。
これらの使い方も説明します。

■必要なパッケージ

□必要な Python パッケージ

  • TkinterDnD2

TkinterPythonの標準パッケージです。

ドラッグアンドドロップの実装(TkinterDnD2の使い方)

GUI に対してドラッグアンドドロップを実現するには、Tkinter 標準では複雑な対応になるため、ライブラリ TkinterDnD2 を使用します。

TkinterDnD2 は完全に tk をラップしているので TkinterDnD2 の Tk インスタンスを作成すれば、Tk がそのまま機能します。

今回は、ファイルをドラッグアンドドロップしてファイル名を取得し処理する使い方を実装しています。

これに関わる使い方を説明します。

□使い方の基本

  • インストール:pip install tkinterdnd2
  • インポート:from tkinterdnd2 import * (この方が安全と仕様にあります)
  • インスタンス作成:TkinterDnD.Tk()
    Tkinter で tk.Tk()としていたところをこれに置き換えるだけです。
  • ドロップを受け取るウィジェットで受け取りを登録:drop_target_register
  • ドロップ後に実行するメソッド(コールバック関数)を登録:dnd_bind

ドラッグアンドドロップの設定メソッド

ウィジェットに追加された主なメソッド

TkinterDnD2 は Tkinter をラップしているので、Tkinter で用意しているウィジェットを使用し、さらに TkinterDnD2 で拡張されたメソッドを利用できます。

以下は、ウィジェットオブジェクトに拡張されたメソッドの一部です。

  • dnd_bind(self, sequence=None, func=None, add=None)
    ウィジェットドラッグアンドドロップ動作をバインド
    シーケンスと動作時に起動する関数を指定

    • 【sequence】<<DropEnter>>, <<DropPosition>>, <<DropLeave>>, <<Drop>>, <<Drop:type>>, <<DragInitCmd>>, <<DragEndCmd>>など
      • << Drop >>:ドロップターゲットウィジェット上でドロップが発生したとき
      • << DropEnter >>:ドロップ中にマウスがウィジェットに入るとき
      • << DropPosition >>:ドロップ中にマウスがウィジェット上を移動したとき
      • << DropLeave >>:マウスがウィジェットを離れたとき(ドロップが発生しないことを通知)
    • 【func】関数
  • drop_target_register(self, *dndtypes)
    ドロップを受け取るウィジェットとして登録

    • 【dndtype】DND_FILES(ファイルリスト), DND_TEXT(テキスト) など(プラットフォーム依存しないもの)
  • drag_source_register(self, button=None, *dndtypes)
    ドラッグを開始するウィジェットとして登録

    • 【dndtype】DND_FILES(ファイルリスト), DND_TEXT(テキスト) など(プラットフォーム依存しないもの)
    • 【button】ドラッグするときのマウスボタン(1:左(デフォルト)、2:中、3:右)
  • drop_target_unregister(self)
    ウィジェットのドロップを受け取る登録を止める

  • drag_source_unregister(self)
    ウィジェットのドラッグを開始する登録を止める

□パス取得 - イベントクラスに追加された属性データ(event.data)

TkinterDnD2 で Tkinter のイベントクラスにも拡張があります。
その中の data 属性でドラッグアンドドロップされたファイルの情報が取得できます。

◎パス情報 - データ仕様

ファイルをドラッグアンドドロップした場合、ファイルのパスが文字列で返ります。文字列には複数のパスが含まれます。

  • 【文字列構文】 パス名1 パス名2 パス名3 ・・・
    【パス名構文】 パス名 = パス名 | {パス名}
    • 「{パス名}」となるのは空白を含む場合
      ※日本語が入る場合も同様みたいですが不明確です。

  • '{C:/temp/persons_email_db - コピー.csv} C:/temp/persons.csv'

◎パス情報のタプル化 - データの分割

取得できるデータは複数のパスを含む文字列なので一つずつのパスに分けないと扱いにくいです。
文字列をパスごとのタプルに分割するメソッドがあります。

  • 【メソッド】 widget.tk.splitlist(str)
    【戻り値】パス名のタプル
    widgetウィジェットオブジェクト
    例   :t = widget.tk.splitlist(event.data)
    結果の例:('C:/temp/persons_email_db - コピー.csv', 'C:/temp/persons.csv')
更新:2022-02-23

□本アプリでのドラッグアンドドロップ対応

本アプリでは、複数ファイルを選択してドラッグし、アプリ画面上の任意の位置にドロップするとファイルの中身が表示されるように対応します。

【処理】

  • ウィジェットにドロップで動作するメソッドをバインド
    • トップレベルウィンドウを TkinterDnD2 のコンストラクタで作成
    • ウィジェットにドロップの受け取りを登録
      dnd_type を <<DND_FILES>> にする
    • ウィジェットにメソッド(コールバック関数)をバインド
      シーケンスを <<Drop>> にする
  • コールバック関数の処理
    • ドロップされたファイル情報をパスのタプルに変換
      event の有無で通常呼び出しと区別する

【コード】

インスタンス作成とバインド

    root = TkinterDnD.Tk()              # トップレベルウィンドウの作成  tkinterdnd2の適用
    
    root.drop_target_register(DND_FILES)
    root.dnd_bind("<<Drop>>", listview.open_csv)

※本アプリではトップレベルウィンドウに対してドラッグアンドドロップの設定を行っていますが、個別のウィジェットでも可能です。

▼コールバック関数でイベントで取得したファイル名を解析

    def open_csv(self, event=None):
    # DnD対応
    if event:
        # DnDのファイル情報はevent.dataで取得
        # "{空白を含むパス名1} 空白を含まないパス名1"が返る
        # widget.tk.splitlistでパス名のタプルに変換
        # list_csv_pathはListboxウィジェットのオブジェクトです
        self.file_paths = self.list_csv_path.tk.splitlist(event.data)

□pyinstaller使用時の注意

pyinstallerを使う場合、次の対応が必要です。

  1. hook-tkinterdnd2.pyをGithubから取得
    リンク:GitHub - pmgagne/tkinterdnd2: Tkinter native drag and drop support for windows, unix and Mac OSX.
    hook-tkinterdnd2.pyを開いて表示されている内容をコピーして作成
    ファイル名はhook-tkinterdnd2.py
  2. hook-tkinterdnd2.py を pyinstaller を実行するフォルダへコピー
  3. pyinstallerに次のオプションを追加
    --additional-hooks-dir .
    pyinstaller -F -w myproject/myproject.py --additional-hooks-dir .

根拠となる説明文

If you want to use pyinstaller, you should use the hook-tkinterdnd2.py file included. Copy it in the base directory of your project, then:

pyinstaller -F -w myproject/myproject.py --additional-hooks-dir=. tkinterdnd2 · PyPI

Tkinterの使い方

Tkinter(ティーキンター)は Python が標準で提供している GUI パッケージです。
他のプログラムの開発ツールで提供されているようなルック&フィールな GUI ツールではありません。
従って、画面を構成するには、すべてコードを書く必要があります。

Tkinter を使用するには import が必要です。

   import tkinter as tk

慣例的に tk と別名を付けるようです。

□初歩的な使い方

ウィンドウを出すだけのサンプルです。
Python を起動して次の命令を入れるとウィンドウが出てきてアプリらしさをちょっとだけ体験できます。

import tkinter as tk     #tkと省略することが慣例のようです  
win = tk.Tk()               #ウィンドウの作成と表示  
win.title("Hello, World")    #タイトル設定  
win.geometry("400x300")      #ウィンドウサイズ設定  
lbl = tk.Label(win, text="ラベル")    #ラベルウィジェットの作成  
lbl.pack()                  #ラベルウィジェットの配置  
win.mainloop()              #イベント待ち  

□使用ウィジェット

ウィジェットは基本的にインスタンスを作成し、表示テキストや表示方法などを指定して使用します。

今回使用しているウィジェットです。

パッケージ ウィジェット 用途 見た目
tkinter Frame
フレーム
ウィジェットの受け皿
tkinter Label
ラベル
文字の表示
tkinter Listbox
リストボックス
選択可能なテキストを表示するボックス
tkinter Button
ボタン
押すと処理が動くボタン
tkinter Scrollbar
スクロールバー
画面をスクロール
tkinter.ttk Combobox
コンボボックス
選択可能なテキストを表示するプルダウンボックス
tkinter.ttk Treeview
ツリービュー
ツリー表示やリスト表示
ここではリスト表示で使用
tkinter.ttk Notebook
ノートブック
タブ切り替え表示

ウィジェットに変数を関連付ける

ウィジェットの値と連動して変更される変数をウィジェット変数と呼びます。
今回は、ファイルパス用リストボックス、文字コード選択用コンボボックス、メッセージ表示用ラベルにウィジェット変数を関連付けます。

ウィジェット変数とウィジェットを関連付けるには、ウィジェットの属性 variable, textvariable にウィジェット変数を指定します。
ウィジェット変数のコンストラクタに value= 引数で初期値を与えられます。
ウィジェット変数の値の読み取りは get() メソッドで、値の書き込みは set() メソッドで行います。

他に、DoubleVar, BooleanVar があります。

【コード】
        self.var_csv_path = tk.StringVar(value="")
        # リストボックス height=負 で全体表示
        self.list_csv_path = tk.Listbox(parent, height=-1, listvariable=self.var_csv_path)
        self.btn_f_sel = tk.Button(parent, text="ファイル選択", command=self.select_files)
        self.lbl_encode = tk.Label(parent, text="文字コード:")
        self.var_encode = tk.StringVar(value="")
        self.cbb_encode = ttk.Combobox(parent, values=["utf_8", "cp932"], width=5, textvariable=self.var_encode)
        self.cbb_encode.current(1)  # 第1要素を選択状態にする
        self.msg = tk.StringVar(value="msg")
        self.lbl_msg = tk.Label(parent
                                , textvariable=self.msg
                                , justify=tk.LEFT
                                , font=("Fixedsys", 11)
                                , relief=tk.RIDGE
                                , anchor=tk.W)

□画面構成 - フレーム構造

プログラムを作るにあたって、どのような画面にするか決めなくてはなりません。画面構成を考えるということですね。
画面を構成するというのは、GUI 部品(ウィジェット)を画面に配置することです。
ウィジェットは設定したテキストに合わせてサイズが決まるので、そのまま使用しても画面構成上問題ないものと、広げないと見栄えが良くないものが出てきます。
ウィジェットを広げる場合、他のウィジェットとの位置関係で思ったように配置できないことがあります。
そのようなときに Frame ウィジェットを使用して区分けをして希望する位置に配置します。
引き出しの整理に使うトレイのようなイメージでしょうか。

今回の画面構成です。

  • u_frame(frame)
    • lbl_csv(Label)
    • list_csv_path(Listbox)
    • btn_f_sel(Button)
    • lbl_encode(Label)
    • cbb_encode(Combobox)
    • lbl_msg(Label)
  • b_frame(frame)
    • note(Notebook)
      • frame1(Frame)
        • treeview1(Treeview)
        • h_scrlbar(Scrollbar)
        • v_scrlbar(Scrollbar)

※「u_frame」などはプログラムで使用した変数名です。
※図形の中の矢印はこの後説明するオプション fill の方向です。

入力をする部分と出力をする部分で大きくフレームを分けています。
結果を表示する部分は余った領域をすべて使用してできるだけ大きく表示させるためです。

ウィジェットの配置 - pack

ウィジェットインスタンスを作成した後に配置 (pack) をしないと表示されません。
配置には他に grid と place がありますが、基本的な pack を使用します。
pack とは、スーツケースに荷物をパッキングするようなイメージなんじゃないかなと勝手に思っています。

ウィジェットを pack すると side オプションで指定された方向から配置します。
その時に配置した方向と直行する方向には可能な限りの領域を割り当てます。
例えば、デフォルトの TOP で配置すると直行する横方向に可能なだけ領域を確保します。横一杯になるということですね。
ただし、これは領域の話で、実際のウィジェットの表示は他のオプションに依ります。

  • 【構文】 ウィジェット.pack(オプション1 = 設定値, オプション2 = 設定値,・・・)

  • 【主なオプション】

    オプション 説明 設定値
    side 次の方向から詰め込む TOP:上(デフォルト)、BOTTOM:下、LEFT:左、RIGHT:右
    fill 割り当て領域内での引き伸ばし NONE:なし(デフォルト)、X:水平方向、Y:垂直方向、BOTH:両方向
    expand 割り当て領域の詰め込み方向への拡大 True:する、False:しない(デフォルト)


その他のオプション

オプション 説明 設定値
after 指定ウィジェットの後に配置 他のウィジェット
before 指定ウィジェットの前に配置 他のウィジェット
anchor 割り当て領域内の配置位置 NW,N,NE,W,CENTER,E,SW,S,SE(方角)
in 配置する親
ipadx,ipady 内部に空ける間隔 間隔(単位無しはピクセル)
padx,pady 外側に空ける間隔 間隔(単位無しはピクセル)

pack で「よく表示されない」という問題が発生しますが、私もそうでした、pack の順番を変えてあげることで解決する場合があるようです。

必ずではないようですが、配置の時に次のことを考慮しておくと良いみたいです。

  • expand オプションを True に指定して pack するウィジェットは、後から pack する。

ウィジェットの pack についてはこちらのサイトが詳しいです。

□動作をウィジェットに割り当てる - バインド

ウィジェットで何か操作した場合にそれに連動して動作するメソッドを割り当てることができます。
この動作(イベント)とメソッドを割り当てることをバインドと呼びます。
イベントには、キー入力可能なウィジェットでキー入力した場合やマウス操作した場合、コンボボックスで選択肢を選択した場合などがあります。

キーバインド

コンボボックスはエントリーを継承しているのでキー操作後に動作する処理を割り当てられます。
コンボボックスで Enter を押した時にCSV ファイルを読み込むようにバインドします。

  • 【構文】 ウィジェット.bind(シーケンス, コールバック関数, 追加フラグ)

  • 引数

    • シーケンス:イベント名(例:<Return>, <Button-1>)
    • コールバック関数:関数には event 引数が必要
      ウィジェットの command オプションと共通の場合は、関数の引数定義を event=None とする
    • 追加フラグウィジェットに対してイベントを置き換えるか追加するか
      デフォルトは置き換え(引数を省略した場合)
        self.cbb_encode.bind("<Return>", self.after_change_encode)
    
    def after_change_encode(self, event=None):

※引数が必要な場合はラムダ式を使用します。(詳細は省略)

◎コンボボックス

コンボボックスでは、選択肢を選択した時に動作する処理を割り当てられます。
bind メソッドで関数を指定します。
その時のシーケンスは、<<ComboboxSelected>> です。

        self.cbb_encode.bind("<<ComboboxSelected>>", self.after_change_encode)

    def after_change_encode(self, event=None):

※引数が必要な場合はラムダ式を使用します。(詳細は省略)

■画面の作成

先ほどの画面構成を踏まえて画面を作成します。

【処理】

  1. Frame の作成
    上側用:入力用テキストボックスや実行用ボタン用
    下側用:Notebookとその中のTreeview とスクロールバー用

  2. Frame の配置 (pack)

  3. 上フレームの中身の作成 ( create_input_frame メソッド)

    1. CSV:」文字用ラベル作成
    2. ファイルパス入力用ウィジェット変数の作成
    3. ファイルパス入力用リストボックス作成
    4. ファイル選択ボタン作成
    5. 文字コード」文字用ラベル作成
    6. 文字コードウィジェット変数の作成
    7. 文字コード用コンボボックス作成
    8. 文字コード用コンボボックスの選択状態設定
    9. メッセージ用ウィジェット変数作成
    10. メッセージ用ラベル作成
    11. ウィジェットの配置 (pack)
    12. キーバインドの作成

※Notebook の中身の作成は「タブの実装」で解説します。

【コード】

▼フレームの作成

        self.u_frame = tk.Frame(bg="white")     # 背景色を付けて配置を見る
        self.b_frame = tk.Frame(bg="green")     # 背景色を付けて配置を見る
        self.note = ttk.Notebook(self.b_frame)
        self.u_frame.pack(fill=tk.X)
        self.b_frame.pack(fill=tk.BOTH, expand=True)
        self.note.pack(fill=tk.BOTH, expand=True)
        self.create_input_frame(self.u_frame)

▼上フレームの中身の作成

    def create_input_frame(self, parent):
        """
        入力項目の画面の作成
        上段:入力ファイルパス、ファイル選択ボタン、開くボタン、文字コード選択
        下段:メッセージ
        """
        self.lbl_csv = tk.Label(parent, text="CSV:")
        self.var_csv_path = tk.StringVar(value="")
        # リストボックス height=負 で全体表示
        self.list_csv_path = tk.Listbox(parent, height=-1, listvariable=self.var_csv_path)
        self.btn_f_sel = tk.Button(parent, text="ファイル選択", command=self.select_files)
        self.lbl_encode = tk.Label(parent, text="文字コード:")
        self.var_encode = tk.StringVar(value="")
        self.cbb_encode = ttk.Combobox(parent, values=["utf_8", "cp932"], width=5, textvariable=self.var_encode)
        self.cbb_encode.current(1)  # 第1要素を選択状態にする
        self.msg = tk.StringVar(value="msg")
        self.lbl_msg = tk.Label(parent
                                , textvariable=self.msg
                                , justify=tk.LEFT
                                , font=("Fixedsys", 11)
                                , relief=tk.RIDGE
                                , anchor=tk.W)
        self.lbl_msg.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)    # 先にpackしないと下に配置されない
        self.cbb_encode.pack(side=tk.RIGHT, fill=tk.Y)
        self.lbl_encode.pack(side=tk.RIGHT, fill=tk.Y)
        self.lbl_csv.pack(side=tk.LEFT, fill=tk.BOTH)
        self.list_csv_path.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.btn_f_sel.pack(side=tk.RIGHT)
        # bind
        self.cbb_encode.bind("<<ComboboxSelected>>", self.after_change_encode)
        self.cbb_encode.bind("<Return>", self.after_change_encode)

■タブの実装(Notebookウィジェットの使い方)

Notebook ウィジェットはタブ切り替えを提供するウィジェットです。
Notebook ウィジェットtkinter.ttk パッケージに入っているので使用には import が必要です。

   import tkinter.ttk as ttk

慣例的に ttk と別名を付けるようです。

この章では次の内容について説明します。

この章での説明

□タブの削除 - forget

既に存在するタブがある場合、forget メソッドで削除できます。 引数にタブを指定します。
タブ指定の一つに CURRENT があり、現在選択中のタブを指します。

  • 【構文】 notebook.forget(タブ指定)
  • サンプル
    【構文】 notebook.forget("current")
  • タブ指定(抜粋)
    • n:0からタブ数の間の整数
    • textオプションの値:タブ名
    • "current":現在選択されているタブ
更新:2022-02-17

【コード】

次のコードは、全タブのクリアを行います。
tabs() メソッドは全タブの名前がリストで返ります。

        # notebookの既存のタブを削除
        while self.note.tabs():
            self.note.forget("current")

□タブの追加 - add

タブの追加には、add メソッドを使用します。
引数に追加するウィジェットを指定します。通常、Frame にすることが多いようです。
オプションとしてタブ名を指定する text= がよく使われます。

  • 【構文】 notebook.add(ウィジェット, オプション)
  • サンプル
    【構文】 notebook.add(frame, text=タブ名)
  • オプション
    • text:タブに表示されるテキスト
更新:2022-02-17

【コード】

        parent.add(frame1, text=tab_name)

□Notebookのタブの画面作成

画面構成にあるようにタブの中身を作成します。
Frame を用意して Treeview とスクロールバーを作成します。
スクロールバーは縦横を作成します。

【処理】

【コード】

▼タブ用フレームの中身の作成

    def create_tree_frame(self, parent:ttk.Notebook, tab_name="") -> ttk.Treeview:
        """
        Treeviewとスクロールバーを持つframeを作成し、Notebookにaddする。
        frameは、Treeviewとスクロールバーをセットする
        Treeviewは、listview形式、行は縞模様
        Args:
            ttk.Notebook:   ttk.Notebook
            string:         tab_name
        Returns:
            Treeview:       ツリービュー
        """
        # tagを有効にするためstyleを更新 tkinter8.6?以降必要みたい
        # 表の文字色、背景色の設定に必要
        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))
        # タブごとのスタイルの設定
        self.style.configure(tab_name + ".Treeview")
        # frameの作成。frameにTreeviewとScrollbarを配置する
        frame1 = tk.Frame(parent, bg="cyan")
        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")
        treeview1["show"] = "headings"      # 表のデータ列だけを表示する指定
        treeview1.tag_configure("odd", background="ivory2")     # 奇数行の背景色を指定するtagを作成
        # 水平スクロールバーの作成
        h_scrollbar = tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)
        treeview1.configure(xscrollcommand=h_scrollbar.set)
        # 垂直スクロールバーの作成
        v_scrollbar = tk.Scrollbar(frame1, orient=tk.VERTICAL, command=treeview1.yview)
        treeview1.configure(yscrollcommand=v_scrollbar.set)
        # pack expandがある方を後にpackしないと他が見えなくなる
        h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)          # 先にパックしないと表示されない
        v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)           # 先にパックしないと表示されない
        treeview1.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        parent.add(frame1, text=tab_name)
        return treeview1

□タブごとのスタイル

読み込んだファイルごとに行の高さを一番高い行に合わせるために、タブ(実際には Treeview )ごとにスタイルを作成します。

タブの中には Treeview ウィジェットを作成します。
Treeview ウィジェットは行の高さをスタイルで指定 ( rowheight= オプション)します。
ウィジェットのスタイルは同じクラスのウィジェットで共通です。
読み込んだファイルごとに行の高さが異なる場合、Notebook のタブに作成する Treeview ごとにスタイルを変える必要があります。

すべてのウィジェットにはデフォルトのスタイルがあります。
デフォルトのスタイル名はウィジェット名の前に「T」が付きます。
ただし、Treeview の場合は例外で Treeview のままです。

デフォルトのスタイルを元に派生した別のスタイルを指定することができます。
その場合、スタイル名を「新しい名前.古い名前」とします。

あるスタイルに基づく新しいスタイルの作成方法は、

  • Styleのインスタンスを作成
  • 「新しい名前.古い名前」形式の名前を使用してconfigure()メソッドを呼び出す

これを利用してタブ(実際には Treeview )ごとに異なる Treeview のスタイルを作成します。
スタイルの新しい名前は、タブ名にします。
つまり、「タブ名.Treeview」で新しいスタイルを定義します。

【コード】

        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))
        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")


        # Treeviewの行の高さを変更 # タブごとのスタイルの設定
        self.style.configure(sheet_name1 + ".Treeview", rowheight = 18 * max_row_lines)
更新:2021-12-10

■表の実装(Treeviewウィジェットの使い方)

Treeview ウィジェットはツリー形式や表形式でデータを表示するウィジェットです。
Treeview ウィジェットtkinter.ttk パッケージに入っているので使用には import が必要です。

   import tkinter.ttk as ttk

慣例的に ttk と別名を付けるようです。

表形式で表示する時の基本的な使い方です。

  1. インスタンス作成
  2. 配置 (pack)
  3. 表示形式の指定 (treeview["show"])
  4. 列の定義 (treeview.column)
  5. 見出しの設定 (treeview.heading)
  6. データ挿入 (treeview.insert)

この章では次の内容について説明します。

この章での説明

□表(Listview)形式の指定

Treeview はツリー形式で表示する部分と、表形式で表示する部分を持っています。
両方、どちらか片方だけの表示を選択できます。
デフォルトは両方を表示するので表形式だけを指定します。
指定は、インスタンスの show オプションか、Treeview のコンストラクタの show= 引数で行います。

        self.tree["show"] = "headings"      # listview形式の指定
        # or
        ttk.Treeview(parent, show="headings")
オプション名 説明 設定値
show 表示対象を指定 ・"tree":ツリー表示部分の表示
・"headings":表形式部分の表示
・["tree","headings"]:すべて(デフォルト)

□1行おきに背景色を設定する

行修飾を行うにはタグ (tag) を使用します。
タグはタグ名で区別して属性の異なるタグを複数設定できます。
行に対しても複数のタグを設定できます。

タグで使用できる主な属性 (オプション)

オプション名 説明
background 背景色
foreground 前景色
font フォント

◎タグの使い方

タグ設定とデータ追加はどちらが先でも動作します。

  • treeviewにタグ (tag) を設定
    treeview.tab_configure(タグ名, オプション)
  • タグを指定してデータ追加
    データ追加時:treeview.insert(…, tags=タグ名)

◎背景色指定時のバグ

リンクのサイトにあるように Treeview の色付けにはバグがあるようです。
fixed_map メソッドを再定義して style の map メソッドを実行する必要があるようです。

【コード】

▼Treeview の色設定のバグフィクス

    def fixed_map(self, option):
        # Fix for setting text colour for Tkinter 8.6.9
        # From: https://core.tcl.tk/tk/info/509cafafae
        #
        # Returns the style map for 'option' with any styles starting with
        # ('!disabled', '!selected', ...) filtered out.

        # style.map() returns an empty list for missing options, so this
        # should be future-safe.
        return [elm for elm in self.style.map('Treeview', query_opt=option) if
            elm[:2] != ('!disabled', '!selected')]
        # tagを有効にするためstyleを更新 tkinter8.6?以降必要みたい
        # 表の文字色、背景色の設定に必要
        self.style = ttk.Style()
        self.style.map('Treeview', foreground=self.fixed_map('foreground')
                                 , background=self.fixed_map('background'))

▼タグの設定

        # Treeviewの作成
        treeview1 = ttk.Treeview(frame1, style=tab_name + ".Treeview")
        treeview1["show"] = "headings"      # 表のデータ列だけを表示する指定
        treeview1.tag_configure("odd", background="ivory2")     # 奇数行の背景色を指定するtagを作成

▼タグを指定してデータ追加

        # treeviewに要素追加。背景はtagを切り替えて設定
        tree.delete(*tree.get_children())   # Treeviewをクリア
        for i, row in enumerate(rows):
            tags1 = []              # tag設定値の初期化
            if i & 1:               # 奇数か? i % 2 == 1:
                tags1.append("odd") # 奇数番目(treeviewは0始まりなので偶数行)だけ背景色を変える(oddタグを設定)
            tree.insert("", tk.END, values=row, tags=tags1)     # Treeviewに1行分のデータを設定

□列の定義

Treeview で列を操作するには、列の定義が必要です。
これは見出し名とは別で列操作のための識別子(カラム識別子)です。
指定は、インスタンスの columns オプションか、Treeview のコンストラクタの colums= 引数で行います。

   treeview = Treeview(parent)
    treeview["columns"] = ["列1", "列2", "列3"]
    # or
    ttk.Treevew(parent, columns=["列1", "列2", "列3"])

実際のコードでは、CSV ファイルに列定義があるかどうかわからないので1で始まる整数を設定します。

【コード】

        # 列定義の作成(現状は1開始の整数)
        columns_ = [i for i in range(1, ws.max_column + 1)]    # 列定義を列数分行う。1スタート

        self.tree["columns"] = columns                  # treeviewの列定義を設定

□見出しの設定

見出しの設定は、heading メソッドで行います。

  • 【構文】 treeview.heading("列1", text="列名")

列定義で指定した名前で列を指定して1列ずつ設定します。

合せて列の幅も見出しの文字の長さで設定します。
Font クラスの measure メソッドで文字列からピクセル値を取得し、
column メソッドで width= 引数を指定して設定します。

【コード】

        font1 = tkFont.Font()
        for col_name in columns:
            self.tree.heading(col_name, text=col_name)  # 見出しの設定
            width1 = font1.measure(col_name) + 10       # 見出しの文字幅をピクセルで取得
            self.tree.column(col_name, width=width1)    # 見出し幅の設定

□データの挿入

データの挿入は、insert メソッドで行います。

  • 【構文】 treeview.insert(parent, index, iid=None, オプション)
  • よく使う使い方
    【構文】 treeview.insert("", tk.END, value=データ列の値 (リスト))
  • 引数
    • parent:挿入するデータの親。"" で最上位
    • index:挿入位置。tk.END で最後尾に追加
    • iidNone(省略も同じ)で iid の作成をお任せ
      "I001" から順に割り当てる
      ※iid はアイテムごとのユニークな識別子
  • オプション
    • values:カラム順のカラムデータ(リスト)
    • image:画像(PhotoImage)オブジェクトを指定
    • text:ツリーカラムのテキストを指定
    • tags:タグを指定
    • open:子アイテムの展開を指定
更新:2022-02-17

【コード】

    tree.insert("", tk.END, values=row, tags=tags1)     # Treeviewに1行分のデータを設定
追加:2021-12-10

□列の幅の自動調整

Treeview の列の幅は、列名で設定します。

  • 【構文】 treeview.column(列名, width=幅)

列の幅は設定する文字の長さを計測します。

   import tkinter.font as tkFont
    tkFont.Font().measure(文字列)  

【処理】

  1. 見出しの文字幅で設定

    1. 見出しの個数分、以下を繰り返す
      1. 見出しの文字列の幅を計算
      2. 列幅として設定
  2. 結果セットの文字幅で再設定

    1. 列の個数分、以下を繰り返す
      1. 同じ列のあるデータを文字に変換しその長さが最も長いデータを求める
        max([x[i] for x in rows], key=lambda x:len(str(x)))
        同じ列のデータをリストにして max 関数で長さを調べる
      2. 文字列データの場合、改行を含む場合に改行で区切った中で長さが最も長いデータを求める
        max(max_str.split("\n"), key=len)
      3. 求めたデータの文字幅を計算
        font1.measure(max_str)
      4. 現在の列幅の設定値を取得
        tree.column(tree['columns'][i], width=None)
        width=None で呼び出す
      5. 求めた文字幅が現在の列幅より大きい時は列幅を再設定
        tree.column(tree['columns'][i], width=width1)

【コード】

▼見出しに対して列の幅を設定

        font1 = tkFont.Font()
        for col_name in columns:
            self.tree.heading(col_name, text=col_name)  # 見出しの設定
            width1 = font1.measure(col_name) + 10       # 見出しの文字幅をピクセルで取得
            self.tree.column(col_name, width=width1)    # 見出し幅の設定

▼データに対して列の幅を設定

        font1 = tkFont.Font()
        # 要素の長さにより列幅を修正
        for i, _ in enumerate(rows[0]):     # 列数分回す(1行目の要素数分)
            # 同じ列のデータをリストにし列の値の長さを求め、最大となる列のデータを求める。
            # 値は数字もあるので文字に変換し長さを求める。また、Noneは'None'となるので'    'とする。
            max_str = max([x[i] for x in rows], key=lambda x:len(str(x))) or "    "
            # 求めたものが文字列だったら、改行された状態での最大となるデータを求める。
            # 厳密にはこの状態で最大となるデータを探さなければならないが割愛
            if type(max_str) is str:
                max_str = max(max_str.split("\n"), key=len)
            width1 = font1.measure(max_str) + 10   # 文字幅をピクセルで取得
            header1 = tree.column(tree['columns'][i], width=None) # 現在の幅を取得
            # 設定済みの列幅より列データの幅の方が大きいなら列幅を再設定
            if width1 > header1:
                tree.column(tree['columns'][i], width=width1)    # 見出し幅の再設定

□行の高さの自動調整

本来は、行ごとに高さの調整がしたかったのですが、そのやり方が分かりませんでした。
自分のなかでは、できないと判断しました。

仕方なく、ファイルに対しては同じ行の高さで、ただし、データが見えるように(行の高さを後から変更できないので)一番高い行に合わせて設定します。
ファイルによって一番高い行の高さは異なるので、ファイルごとに行の高さを変えます。
※ファイルごとに設定を変える方法は「タブごとのスタイル」を参照

考え方は簡単で、セルのデータが複数行の場合の行数を求めて、それを元に高さを設定します。

【処理】

  1. 列のデータを取得
    [s for s in itertools.chain.from_iterable( self.dict_tables.get( sheet_name1)[0]) if type(s) is str]
    辞書から値(rows, columnsのタプル)を取得、添え字0がrows
    rows は openpyxl で取得した rows をリストに変換したもの
    2次元のリストなので chain.from_iterable で平坦化してNoneを除いて取得

  2. 対象がなければ処理を抜ける

  3. 抽出したリストの要素の中で改行の数の最も多い要素を取得
    max(cells, key=lambda x:x.count("\n"))
    max 関数でリストの中から改行文字「\n」の数の最大値を取得
    max 関数で引数を伴うメソッドで値を判断する場合 lambda 式を使用

  4. Treeview クラスのスタイルを設定
    Treeview ごとのスタイルとして設定するため「ファイル名.Treeview」で設定
    Treeview の行の高さは rowheight に設定
    設定値は px 単位なので1行の高さを18pxとして計算

【コード】

            # 一番行数の多い行に合わせて高さを設定する
            # 2次元のデータを平坦化しstr型だけを抽出する
            cells = [s for s in itertools.chain.from_iterable(self.dict_tables.get(tab_name1)[0]) if type(s) is str]
            if not cells:
                continue    # 対象がない場合は抜ける
            # 抽出したリストの要素の中で改行の数の最も多い要素を取得
            longest_cell = max(cells, key=lambda x:x.count("\n"))
            max_row_lines = longest_cell.count("\n") + 1             # 改行の数を数える
            # Treeviewの行の高さを変更
            self.style.configure(tab_name1 + ".Treeview", rowheight = 18 * max_row_lines)

■スクロールバーの設置

スクロールバーはスクロールバーウィジェットを作成してスクロールさせたいウィジェットと関連付けします。

□コンストラク

  • 【構文】 tk.Scrollbar(親ウィジェット, オプション)
  • よく使うオプションを使った時の構文
    【構文】 tk.Scrollbar(parent, orient=tk.HORIZONTAL, command=対象ウィジェット.xview)
    【構文】 tk.Scrollbar(parent, orient=tk.VERTICAL, command=対象ウィジェット.yview)

  • 【主なオプション】

    オプション 説明 設定値
    orient スクロールの方向 HORIZONTAL:水平、VERTICAL:垂直
    command スクロール対象ウィジェットの移動メソッド メソッド名
    width スクロールバーの幅(水平の場合は高さ、垂直の場合は幅) デフォルトは16
更新:2022-03-06

□対象ウィジェットの割り付け

  • 【構文】 対象ウィジェット.configure(xscrollcommand=横Scrollbarウィジェット.set)
  • 【構文】 対象ウィジェット.configure(yscrollcommand=縦Scrollbarウィジェット.set)
更新:2022-03-06

【処理】

  1. インスタンス作成
    tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)

    • 第一引数で親ウィジェットを指定
    • orient で垂直 (VERTICAL) 、水平 (HORIZONTAL) を指定
    • command でウィジェットの方向を指定 ※垂直方向の場合は x が y
  2. スクロールバーを対象ウィジェットに関連付け
    treeview1.configure(xscrollcommand=h_scrollbar.set)

    • xscrollcommand は水平スクロール発生時の呼び出しメソッドを指定
      ここにスクロールバーの set メソッドを指定
      ※垂直方向の場合は x が y 、h が v

【コード】

        # 水平スクロールバーの作成
        h_scrollbar = tk.Scrollbar(frame1, orient=tk.HORIZONTAL, command=treeview1.xview)
        treeview1.configure(xscrollcommand=h_scrollbar.set)
        # 垂直スクロールバーの作成
        v_scrollbar = tk.Scrollbar(frame1, orient=tk.VERTICAL, command=treeview1.yview)
        treeview1.configure(yscrollcommand=v_scrollbar.set)
        # pack expandがある方を後にpackしないと他が見えなくなる
        h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)          # 先にパックしないと表示されない
        v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)           # 先にパックしないと表示されない

■ファイル選択ダイアログ

Tkinter にはファイル選択用のダイアログが用意されています。
tkinter.filedialog パッケージに入っているので使用には import が必要です。

   from tkinter import filedialog

ダイアログを開いて選択された複数ファイル名を取得するには askopenfilenames メソッドを使用します。

  • 【構文】 filedialog.askopenfilenames(オプション)

  • 【オプション】

    オプション 説明 設定値
    parent 指定したウィンドウの上にダイアログを表示 ウィジェット
    title ダイアログのタイトルを指定 文字列
    initialdir ダイアログを表示した時に開くディレクトリを指定
    initialfile ダイアログを表示した時に選択されているファイル名を指定
    filetypes 選択可能なファイルの種類を指定 (ラベル, パターン)のタプルのリスト(ワイルドカード可)
    multiple 複数ファイル選択の許可を指定 True:許可、False:許可しない

□filetypes

ファイルの種類の選択は、ワイルドカードが指定できますが、一般的なワイルドカードとは意味が異なるようです。
特別な指定として "*" があり、すべてのファイルが対象です。

  • 【構文】 [("表示名1", ".拡張子1 .拡張子2..."), ("表示名2", ".拡張子3 .拡張子4...")...]

    例:filetypes=[("xls", ".xls .xlsx"), ("csv", ".csv")]

【処理】

  1. オプションで開くファイルの拡張子を指定してファイルダイアログを開く
  2. 取得したファイル名をリストボックスウィジェットに対応付けている変数に設定
    パス名だと長くなるのでファイル名だけにする

【コード】

    def select_files(self, event=None):
        """
        ファイル選択ダイアログを表示。選択したファイルパスを保存
        """
        # 拡張子の辞書からfiletypes用のデータを作成
        # 辞書{".csv":("CSV", ","), ".tsv":("TSV", "\t")}、filetypes=[("CSV",".csv"), ("TSV",".tsv")]
        self.file_paths = filedialog.askopenfilenames(filetypes=[(value[0], key) for key, value in self.csv_op.extensions.items()])
        basenames = [os.path.basename(file_path) for file_path in self.file_paths]
        self.var_csv_path.set(basenames)
        self.open_csv()

CSV ファイルの操作

CSV ファイルを扱うには、標準の CSV モジュールを使用します。
使用には import が必要です。

   import csv

CSV ファイルを読み込んで使用する場合の基本は次の通りです。

  1. CSV ファイルをオープン
  2. CSV ファイルを読み込む( reader オブジェクト(反復可能)が返る)
  3. reader オブジェクトから行、列の値を取得

この章では次の内容について説明します。

この章での説明

CSV ファイルのオープン

  • 【構文】 open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)

  • 【主な引数】

    名前 説明 設定値
    file 開くパス or ファイルのようなオブジェクト 文字列 or バイナリーモードで開いたファイルライクなオブジェクト
    mode ファイルが開かれるモード 'r':読み込み用に開く (デフォルト)
    'w':書き込み用に開き、まずファイルを切り詰め
    'x':排他的な生成に開き、ファイルが存在する場合は失敗
    'a':書き込み用に開き、ファイルが存在する場合は末尾に追記
    newline ユニバーサル改行モード None, '', '\n', '\r', '\r\n' のいずれか
    ''を推奨
    encoding 文字コード 例)cp932, utf_8
    デフォルトはシステムのデフォルトエンコーディングユニコード文字列

CSV ファイルの読み込み

  • 【構文】 reader(csvfile, dialect='excel', **fmtparams)

    戻り値は、reader クラスのインスタンスです。

  • 【主な引数】

    名前 説明 設定値
    csvfile 開くパス or ファイルのようなオブジェクト 文字列 or バイナリーモードで開いたファイルライクなオブジェクト
    delimiter フィールド間を分割するのに用いられる 1 文字からなる文字列 デフォルトは ','
    quotechar フィールドをクオートする際に用いられる 1 文字からなる文字列 デフォルトは '"'
    quoting クオートがいつ reader によって認識されるかを制御 QUOTE_MINIMAL:特別な文字を含むフィールドだけをクオート(デフォルト)
    QUOTE_NONNUMERIC:クオートされていない全てのフィールドを float 型に変換
    QUOTE_NONE:クオート文字の特別扱いをしない
    skipinitialspace delimiter の直後に続く空白は無視するかどうか True:無視する、False:無視しない(デフォルト)

□本アプリでの CSV ファイル読み込み

エンコーディングと区切り文字は、画面の選択肢とファイルの拡張子から指定して、 CSV ファイルをオープンして読み込みをします。

はじめに拡張子、ファイルダイアログでの表示名、区切り文字を共通で扱いたいので辞書を用意しました。
キーには、拡張子を当て、拡張子の判定に利用します。
【辞書の構造】 {拡張子:(ファイルダイアログでの表示名, 区切り文字)}

   self.extensions = {".csv":("CSV", ","), ".tsv":("TSV", "\t"), ".txt":("Text", "\n")}

【処理】

  1. ファイルのパスから拡張子を取得
  2. 辞書から拡張子に対応した区切り文字を取得
  3. ファイルをオープン
    エンコーディングをコンボボックスで選択したものに設定
  4. ファイルの読み込み
    区切り文字を拡張子に合わせたものに設定
  5. 値をリスト化
  6. 全行で列数をそろえる
    • 全行の最大列数を求める
    • 不足している列を空文字で埋めてリストを再作成

【コード】

        basename = os.path.basename(file_name)
        # 拡張子によってcsvのdelimiterを変える(辞書から取得)
        delimiter_ = self.extensions.get(os.path.splitext(file_name)[1])[1]
        with open(file_name, encoding=encode_, newline="") as csvfile:
            spamreader = csv.reader(csvfile, delimiter=delimiter_)
            rows1 = [row for row in spamreader]
            # 最大列数を求める
            column_len = max(len(v) for v in rows1)
            # 列の不足を空文字で補完する
            rows1 = [x + [""] * (column_len - len(x)) for x in rows1]

■全体のソース

全体のソースはこちらから取得できます。
取得先:GitHub juu7g/Python-CSV-Viewer

■バイナリ(アプリ)

pyinstaller でバイナリ( exe ) ファイルを作成しました。

  • 作成コマンド: pyinstaller -F -w --additional-hooks-dir . ファイル名
    ※実行には hook-tkinterdnd2.py ファイルが必要です(作り方は「pyinstaller使用時の注意」を参照)

□バイナリ取得先

バイナリも公開します。
こちらから取得してください。Github からダウンロード

□使い方

  • インストール

    ダウンロードした zip ファイルを任意のフォルダで解凍します

  • 実行

  • 操作

  • 画面の説明

    • 指定したファイルの内容を表形式で表示します
    • 複数ファイルを指定した場合、タブを変えて表示します
  • アンインストール

    解凍したファイルをすべて削除します

□使用上の注意

制限事項があります。

■さいごに

ドラッグアンドドロップをやりたくて CSV Viewer を作ってみました。
少し調べると簡単そう(実際、とっかかりは簡単)なのですが、つまづくポイントもありました。

ドロップされたファイル情報の解析は、根気よくネット検索して何とか見つけることができました。
勉強という意味ではコードを書いて対応するのも良いのでしょうが、使えるものは使いたい派です。

同じように苦労したのが pyinstaller での exe 化です。
TkinterDnD2 のホームページに注意書きはあるのですが、良く理解できなくて。
英語だからではないような気もしました。

あわせて読みたい - Tkinter関連記事

□ご注意

本記事は次のバージョンの下で動作した内容を元に記述しています。

  • Python 3.8.5
  • TkinterDnD2 0.3.0

■参考

投稿: 、更新: