2023-2I プログラミング1 第26回 講義資料

2024年01月22日(月)1・2時限

1 シンボルの名前変更 (VSCode)

VSCodeには「シンボルの名前変更」というリファクタリングに便利な機能があります。これは シンボル (変数名や関数名など) を安全に一括して変更 できる機能になります。変数名や関数名の上でマウスを「右クリック」して呼び出すことができます (あるいは キーボードの F2 でも呼び出すことができます)。

img

通常のエディタの「置換」を使用すると意図せず プログラムが破壊されること があります (置換前には実行可能だったプログラムが、置換後に実行できなくなることなどがよくあります)。一方で、VSCodeの「シンボルの名前変更」の機能を使用すると単純な置換とは異なり、プログラムの構造を解析し、プログラムを破壊することなく適切な名前変更を行なってくれます。

例えば、以下のプログラムにおいて、第13行目 の 変数 ff1 という名前に変更したいとします。

def func_1(f):
  assert type(f) == int
  if f % 2 == 0:
    return f * 2
  else :
    return f ** 2

def func_2(fs):
  assert type(fs) == list
  for f in fs:
    print(f, end=' ')

f = 5 # この変数の名前を変更したい
f2 = func_1(f)
print(f'first step  -> func_1 result : {f2}')
print(f'second step -> func_2 result : ', end='')
func_2([1,2,3])

このとき「置換」の機能を使って変数の名前変更すると、次のように意図していない箇所、例えば fordefif などの 予約語func_1func_2 などの 関数名'first step ...'などの 文字列リテラル、コメントなども影響を受けてしまいます。

例えば、繰り返し構文のための forf1or になってしまいます (プログラムは実行不可能になります)。

def1 f1unc_1(f1):
  assert type(f1) == int
  if1 f1 % 2 == 0:
    return f1 * 2
  else :
    return f1 ** 2

def1 f1unc_2(f1s):
  assert type(f1s) == list
  f1or f1 in f1s:
    print(f1, end=' ')

f1 = 5
f12 = f1unc_1(f1)
print(f1'f1irst step  -> f1unc_1 result : {f12}')
print(f1'second step -> f1unc_2 result : ', end='')
f1unc_2([1,2,3])

一方で「シンボルの名前変更」を使用すると、プログラム全体として不整合が生じないように適切な範囲 (適切な変数のスコープ内) の変数や関数に対して安全に名前変更が可能となります。便利な機能なので、積極的に利用してください (特に PySide などのGUIアプリの開発では威力を発揮します)。

2 ウィンドウの初期位置

ここでは、PySideを利用したGUIアプリのウィンドウの初期位置を「スクリーン中央」に設定する方法について学びます。

2.1 準備

前回の講義において、ウィンドウの「初期位置」と「サイズ」は setGeometry メソッドに次のような4つの引数 (整数値) を与えることで設定しました。

self.setGeometry(100, 50, 640, 240)

これとは別に「QRectオブジェクト」を使って、次のようにウィンドウのジオメトリ (位置とサイズ) を設定することもできます。ウィンドウの初期位置を「スクリーン中央」に設定するためには、こちらの PySide6.QtCore.QRect を使う方法を利用します。

rect = Qc.QRect() # Rect: Rectangle (長方形・矩形)
rect.setSize(Qc.QSize(640,480))      # サイズ設定
rect.moveTopLeft(Qc.QPoint(100,50))  # 位置設定
self.setGeometry(rect) 

ここで、上記の 第03行目moveTopLeft メソッドの代わりに、moveBottomRight を使用すれば ウィンドウの「右下を基準に位置設定」をすること、つまり、ウィンドウの「右下 (BottomRight)」がスクリーン座標の (100,50) にくるようにウィンドウを配置できます。

また rect.moveCenter を使用すればウィンドウの「中央を基準に位置設定」をすることができます。

2.2 ウィンドウ初期位置をスクリーン中央に設定

ウィンドウの初期位置が「スクリーンの中央」となるようなプログラム (qt-06.py) を次に示します。ここ言う「スクリーン」とは PC画面 (デスクトップ画面) を指します。

プログラムを実際に実行して動作を確認してください。

import sys
import PySide6.QtWidgets as Qw
import PySide6.QtCore as Qc

class MainWindow(Qw.QMainWindow):
  
  def __init__(self, app:Qw.QApplication):

    super().__init__() 

    self.setWindowTitle('MainWindow') 

    # ウィンドウの位置をスクリーン中央に設定
    rect = Qc.QRect()
    rect.setSize(Qc.QSize(640,480))
    rect.moveCenter(app.primaryScreen().availableGeometry().center())
    self.setGeometry(rect) 

## main ##
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow(app) # appを引数に与えてインスタンスを生成
  main_window.show()
  sys.exit(app.exec())

ウィンドウをスクリーン中央に表示するためには スクリーン (PC画面) の Geometry (形状・ジオメトリ) に関する情報が必要となります。この情報は、第21行目 で生成される app (PySide6.QtWidgets.Applicationクラスのインスタンス) の .primaryScreen().availableGeometry() メソッドで取得ができます。

ただし、このスクリーン の Geometry が必要になるのは、MainWindowクラスの「コンストラクタ (__init__ 関数) のなか」であるため、第07行目 のコンストラクタの定義開始部 (シグネチャ) で、Qw.Application を引数として受け取れるようにしています。そして、第22行目 では、main_window = MainWindow(app) のようにコンストラクタの引数に app を渡しています。

なお、コンストラクタの 定義開始部 (シグネチャ)

def __init__(self, app):

ではなく

def __init__(self, app:Qw.QApplication):

としていることには理由があり、それについては次に説明します。

2.3 型ヒント

Python開発環境が適切に設定された VSCode では Pylance という拡張機能によって、コーディングの際に「入力補完」や「エラーチェック」が機能します。

Pylance による開発支援機能は、バックグラウンドでプログラムコードをリアルタイムに解析して動作します。この際、Pylance の内部では「型の推論 (Type Inference) 」が行われます。例えば、次のようなプログラムを書いている途中、つまり、第02行目. までコードを打ち込んだとき・・・

x = 10
x.

Pylance は 第01行目x=10 から、変数 x の 型 (Type) は「整数値である」と推論し、次の図に示すように整数値オブジェクトが持っている プロパティ (属性)メソッド を入力候補として表示してくれます。

img

一方で、変数 x が「文字列である」と推論できるときは、文字列オブジェクトとして適切な入力候補を表示してくれます (文字列オブジェクトが持っている プロパティ (属性)メソッド を入力候補として表示してくれます)。

x = '10'
x.
img

上図において、入力候補として表示されるメソッドが 先の例とは違うことに注目してください。例えば startsWith メソッドは「その文字列が引数で指定した文字から開始するか」をブール値 (True/False) で返すもので、これは 整数値オブジェクトには存在しないメソッドです。

以上のように、Pylance の型推論は非常に強力で優秀ですが、関数の引数として値を受け取る変数など に対しては十分な推論ができず、結果として「入力補完」や「エラーチェック」などの機能が提供されません。

例えば、次のプログラムでは 第02行目 において xx. まで文字を打ち込んでも入力候補は表示されません (実際に試して確認してください)。これは、Pylance が、変数 xx の「型の推論」に失敗しているためです。関数 func は、第05行目 以外にも、プログラム内の別のところから、別の引数を伴って呼び出される可能性があり、仮引数 xx の型を一意に決定 (推論) できないためです。

def func(xx):
  xx.

x = '10'
func(x)
img

このような状況において、Python 3.5 以降では、型ヒント という機能を用いて 型を推論するための補助情報 をプログラマから提供することができます。具体的には、関数のシグネチャ(定義の開始部分)において、引数の名前の後にコロン : に続けて変数の型を指定します。例えば func(xx:int):func(xx:str): のように記述することができます。これにより、関数の引数に期待される型が明確になり、Pylance の「入力補完」や「エラーチェック」が機能するようになります。また、コードの可読性と保守性も向上し、チーム開発においても非常に役立ちます。

実際に、先のプログラムを以下のように書き替えると、xx. まで文字を打ち込んだ段階で入力候補が表示されるようになります。

def func(xx:str): # xxは文字列型であるという「型ヒント」つき
  xx.

x = '10'
func(x)
img

また、関数のシグネチャに型ヒントを与えることで、関数を呼び出す側において、不適切な引数を与えたときに警告が表示されるようになります。

例えば、以下の 第01行目 では、関数 func が文字列型の引数 xx を受け取るように型ヒントが設定されています。しかし、第05行目 で整数値を持つ変数 x が引数として渡されているため、Pylance から警告 (PylancereportGeneralTypeIssues) が通知されます。

def func(xx:str):
  return f'「{xx}」'

x = 10
y = func(x)
img

型 “Literal[10]” の引数を、関数 “func” の型 “str” のパラメーター “xx” に割り当てることはできません “Literal[10]” は “str” と互換性がありません PylancereportGeneralTypeIssues

さらに、型ヒントは関数の 戻り値 に対しても設定可能です。例えば、第1引数が「数値型」、第2引数が「辞書型もしくは None」、第3引数が「ブール型」で、戻り値が「リスト型」を期待する関数のシグネチャは次のように記述できます。

def func(a1: float, a2: Optional[Dict], a3: bool) -> List:

なお、型ヒント付きの関数のシグネチャの設定 (記述)は初心者には、やや難しくなります。そのため ChatGPT などを使用することを推奨します。例えば「Pythonにおいて、文字列リストを引数として受け取り、それぞれの文字列の長さのリストを戻り値とする関数の「型ヒント」は、どのように記述すればよいですか?」のように質問することで、適切なシグネチャを得ることができます。

3 メッセージボックス (メッセージダイアログ)

PySide では、各種メッセージボックス (「メッセージダイアログ」とも言います) を表示してユーザに「情報を通知」したり、「意思確認をすること」ができます。

img

通常、メッセージボックスは「モーダルウィンドウ」として表示されます。モーダルウィンドウとは、そのウィンドウでの操作を完了するまでは 他の操作を行うことができないタイプの画面 です。また、PCの設定によってはメッセージボックスの表示に際して「通知音」が鳴ることもあるため、適切に使用しないと UX (ユーザーエクスペリエンス) が大きく損なわれることになります (いわゆる「いちいち鬱陶しいプログラム」になってしまいます)。十分に注意してください。

以下、PySideで「メッセージボックス」を使用する方法について説明していきます。

3.1 メッセージボックスの表示

プロジェクトフォルダに qt-07.py というファイルを新規作成し、次のプログラムを貼り付けて実行し、その動作を確認してください。

import sys
import PySide6.QtWidgets as Qw

# エイリアス (省略名) の設定
btn_type = Qw.QMessageBox.StandardButton

# MainWindow の 定義
class MainWindow(Qw.QMainWindow):
  
  def __init__(self):
    super().__init__() 
    self.setWindowTitle('メッセージボックスの利用')     
    self.setGeometry(100, 50, 640, 100) 

    #「Show MessageBox 1」ボタンの生成と設定
    self.btn_1 = Qw.QPushButton('Show MessageBox 1',self)
    self.btn_1.setGeometry(10,10,150,25)
    self.btn_1.clicked.connect(self.btn_1_clicked)

    #「Show MessageBox 2」ボタンの生成と設定
    self.btn_2 = Qw.QPushButton('Show MessageBox 2',self)
    self.btn_2.setGeometry(170,10,150,25)
    self.btn_2.clicked.connect(self.btn_2_clicked)
    
    # 結果表示のためのラベル
    self.lb_msg = Qw.QLabel('ボタンを押下してください。',self)
    self.lb_msg.setGeometry(15,35,500,30)

  # MessageBoxの表示関数1 (簡易版・静的メソッドの利用)
  def btn_1_clicked(self):

    msgbox_title = 'メッセージボックスのタイトル'
    msgbox_text = 'ダイアログに表示するメッセージは\n内部で改行することもできます。\n'
    msgbox_text += '[ESC]キーを押すと [Cancel] 押下と同等の挙動をします。'
    
    # メッセージボックスの生成、表示、応答(戻値)の取得 
    ret = Qw.QMessageBox.question(
            self,          # 親ウィンドウ
            msgbox_title,  # タイトル
            msgbox_text,   # メッセージ本体
            btn_type.Yes | btn_type.No | btn_type.Cancel, # 表示ボタン
            btn_type.No    # デフォルトボタン
          )
    
    # 戻値に応じた処理
    if ret == btn_type.Yes:
      msg = '[Yes] ボタン が押下されました。'
    elif ret == btn_type.No : 
      msg = '[No] ボタン が押下されました。'
    elif ret == btn_type.Cancel :
      msg = '[Cancel] ボタン または [X] ボタン が押下されました。'
    else :
      msg = '予期せぬ応答が返ってきました。'
    self.lb_msg.setText(msg)

  # MessageBoxの表示関数2 (メッセージボックスの「インスタンス」を生成する方法)
  def btn_2_clicked(self):

    # インスタンスの生成と設定
    msgBox = Qw.QMessageBox(self) # インスタンスの生成
    msgBox.setWindowTitle('メッセージボックスのタイトル')
    msgBox.setText('ダイアログに表示するメッセージは\n内部で改行することもできます。')
    msgBox.setIcon(Qw.QMessageBox.Icon.Warning)
    msgBox.setStandardButtons(btn_type.Yes | btn_type.No)

    # 表示と応答の取得・処理
    ret = msgBox.exec() # 表示と応答の取得
    if ret == btn_type.Yes:
      msg = '[Yes] ボタン が押下されました。'
    elif ret == btn_type.No:
      msg = '[No] ボタン もしくは [X] ボタンが押下されました。'
    else :
      msg = '予期せぬ応答が返ってきました。'
    self.lb_msg.setText(msg)

## main ##
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

このプログラムは、ボタンを押下するとメッセージボックスが表示され、その結果をラベル (QLabel) に表示します。メッセージボックスを表示すると、それが閉じられるまで親ウィンドが操作できないモーダルウィンドウとして機能します。

なお、ラベル (QLabel) は、テキストボックス (QTextEdit や QLineEdit) とは異なり、ユーザーがマウスで範囲選択すること はできません。

img

PySideでメッセージボックスを表示するときは「メッセージボックスのインスタンスを生成せずに静的メソッドを使用する方法」と「メッセージボックスのインスタンスを生成して使用する方法」の2つがあります。

3.2 静的メソッドを使用する方法

静的メソッドとは、オブジェクト (インスタンス) を生成せずに利用可能なメソッドです。PySide6.QtWidgetsw.QMessageBox クラスの静的メソッド question を使用したメッセージボックスの表示と応答処理は 第32行目 から 第54行目 までの範囲になります。

msgbox_title = 'メッセージボックスのタイトル'
msgbox_text = 'ダイアログに表示するメッセージは\n内部で改行することもできます。\n'
msgbox_text += '[ESC]キーを押すと [Cancel] 押下と同等の挙動をします。'

# メッセージボックスの生成、表示、応答(戻値)の取得 
ret = Qw.QMessageBox.question(
        self,          # 親ウィンドウ
        msgbox_title,  # タイトル
        msgbox_text,   # メッセージ本体
        btn_type.Yes | btn_type.No | btn_type.Cancel, # 表示ボタン
        btn_type.No    # デフォルトボタン
      )

# 戻値に応じた処理
if ret == btn_type.Yes:
  msg = '[Yes] ボタン が押下されました。'
elif ret == btn_type.No : 
  msg = '[No] ボタン が押下されました。'
elif ret == btn_type.Cancel :
  msg = '[Cancel] ボタン または [X] ボタン が押下されました。'
else :
  msg = '予期せぬ応答が返ってきました。'
self.lb_msg.setText(msg)

処理のメインとなる部分は 第37行目Qw.QMessageBox.question であり、この静的メソッドに次の引数を与えることで、メッセージボックスを表示し、それに対するユーザーの応答を変数 ret に得ることができます。retr は、慣例的に戻り値 (Retuen Value) の省略形としてよく良く使用される変数名です。

img

ret = Qw.QMessageBox.question(...) を実行するとメッセージボックスが表示され、その応答が戻り値となり、変数 ret に格納されます。なお、タイトルバーの ボタンを押下したときの戻り値は、第4引数に [Cancel] ボタンを含んでいるときは、[Cancel] ボタンの押下と同じ になります。また、第4引数に [Cancel] ボタンを含まず [No] ボタンを含むときは、 ボタンの押下は [No] ボタンと同じになります (ややこしいです)。その他については、実際に確認してください。

なお、QMessageBox.question 以外に informationwarningcritical などのメソッドがあり、ダイアログの内部に表示されるアイコンが変化します。

img

3.3 インスタンスを生成して使用する方法

メッセージボックスのインスタンスを生成して使用する場合の処理は 第59行目 から 第74行目 までの範囲になります。こちらの方法のほうが、より細かな設定をすることができます。

# インスタンスの生成と設定
msgBox = Qw.QMessageBox(self) # インスタンスの生成
msgBox.setWindowTitle('メッセージボックスのタイトル')
msgBox.setText('ダイアログに表示するメッセージは\n内部で改行することもできます。')
msgBox.setIcon(Qw.QMessageBox.Icon.Warning)
msgBox.setStandardButtons(btn_type.Yes | btn_type.No)

# 表示と応答の取得・処理
ret = msgBox.exec() # 表示と応答の取得
if ret == btn_type.Yes:
  msg = '[Yes] ボタン が押下されました。'
elif ret == btn_type.No:
  msg = '[No] ボタン もしくは [X] ボタンが押下されました。'
else :
  msg = '予期せぬ応答が返ってきました。'
self.lb_msg.setText(msg)

第60行目 で QMessageBox のインスタンスを生成して、変数 msgBox に格納しています。メッセージボックスのタイトルや表示するボタンなどの設定は、.setXxxxx などのメソッドを通して行います。詳細については公式リファレンスを参照してください。

4 ファイル関連ダイアログ

4.1 ファイルオープンダイアログ

ファイルオープンダイアログ」は、読み込み対象の「ファイル選択」に使用する汎用ダイアログになります。サンプルプログラムを次に示します。プロジェクトフォルダに qt-08-open.py というファイルを作成して、プログラムを貼り付けて実行し、その動作を確認してください。このプログラムは ファイルオープンダイアログを使ってファイルを選択し、その内容をテキストボックスに表示 するものです。

img

撤退しなければならかった

なお、第61行目から第67行目にかけての「テキストファイルの読み込み (テキストモードでのファイルの読み込み)」については第18回講義で学習済みです。このプログラムでは 文字コードが「UTF-8」以外の場合は読み込みに失敗する ので注意してください。

import os
import sys
import PySide6.QtWidgets as Qw

# PySide6.QtWidgets.MainWindow を継承した MainWindow クラスの定義
class MainWindow(Qw.QMainWindow):
  
  # コンストラクタ(初期化)
  def __init__(self):

    # 親クラスのコンストラクタの呼び出し
    super().__init__() 

    # ウィンドウタイトル設定
    self.setWindowTitle('MainWindow') 

    # ウィンドウのサイズ(640x240)と位置(X=100,Y=50)の設定
    self.setGeometry(100, 50, 640, 240) 

    #「Open」ボタンの生成と設定
    self.btn_open = Qw.QPushButton('Open',self)
    self.btn_open.setGeometry(10,10,100,25)
    self.btn_open.clicked.connect(self.btn_open_clicked)

    # ナビゲーション情報を表示するラベル
    self.init_navi_msg = \
      '[Open] ボタンを押下して ファイル ( *.txt または *.csv ) を選択してください。'
    self.lb_navi = Qw.QLabel(self.init_navi_msg,self)
    self.lb_navi.setGeometry(15,35,620,30)

    # テキストボックス
    self.tb_viwer = Qw.QTextEdit('',self)
    self.tb_viwer.setGeometry(10,65,620,160)
    self.tb_viwer.setPlaceholderText('(読み込んだファイルの内容が表示されます)')
    self.tb_viwer.setReadOnly(True)

  #「Open」ボタンの押下処理
  def btn_open_clicked(self):
    title = 'テキストファイル または CSVファイル を開く' 
    init_path = os.path.expanduser('~/Desktop')
    filter = 'Text / CSV file (*.txt *.csv)'
    path = Qw.QFileDialog.getOpenFileName(
            self,      # 親ウィンドウ
            title,     # ダイアログタイトル
            init_path, # 初期位置(フォルダパス)
            filter)    # 拡張子によるフィルタ
    print(f'path => "{path}"')  # 確認

    if path[0] == '' :
      print('ファイル選択がキャンセルされました。')
      self.lb_navi.setText(self.init_navi_msg)
      return

    ret = self.read_text(path[0])
    self.tb_viwer.setPlainText(ret)
    self.lb_navi.setText(f"ファイル '{path[0]}' を読み込みました。")

    if len(ret) == 0 :
      self.tb_viwer.setPlaceholderText('(空ファイルです)')

  # テキストモードでファイルの読込み
  def read_text(self,path):
    # 念のためファイルの存在をチェック
    assert os.path.isfile(path) == True 
    with open(path, encoding='utf_8') as file:
      text = file.read()
    return text

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

第42行目QFileDialog.getOpenFileName (QFileDialogクラスの静的メソッド) が ファイルオープンダイアログを表示し、ユーザーによって選択されたファイルパスを返す関数 になります。各引数は次のようになります。

img

QFileDialog.getOpenFileName の戻り値は、要素数が「2」のタプルとなります。0番目の要素には ファイルパス、1番目の要素には ファイルタイプ (フィルタ文字列で与えた文字列) が格納されます。第47行目において、内容をコンソールに出力しているので確認してみてください。なお、ダイアログの [キャンセル] ボタンが押下されたり、タイトルバーの ボタンが押下された場合は、2要素とも空文字列のタプル ('','') が戻値になります。

4.1.1 複数ファイルの選択

複数のファイルが選択可能な QFileDialog.getOpenFileNames も存在します (メソッド名の末尾が複数形 (Names) になっている点に注意してください)。

こちらについての詳細は、各自でウェブ検索してください。

img

演習1 ファイルオープンダイアログがオープンしたときの初期位置が「ドキュメント」となるようにプログラムを書き替えよ。

演習2 フィルタ文字列を Text file (*.txt);;CSV file (*.csv) に書き換えて、その動作を確認せよ。

演習3 (Ex) 複数のファイルが選択可能な QFileDialog.getOpenFileNames の使い方について調べて、実際に動作を確認せよ。

(Ex) の演習は、授業時間外に取り組んでください。

4.2 ファイルセーブダイアログ

ファイルセーブダイアログ」は、ファイルの保存先の設定に使用する汎用ダイアログになります。ファイルオープンダイアログとの違いは、ダイアログ内のボタンが [開く] ではなく [保存] である点、さらに ファイルの上書きに関する確認など の機能が提供される点となります。

img

プロジェクトフォルダに qt-08-save.py というファイルを作成して、プログラムを貼り付けて実行し、その動作を確認してください。このプログラムは 2024年2月のカレンダを生成して、ファイルセーブダイアログにより保存先を選択し、テキストファイルとして出力 するものです。

img

なお、第75行目 から 第78行目 にかけての「テキストファイルの書き込み」については第18回講義で学習済みです。

import os
import sys
import PySide6.QtWidgets as Qw
import datetime as dt
import locale

# ロケールの設定
locale.setlocale(locale.LC_TIME, 'Japanese_Japan') # Windows環境

# 指定した月のカレンダを文字を得る関数
def get_month_calendar(year:int, month:int)->str:

  dt_start = dt.datetime(year,month,1)
  dt_end = dt.datetime(year,month+1,1) - dt.timedelta(days=1)
  
  calendar = ''
  d = dt_start
  while d <= dt_end:
    calendar += d.strftime('%m月%d日(%a) \n')
    d += dt.timedelta(days=1)

  return calendar.strip() # stripメソッドで末尾の改行を削除

# PySide6.QtWidgets.MainWindow を継承した MainWindow クラスの定義
class MainWindow(Qw.QMainWindow):
  
  # コンストラクタ(初期化)
  def __init__(self):

    # 親クラスのコンストラクタの呼び出し
    super().__init__() 

    # ウィンドウタイトル設定
    self.setWindowTitle('MainWindow') 

    # ウィンドウのサイズ(640x240)と位置(X=100,Y=50)の設定
    self.setGeometry(100, 50, 640, 240) 

    #「Save」ボタンの生成と設定
    self.btn_open = Qw.QPushButton('Save',self)
    self.btn_open.setGeometry(10,10,100,25)
    self.btn_open.clicked.connect(self.btn_open_clicked)

    # ナビゲーション情報を表示するラベル
    self.init_navi_msg = \
      '[Save] ボタンを押下して保存先を選択してください。'
    self.lb_navi = Qw.QLabel(self.init_navi_msg,self)
    self.lb_navi.setGeometry(15,35,620,30)

    # テキストボックス
    self.tb_calendar = Qw.QTextEdit('',self)
    self.tb_calendar.setGeometry(10,65,620,160)
    self.tb_calendar.setReadOnly(True)
    self.tb_calendar.setPlainText(get_month_calendar(2024,2))

  #「Save」ボタンの押下処理
  def btn_open_clicked(self):
    title = 'カレンダー (テキストファイル) の保存' 
    default_path = os.path.expanduser('~/Desktop/カレンダー.txt')
    filter = 'Text file (*.txt)'
    path = Qw.QFileDialog.getSaveFileName(
            self,         # 親ウィンドウ
            title,        # ダイアログタイトル
            default_path, # デフォルトファイル名
            filter)       # 拡張子によるフィルタ
    print(f'path => "{path}"')  # 確認

    if path[0] == '' :
      self.lb_navi.setText('保存をキャンセルしました。')
      return

    self.save_text(path[0],self.tb_calendar.toPlainText())
    self.lb_navi.setText(f"カレンダを '{path[0]}' に保存しました。")

  # テキストモードでファイルの書き込み
  def save_text(self,path,text):
    with open(path, mode='w', encoding='utf_8') as file:
      text = file.write(text)

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

ファイルセーブダイアログを表示するためには、第61行目 に示すように QFileDialog.getSaveFileName を使用します。引数や戻り値などは、基本的には getOpenFileName メソッドとほぼ同じとなります。ただし、第3引数だけは少し意味合いが異なり「保存ファイルのデフォルトのファイル名」を設定します。

先に説明したように、既存のファイルを選ぶと「確認ダイアログ」を自動表示する機能を持っています。

img

4.2.1 カレンダ文字列の作成

第07行目 から 第22行目 では、引数で指定した月のカレンダ文字列を得る関数を定義しています。ここで使用しているdatetime の基礎についは前回講義で学習済みです。

# ロケールの設定
locale.setlocale(locale.LC_TIME, 'Japanese_Japan') # Windows環境

# 指定した月のカレンダを文字を得る関数
def get_month_calendar(year:int, month:int)->str:

  dt_start = dt.datetime(year,month,1)
  dt_end = dt.datetime(year,month+1,1) - dt.timedelta(days=1)
  
  calendar = ''
  d = dt_start
  while d <= dt_end:
    calendar += d.strftime('%m月%d日(%a) \n')
    d += dt.timedelta(days=1)

  return calendar.strip() # stripメソッドで末尾の改行を削除

ここでは、曜日文字列を日本語にするために 第08行目 で「ロケール (Locale)」の設定をしています。ロケールとは、特定の地域や言語に適したソフトウェアの動作や表示を定義するための設定 であり、具体的には「言語」「文字コード」「日付と時間のフォーマット (書式)」「数値のフォーマット(書式)」、「通貨のフォーマット(書式)」などの地域や文化に依存する様々な要素の設定を含みます。

ロケールを設定するための文字列 (ここでは 'Japanese_Japan') は、OS によって異なるので注意してください。Mac OS 環境では ja_JP'、Linux環境では ja_JP.UTF-8 のように設定するそうです (未検証)。Mac OS および Linux で利用可能なロケールの一覧は locale -a コマンドで確認することができます。


演習1: 第08行目 をコメントアウトすると (ロケール設定を無効化すると) どうなるか確認せよ。

演習2 (Ex) このプログラムは「2024年2月」のカレンダを固定的に出力するものとなっている。これを 、任意の年月のカレンダが出力できるプログラムに拡張せよ (後述の各種ウィジェットを利用すること)。

(Ex) の演習は、授業時間外に取り組んでください。

5 ラジオボタン

ラジオボタン (Radio Button) 」は、複数の選択肢から「1個だけ」を選択させたいときに使用するウィジェットとなります。やや難しい言葉で表現すると 相互排他性 を持ったウィジェットと言えますす。

個々のラジオボタンは「PySide6.QtWidgets.QRadioButtonクラス」から生成しますが、それに加えて、相互排他性を持たせるために 論理的なグループ を構成する必要があります。つまり、そのグループに属するラジオボタンは「同時に1個だけ」しか選択できないような仕組みを与える必要があります。これには「PySide6.QButtonGroupクラス」を使用します。

5.1 ラジオボタンのサンプルプログラム1

ラジオボタンのサンプルプログラムを次に示します。プロジェクトフォルダに qt-09-radio-button-1.py というファイルを作成して、プログラムを貼り付けて実行し、その動作を確認してください。

img

このプログラムでは、初期値としてゼロオリジンで2番目の「良」にチェックが付けられています。他の評語 (例えば「秀」) をクリックすると「良」からチェックが外れ、その評価にチェックが付きます (相互排他性が実現されています)。

import sys
import PySide6.QtWidgets as Qw

class MainWindow(Qw.QMainWindow):
  
  def __init__(self):

    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 100) 

    # ラジオボタンを格納する論理的なグループ (論理的なコンテナ)
    self.rb_group = Qw.QButtonGroup(self)
    
    x, dx = 15, 45
    rb1 = Qw.QRadioButton(self)
    rb1.setText('秀')
    rb1.setGeometry(x, 10, 50, 25) 
    self.rb_group.addButton(rb1, 1) # 第2引数は任意ID(整数)

    x += dx
    rb2 = Qw.QRadioButton(self)
    rb2.setText('優')
    rb2.setGeometry(x, 10, 50, 25) 
    self.rb_group.addButton(rb2, 2)

    x += dx
    rb3 = Qw.QRadioButton(self)
    rb3.setText('良')
    rb3.setGeometry(x, 10, 50, 25) 
    self.rb_group.addButton(rb3, 3)

    x += dx
    rb4 = Qw.QRadioButton(self)
    rb4.setText('可')
    rb4.setGeometry(x, 10, 50, 25) 
    self.rb_group.addButton(rb4, 4)

    x += dx
    rb5 = Qw.QRadioButton(self)
    rb5.setText('不可')
    rb5.setGeometry(x, 10, 50, 25) 
    self.rb_group.addButton(rb5, 5)

    # ラベル
    self.lb_navi = Qw.QLabel('',self)
    self.lb_navi.setGeometry(15,35,620,30)

    # イベント処理
    self.rb_group.buttonClicked.connect(self.rb_state_changed)

    # 初期値設定
    rb3.setChecked(True)    #「良」を初期選択
    # self.rb_group.button(3).setChecked(True) # こちらの方法でもOK
    self.rb_state_changed() # プログラムでチェックを変更したときは明示的に実行

  def rb_state_changed(self):
    checked_rb_id = self.rb_group.checkedId()
    if checked_rb_id == 1:
      self.lb_navi.setText('評点 : 90点以上')
    elif checked_rb_id == 2:
      self.lb_navi.setText('評点 : 80点以上90点未満')
    elif checked_rb_id == 3:
      self.lb_navi.setText('評点 : 70点以上80点未満')
    elif checked_rb_id == 4:
      self.lb_navi.setText('評点 : 60点以上70点未満')
    elif checked_rb_id == 5:
      self.lb_navi.setText('評点 : 60点未満 (不合格)')
    else :
      self.lb_navi.setText('想定外の処理がされました。')

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

「秀」というラベルを持ったラジオボタンの生成とジオメトリの設定は 第16行目 から 第18行目 で行っています。以下、同様に「優」「良」「可」「不可」のラジオボタンの生成と設定を行なっています。

相互排他性を持たせるための論理的なグループについては 第13行目 で作成して、その後、第19行目第25行目第31行目第37行目第47行目self.rb_group.addButton(rb0, 0) のようにして論理グループの要素として各ラジオボタン ( rb1 から rb4 ) を追加しています。addButton メソッドの 第1引数 には「グループに追加するラジオボタンのインスタンス」を与え、第2引数 には「そのラジオボタンを識別するためのID」を与えます。ID には重複しない 1 以上の任意の整数値を使用します。

また、ラジオボタンのチェックが変わったとき (=クリックされたとき) に呼び出す関数 (コールバック関数) は、各ラジオボタンで設定するのではなく、第50行目 のように論理的なグループに対して設定します。コールバック関数のなかで どのラジオボタンボタンがチェックされたのかの情報 は、第58行目 のように論理グループの checkedId() を利用して判定します。このメソッドは、addButton の第2引数で与えた ID (整数値) を返します。

なお、ラジオボタンの初期値 (初期状態で、どのラジオボタンにチェックを入れるか) は、第53行目 で設定しています。第50行目self.rb_group.buttonClicked.connect(self.rb_state_changed) であることから分かるように、関数 rb_state_changed が自動的に呼び出されるのは、あくまで GUI を通して buttonClicked されたときです。そのため、プログラムからチェックを変更したときは、第50行目 のように 関数 rb_state_changed を明示的に呼び出す必要があります。


演習1 : 第55行目 をコメントアウトするとどうなるか、実際に確認せよ。

演習2 : addButton メソッドで、重複した ID を設定するとどうなるか、実際に確認せよ。例えば、第25行目addButton メソッドの 第2引数1 に設定するとどうなるか確認せよ。

5.2 ラジオボタンのサンプルプログラム2

先に示した qt-09-radio-button-1.py は、プログラムの内部で「データ」と「ロジック」が入り乱れており、非常に保守性や拡張性が悪くなっています。これを修正し、さらにレスポンシブデザインにしたプログラムを次に示します。実際に実行して、先ほどと同じ動作をすることを確認してください。

import sys
import PySide6.QtCore as Qc
import PySide6.QtWidgets as Qw

# クラスの定義
class Grade():
  def __init__(self,label:str,msg:str):
    self.label = label
    self.msg = msg

grades = [Grade('秀','90点以上'),
          Grade('優','80点以上90点未満'),
          Grade('良','70点以上80点未満'),
          Grade('可','60点以上70点未満'),
          Grade('不可','60点未満 (不合格)')]

class MainWindow(Qw.QMainWindow):
  
  def __init__(self):

    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 100) 

    # メインレイアウトの設定
    central_widget = Qw.QWidget(self)
    self.setCentralWidget(central_widget)
    main_layout = Qw.QVBoxLayout(central_widget) # 要素を垂直配置
    main_layout.setAlignment(Qc.Qt.AlignmentFlag.AlignTop) # 上寄せ
    main_layout.setContentsMargins(15,10,10,10)

    # ラジオボタンを格納する画面表示上のコンテナ
    rb_layout = Qw.QHBoxLayout() # 要素を水平配置
    rb_layout.setAlignment(Qc.Qt.AlignmentFlag.AlignLeft) # 左寄せ
    main_layout.addLayout(rb_layout)

    # ラジオボタンを格納する論理的なグループ (論理的なコンテナ)
    self.rb_group = Qw.QButtonGroup(self) 

    # ラジオボタンの生成と設定
    for i, grade in enumerate(grades,1):
      rb = Qw.QRadioButton(self)
      rb.setText(grade.label)
      rb.setFixedSize(50,25)
      rb_layout.addWidget(rb)
      self.rb_group.addButton(rb,i)

    # ラベル
    self.lb_navi = Qw.QLabel('',self)
    main_layout.addWidget(self.lb_navi)

    # イベント処理
    self.rb_group.buttonClicked.connect(self.rb_state_changed)

    # 初期値設定
    self.rb_group.button(3).setChecked(True)
    self.rb_state_changed()

  def rb_state_changed(self):
    rb_id = self.rb_group.checkedId() - 1
    if 0 <= rb_id < len(grades) :
      self.lb_navi.setText(f'評点 : {grades[rb_id].msg}')
    else:
      self.lb_navi.setText('想定外の処理がされました。')

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

このプログラムでは、データが 第13行目 から 第17行目 に集約されており、成績評価に関する変更があったときにも、わずかな修正で対応することができます。

演習1 : 2021年度以前のカリキュラムにおいては「優 : 80点以上」「良:65点以上80点未満」「可:60点以上65点未満」「不可:60点未満 (不合格)」であった。これに対応するように qt-09-radio-button-2.py を書き替えよ。

演習2 (Ex) : プログラム qt-09-radio-button-2.py を読解せよ。

6 チェックボックス

チェックボックスは、ラジオボタンと違い相互排他性のないウィジェットとなります。基本的には「チェック済み」と「未チェック」の2値を扱いますが、ウィジェットとしては次の3つの状態を持てることに注意してください。

img

6.1 チェックボックスのサンプルプログラム1

チェックボックスのサンプルプログラムを次に示します。

img

プロジェクトフォルダに qt-10-check-box-1.py というファイルを作成して、プログラムを貼り付けて実行し、その動作を確してください。

import sys
import PySide6.QtCore as Qc
import PySide6.QtWidgets as Qw

class MainWindow(Qw.QMainWindow):
  
  def __init__(self):

    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 100) 

    self.cb = Qw.QCheckBox(self)
    self.cb.setText(f'利用規約に同意します。')
    self.cb.setGeometry(15,10,150,25)
    self.cb.setCheckState(Qc.Qt.CheckState.Unchecked)
    self.cb.stateChanged.connect(self.cb_state_changed)

    # ボタン
    self.btn = Qw.QPushButton('インストール',self)
    self.btn.setGeometry(15,40,100,25)

    # ラベル
    self.lb = Qw.QLabel('※ 操作を続けるために利用規約に同意してください。',self)
    self.lb.setStyleSheet('QLabel { color: red; }')
    self.lb.setGeometry(125,40,300,25)

    # 初期化
    self.cb_state_changed()

  def cb_state_changed(self):
    if self.cb.checkState() == Qc.Qt.CheckState.Checked :
      self.btn.setEnabled(True)
      self.lb.setVisible(False)
    else :
      self.btn.setEnabled(False)
      self.lb.setVisible(True)
      
# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

6.2 チェックボックスのサンプルプログラム2

チェックボックスを利用したプログラム例を示します。

img

このプログラムは、レスポンシブデザインに加えて、QCheckBoxオブジェクトに動的に属性 (subject) を追加するなど応用的な内容を含みます。プログラムの詳細については ChatGPT などを活用して理解して努めてください (質問例と回答例)。

import sys
import PySide6.QtCore as Qc
import PySide6.QtWidgets as Qw

# 科目情報を扱うクラスの定義
class Subject():
  def __init__(self, name:str, credits:int, term:str):
    self.name = name        # 科目名
    self.credits = credits  # 単位数
    self.term = term        # 開講期

# 科目情報のインスタンスの生成
subjects = [Subject('国語2',2,'通年'),
            Subject('微分積分1',2,'前期'),
            Subject('論理回路1',1,'後期'),
            Subject('メディアデザイン入門',2,'後期'),
            Subject('プログラミング1',2,'後期'),
            Subject('工学基礎実習',4,'通年')]

# レイアウト設定用のエイリアス
sp = Qw.QSizePolicy.Policy

# GUI MainWindow クラス
class MainWindow(Qw.QMainWindow):
  
  def __init__(self):

    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 100)

    # メインレイアウトの設定
    central_widget = Qw.QWidget(self)
    self.setCentralWidget(central_widget)
    main_layout = Qw.QVBoxLayout(central_widget) # 要素を垂直配置
    main_layout.setAlignment(Qc.Qt.AlignmentFlag.AlignTop) # 上寄せ
    main_layout.setContentsMargins(15,10,10,10)

    # チェックボックスの生成と設定
    self.checkboxes : list[Qw.QCheckBox] = []
    for sub in subjects:
      cb = Qw.QCheckBox(self)
      cb.setText(f'【{sub.term}{sub.name} ( {sub.credits}単位 )')
      cb.setCheckState(Qc.Qt.CheckState.Checked)
      cb.setCursor(Qc.Qt.CursorShape.PointingHandCursor) # カーソル形状
      # QCheckBox に 動的に subject属性 を追加。
      # VSCode からの型チェック警告を非表示にするため「type: ignore」を記述
      cb.subject = sub # type: ignore 
      cb.stateChanged.connect(self.cb_state_changed)
      self.checkboxes.append(cb)
      main_layout.addWidget(cb)

    # チェックボックス群とラベルの間に 20px スペーサを追加
    spacer = Qw.QSpacerItem(0, 10, sp.Fixed, sp.Fixed)
    main_layout.addSpacerItem(spacer)

    # ラベル
    self.lb_info = Qw.QLabel('',self)
    main_layout.addWidget(self.lb_info)

    # ラベルの表示を更新
    self.cb_state_changed()

  # チェックボックスの状態が変化したときのコールバック関数
  def cb_state_changed(self):
    sum_credits = 0
    for cb in self.checkboxes:
      if cb.isChecked() :
        sum_credits += cb.subject.credits # type: ignore
        cb.setStyleSheet('QCheckBox { color: black; }')
      else :
        cb.setStyleSheet('QCheckBox { color: red; }')
    self.lb_info.setText(f'合計の取得単位数は「{sum_credits}単位」です。')

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())