顯示具有 tkinter 標籤的文章。 顯示所有文章
顯示具有 tkinter 標籤的文章。 顯示所有文章

2023年9月20日 星期三

市圖還書 2 本

今天去文藻參加高雄 Python 社群 Django 分享會時順路去河堤還下面兩本書 : 
No.1 借很久了才被預約, 先記下來以後有空要學日文時再回借. No.2 目前用不到了 (專案已寫完) 先還, 反正母校還有新版可借. 

2023年9月8日 星期五

高科大還書 1 本

因有 NLP 預約書到館, 所以昨天中午午休時拿下面這本去換 (學校尚未開學) : 
這本是 2020 的新版 (書況極新都沒人借過的樣子), 但與我之前 2018 年買的舊版比對其實內容幾乎一樣, 可能是修改一些小錯誤之後再刷而已. 過去三個月用 tkinter 寫了四套 GUI 軟體, 雖然尚未 100% 完全掌握, 但也算用起來得心應手, 我以前都太小看這個 Python 內建套件了, 其實它所提供的功能足敷大部分應用所需. 

2023年7月9日 星期日

Python 學習筆記 : 在 Tkinter 中使用執行緒執行耗時函式的方法

上週完成維運自動化軟體後馬上開工寫第二個自動化軟體 (與 CSR 有關), 因為近來有關部門提交的數據量大幅增長, 我以前用網頁技術撰寫的舊版軟體因為須與系統在 timing 上同步, 導致執行時間過長, 因此決定改用 Python 來改寫. 正因為要執行的數據較多, 因此想在按下自動執行按鈕後用彈出視窗來顯示一個進度條與例如 '正在執行資料更新作業, 請稍後' 這樣的提示, 避免因為執行中無回應而讓操作者以為程式當掉了. 

在 Tkinter 中彈出視窗元件是 TopView, 它跟 root 視窗一樣可以放置各種元件, 這裡則只是要放一個進度條 ProgressBar 與一個用來顯示執行狀態的 Label 元件而已 (本來想將提示字串直接顯示在進度條上面, 但查詢 ProgressBar 物件的方法發現並無此功能, 所以進度提示必須放在 Label 上). 最重要的是, 因為執行資料更新作業與顯示執行進度必須同時進行, 因此進度條視窗必須利用另一個執行緒來跑, 否則兩個都在主執行緒的話, 進度條會在漫長的執行作業結束後才顯示, 這樣就失去意義了. 

以下是簡化過後的範例, 主視窗 root 上只有一個開始執行的按鈕, 按下去會觸發執行一個 start_running_task() 函式, 此函式會一個彈出視窗來顯示進度條, 然後建立一個執行緒來執行傳進來的那個無法確定多久可以執行完畢的函式, 彈出視窗會定期 (這裡設定每 100 ms) 去檢查該作業執行緒是否還存在 (工作完成執行緒就會自動結束), 如果執行緒已結束就關閉顯示進度條的彈出視窗, 範例程式碼如下 : 


測試 1 : 使用執行緒執行耗時函式 [看原始碼]

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
import time
import threading

def start_running_task(task, msg):
    def check_thread():   # 檢查執行緒
        if thread.is_alive():   # 執行緒還沒結束 : 繼續每 100 ms 檢查一次
            progress_win.after(100, check_thread) # 每 100 ms 檢查執行緒 
        else:  # 若執行緒已結束關閉進度條視窗            
            progress_win.destroy()
            msgbox.showinfo('通知訊息', '執行作業已完成')
    # 建立 TopLevel 彈出視窗
    progress_win=tk.Toplevel(root)  
    progress_win.title('處理進度')
    progress_win.update_idletasks()  # 強制更新視窗更新, 處理所有事件
    progress_win.geometry('400x140')  # 將彈出視窗左上角拉到指定座標並設定大小
    progressbar=ttk.Progressbar(progress_win, mode='indeterminate', length=250)
    progressbar.pack(padx=3, pady=30)
    progressbar.start(5)   # 起始進度條並設定速度 (值越小跑越快)
    label=ttk.Label(progress_win)  # 顯示提示詞用
    label.config(text=msg, font=('Helvetica', 10, 'bold'))  # 設定字型大小
    label.pack(padx=3, pady=3)    
    thread=threading.Thread(target=task)  # 建立執行緒
    thread.start()    # 啟始執行緒
    progress_win.after(100, check_thread)  # 每 100 ms 檢查執行緒是否還存活    

def task():
    time.sleep(10)   # 模擬漫長的執行時間

root=tk.Tk()
root.title('執行緒測試')
root.geometry('600x400')
start_button=ttk.Button(root, text='開始執行', command=lambda: \
                        start_running_task(task, '執行中請稍候 ...'))
start_button.pack()
root.mainloop()

這裡在長時間作業函式 task() 中我使用 time.sleep() 來模擬那漫長的資料更新作業 (實際的系統是透過 Telnet 連線後向遠端主機連續丟出大量資料更新指令). 當按下按鈕時 command 參數的 lambda 會呼叫 start_running_task() 並傳入長時間作業函式 task 與要顯示在彈出視窗的提示字串,  start_running_task() 函式會建立一個執行緒來執行此作業函式 task(), 並用 after() 方法每 100 毫秒呼叫回呼函式 check_thread() 來檢查作業函式 task() 是否執行結束, 是的話就關閉彈出視窗, 否則繼續每 100 毫秒檢查一次直到 task() 結束, 執行緒終止才關掉彈出視窗, 結果如下 : 


 


其實不是每個作業函式都要丟給 start_running_task(), 只有作業時間較長 (通常使用者耐性大約 5~10 秒) 的作業才需要, 一般 3~5 秒就完成的函式進度條才閃一下就結束了, 丟給 start_running_task() 沒意思. 

如果要讓顯示進度條的彈出視窗顯示在整個螢幕的正中央, 可以加入設定彈出視窗 geometry 的程式碼, 如下面黃色背景的部分 :


測試 2 : 使用執行緒執行耗時函式 (彈出視窗置中) [看原始碼]

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
import time
import threading

def start_running_task(task, msg):
    def check_thread():   # 檢查執行緒
        if thread.is_alive():   # 執行緒還沒結束 : 繼續每 100 ms 檢查一次
            progress_win.after(100, check_thread) # 每 100 ms 檢查執行緒 
        else:  # 若執行緒已結束關閉進度條視窗            
            progress_win.destroy()
            msgbox.showinfo('通知訊息', '執行作業已完成')
    # 建立 TopLevel 彈出視窗
    progress_win=tk.Toplevel(root)  
    progress_win.title('處理進度')
    progress_win.update_idletasks()  # 強制更新視窗更新, 處理所有事件
    # 設定彈出視窗左上角座標與大小始其置中
    width=400   # 彈出視窗寬度
    height=140  # 彈出視窗高度    
    screen_width=progress_win.winfo_screenwidth()    # 取得彈出視窗寬度
    screen_height=progress_win.winfo_screenheight()  # 取得彈出視窗高度
    x=(screen_width // 2) - (width // 2)    # 彈出視窗的左上角 x 座標
    y=(screen_height // 2) - (height // 2)  # 彈出視窗的左上角 y 座標  
    geometry=f'{width}x{height}+{x}+{y}'    # 設定視窗左上角座標與大小
    progress_win.geometry(geometry)  # 將彈出視窗左上角拉到指定座標並設定大小
    progressbar=ttk.Progressbar(progress_win, mode='indeterminate', length=250)
    progressbar.pack(padx=3, pady=30)
    progressbar.start(5)   # 起始進度條並設定速度 (值越小跑越快)
    label=ttk.Label(progress_win)  # 顯示提示詞用
    label.config(text=msg, font=('Helvetica', 10, 'bold'))  # 設定字型大小
    label.pack(padx=3, pady=3)    
    thread=threading.Thread(target=task)  # 建立執行緒
    thread.start()    # 啟始執行緒
    progress_win.after(100, check_thread)  # 每 100 ms 檢查執行緒是否還存活    

def task():
    time.sleep(10)   # 模擬漫長的執行時間

root=tk.Tk()
root.title('執行緒測試')
root.geometry('600x400')
start_button=ttk.Button(root, text='開始執行', command=lambda: \
                        start_running_task(task, '執行中請稍候 ...'))
start_button.pack()
root.mainloop()

這樣彈出視窗就會出現在全螢幕的正中央了. 我查書與爬文都沒找到滿意合用的, 以上的做法是我不斷調整 prompt 詢問 ChatGPT 好多次 (它的回答不一定有效) 摸索測試出來的可行架構, 特地記下來以便往後的專案參考. 如何善用 AI 加速軟體專案開發很值得探討. 


2023-07-10 補充 :

如果要在執行作業完成時顯示所耗費的時間, 可以在 start_running_task() 裡面計算起訖時間, 作法如下面範例所示 : 


測試 3 : 使用執行緒執行耗時函式 (顯示執行時間) [看原始碼]

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
import time
import threading

def start_running_task(task, msg):
    def check_thread():   # 檢查執行緒
        nonlocal start_time   # 參照外部變數
        if thread.is_alive():   # 執行緒還沒結束 : 繼續每 100 ms 檢查一次
            progress_win.after(100, check_thread) # 每 100 ms 檢查執行緒 
        else:  # 若執行緒已結束關閉進度條視窗            
            progress_win.destroy()
            end_time=time.time()     # 結束時間
            elapsed=round(end_time - start_time, 2)   # 計算耗時
            msgbox.showinfo('通知訊息', f'執行作業已完成, 耗時 {elapsed} 秒')
    start_time=time.time()  # 開始時間 (計算執行時間用)
    # 建立 TopLevel 彈出視窗
    progress_win=tk.Toplevel(root)  
    progress_win.title('處理進度')
    progress_win.update_idletasks()  # 強制更新視窗更新, 處理所有事件
    progress_win.geometry('400x140')  # 將彈出視窗左上角拉到指定座標並設定大小
    progressbar=ttk.Progressbar(progress_win, mode='indeterminate', length=250)
    progressbar.pack(padx=3, pady=30)
    progressbar.start(5)   # 起始進度條並設定速度 (值越小跑越快)
    label=ttk.Label(progress_win)  # 顯示提示詞用
    label.config(text=msg, font=('Helvetica', 10, 'bold'))  # 設定字型大小
    label.pack(padx=3, pady=3)    
    thread=threading.Thread(target=task)  # 建立執行緒執行 task() 函式
    thread.start()    # 啟始執行緒
    progress_win.after(100, check_thread)  # 每 100 ms 檢查執行緒是否還存活    

def task():
    time.sleep(10)   # 模擬漫長的執行時間

root=tk.Tk()
root.title('執行緒測試')
root.geometry('600x400')
start_button=ttk.Button(root, text='開始執行', command=lambda: \
                        start_running_task(task, '執行中請稍候 ...'))
start_button.pack()
root.mainloop()

這裡因為檢查執行緒的關係使用了巢狀函式, 內部函式若要參照外部變數 start_time, 必須於內部函式中宣告為 nonlocal, 執行結果如下 : 




如果要讓 start_running_task() 可以傳入參數控制彈出視窗中 Label 顯示的訊息, 以及執行緒結束對話框內顯示的訊息, 改寫如下 : 


測試 4 : 使用執行緒執行耗時函式 (以參數控制顯示訊息) [看原始碼]

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
import time
import threading

def start_running_task(task, msg_start='作業執行中 ...', msg_end='已執行完成'):
    def check_thread():   # 檢查執行緒
        nonlocal start_time   # 參照外部變數
        if thread.is_alive():   # 執行緒還沒結束 : 繼續每 100 ms 檢查一次
            progress_win.after(100, check_thread) # 每 100 ms 檢查執行緒 
        else:  # 若執行緒已結束關閉進度條視窗            
            progress_win.destroy()
            end_time=time.time()
            elapsed=round(end_time - start_time, 2)   # 計算耗時
            msgbox.showinfo('通知訊息', f'{msg_end}, 耗時 {elapsed} 秒')
    start_time=time.time()  # 計算執行時間用
    # 建立 TopLevel 彈出視窗
    progress_win=tk.Toplevel(root)  
    progress_win.title('處理進度')
    progress_win.update_idletasks()  # 強制更新視窗更新, 處理所有事件
    progress_win.geometry('400x140')  # 將彈出視窗左上角拉到指定座標並設定大小
    progressbar=ttk.Progressbar(progress_win, mode='indeterminate', length=250)
    progressbar.pack(padx=3, pady=30)
    progressbar.start(5)   # 起始進度條並設定速度 (值越小跑越快)
    label=ttk.Label(progress_win)  # 顯示提示詞用
    label.config(text=msg_start, font=('Helvetica', 10, 'bold'))  # 設定字型大小
    label.pack(padx=3, pady=3)    
    thread=threading.Thread(target=task)  # 建立執行緒
    thread.start()    # 啟始執行緒
    progress_win.after(100, check_thread)  # 每 100 ms 檢查執行緒是否還存活    

def task():
    time.sleep(10)   # 模擬漫長的執行時間

root=tk.Tk()
root.title('執行緒測試')
root.geometry('600x400')
start_button=ttk.Button(root, text='開始執行', command=lambda: \
                        start_running_task(task))
start_button.pack()
root.mainloop()

此例修改了 start_running_task() 的參數結構, 改為可傳入 msg_start 與 msg_end 兩個參數, 兩者皆有預設值, 如要更改可在呼叫 start_running_task() 時傳入參數, 例如 : 

start_running_task(task, '執行中請稍候 ...', '工作以執行完成')

下面是以預設值執行的結果 : 





如果執行結束時需要從 task() 函式取得資訊 (例如執行的指令失敗的個數) 顯示在結束的對話框要怎麼做? 這種情況要修改上面的程式碼, 添加一個內部中介函式例如 run_task() 來取得 task() 的傳回值, 並且將檢查執行緒的定時器函式 after() 移到 run_task() 裡面來並增加一個傳回值參數, 同時 check_thread() 要添加一個參數來取得 run_task() 傳回來的結果, 透過這個傳回值來製作完成對話框要顯示的資訊, 這樣也省了上面範例的 end_msg 參數了, 程式如下 : 


測試 5 : 使用執行緒執行耗時函式 (從作業函式取得傳回值) [看原始碼]

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox
import time
import threading

def start_running_task(task, msg='作業執行中 ...'):
    def check_thread(result):   # 檢查執行緒, result 為 task() 之傳回值
        nonlocal start_time     # 參照外部變數
        if thread.is_alive():   # 執行緒還沒結束 : 繼續每 100 ms 檢查一次
            progress_win.after(100, check_thread) # 每 100 ms 檢查執行緒 
        else:  # 若執行緒已結束關閉進度條視窗            
            progress_win.destroy()
            end_time=time.time()
            elapsed=round(end_time - start_time, 2)   # 計算耗時
            msgbox.showinfo('通知訊息', f'{result}\n耗時 {elapsed} 秒')
    def run_task():   # 中介函式
        result=task()     # 取得作業函式傳回值
        progress_win.after(100, check_thread, result)  # 每 100 ms 檢查執行緒是否還存活

    start_time=time.time()  # 計算執行時間用
    # 建立 TopLevel 彈出視窗
    progress_win=tk.Toplevel(root)  
    progress_win.title('處理進度')
    progress_win.update_idletasks()  # 強制更新視窗更新, 處理所有事件
    progress_win.geometry('400x140')  # 將彈出視窗左上角拉到指定座標並設定大小
    progressbar=ttk.Progressbar(progress_win, mode='indeterminate', length=250)
    progressbar.pack(padx=3, pady=30)
    progressbar.start(5)   # 起始進度條並設定速度 (值越小跑越快)
    label=ttk.Label(progress_win)  # 顯示提示詞用
    label.config(text=msg, font=('Helvetica', 10, 'bold'))  # 設定字型大小
    label.pack(padx=3, pady=3)    
    thread=threading.Thread(target=run_task)  # 建立執行緒執行中介函式 run_task()
    thread.start()    # 啟始執行緒

def job():
    time.sleep(5)   # 模擬漫長的執行時間
    error=2
    return '執行作業已完成, 錯誤數=' + str(error)

root=tk.Tk()
root.title('執行緒測試')
root.geometry('600x400')
start_button=ttk.Button(root, text='開始執行', command=lambda: \
                        start_running_task(job))
start_button.pack()
root.mainloop()

注意, 此例為了避免混淆, 以為內部函式 run_task() 呼叫的 task() 是直接呼叫外部函式 task(), 特地將要執行的漫長作業從 task() 改成 job(), 在按下執行按鈕時呼叫 start_running_task(job) 將 job 函式傳給 start_running_task(), 所以 run_task() 呼叫的 task() 其實就是外部的 job(), 結果如下 :





最後這個例子才是最終用在我的軟體專案中的做法. 

2023年7月6日 星期四

Python 學習筆記 : 使用 Tkinter 的剪貼簿實作複製與貼上功能

Tkinter 的 Text 元件通常用來輸入或編輯多行文字, 如果要複製部分或全部內容, 可以用滑鼠選取後按 CTRL+C 或按滑鼠右鍵選複製, 這樣便會被複製到作業系統的剪貼簿裡, 方便於其他軟體中貼上. 這種透過剪貼簿複製貼上的功能也可以用 Python Tkinter 程式碼控制. 視窗頂層物件 (即呼叫 tk.Tk() 建立之物件) 可利用  clipboard_append() 與 clipboard_get() 方法存取作業系統的剪貼簿以便實作複製與貼上功能.  

在下面的簡化範例中, 視窗中放置了一個 Text 元件, 以及四個按鈕, 分別是貼上, 清除, 選取複製, 與全部複製, 按下複製鈕會將選取內容存入剪貼簿, 按下貼上則是從剪貼簿取得暫存之內容 :

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox as msgbox

root=tk.Tk()  
root.title('剪貼簿複製貼上測試') 
root.geometry('500x300') 

def paste():  # 從剪貼簿貼到編輯區
    editor_text.delete(1.0, "end")   # 清除編輯區內容
    editor_text.insert(tk.INSERT, root.clipboard_get())  # 插入內容

def clear():  # 清除編輯區
    editor_text.delete(1.0, "end")   # 清除編輯區

def copy_all():
    selection=editor_text.get(1.0, tk.END) # 取得 Text 全部內容
    root.clipboard_clear()  # 清除剪貼簿
    root.clipboard_append(selection)  # 複製到剪貼簿
    msgbox.showinfo('通知訊息', '編輯區內容已全部複製到剪貼簿')

def copy_select():
    try: # 未選取會出現例外
        selection=editor_text.get(tk.SEL_FIRST, tk.SEL_LAST) # 取得被選取內容
        root.clipboard_clear()  # 清除剪貼簿
        root.clipboard_append(selection)  # 複製到剪貼簿
        msgbox.showinfo('通知訊息', '選取內容已複製到剪貼簿')
    except Exception as e:
        msgbox.showinfo('通知訊息', '未選取內容') 

editor_text=tk.Text(root, width=70, height=12)
editor_text.pack(fill=tk.X, expand=True)
paste_btn=ttk.Button(root, text='貼上', command=paste)
paste_btn.pack(side=tk.LEFT)
clear_btn=ttk.Button(root, text='清除', command=clear)
clear_btn.pack(side=tk.LEFT)
copy_select_btn=ttk.Button(root, text='選取複製', command=copy_select)
copy_select_btn.pack(side=tk.LEFT)
copy_all_btn=ttk.Button(root, text='全部複製', command=copy_all)
copy_all_btn.pack(side=tk.LEFT)

root.mainloop()

程式內容摘要說明如下 : 
  • 呼叫視窗物件的 clipboard_append(selection) 方法可將選取內容複製到剪貼簿
  • 呼叫視窗物件的 clipboard_get() 方法可取得剪貼簿內容
  • 呼叫視窗物件的 clipboard_clear() 方法可清除剪貼簿
  • 呼叫 Text 物件的 delete(1.0, "end") 方法可刪除 Text 物件全部內容
  • 呼叫 Text 物件的 get(tk.SEL_FIRST, tk.SEL_LAST) 方法可取得被選取內容, 但未選取時會出現 _tkinter.TclError: text doesn't contain any characters tagged with "sel" 例外, 故須放在 try~except 中捕捉例外. 
結果如下 : 





Python 學習筆記 : Tkinter 的 Entry 點一下清除預設內容的方法

晚上得空繼續來記錄 Tkinter GUI 軟體開發的小技巧, 本篇是常見的一種 Entry 元件的功能, 即點擊Entry 輸入框時自動消除裡面的預設值或輸入提示語. 下面以簡化的登入畫面為例說明, 使用 pack 排版依序將帳號與密碼兩個 Entry 輸入框與登入按鈕放入視窗中.

import tkinter as tk
from tkinter import ttk

root=tk.Tk()  
root.title('點擊 Entry 清除內容測試') 
root.geometry('300x150') 

def clear_account_entry(event):   # 清除帳號框內容
    account_entry.delete(0, tk.END)    # 刪除開頭與結尾索引之間的 (全部) 內容
    
def clear_password_entry(event):  # 清除密碼框內容
    password_entry.delete(0, tk.END)   # 刪除開頭與結尾索引之間的 (全部) 內容
    
def login():
    pass
        
account=tk.StringVar()
account_entry=tk.Entry(root, textvariable=account)
account_entry.insert(0, '請輸入帳號')
account_entry.bind('<Button-1>', clear_account_entry)
account_entry.pack()
password=tk.StringVar()
password_entry=tk.Entry(root, textvariable=password)
password_entry.insert(0, '請輸入密碼')
password_entry.bind('<Button-1>', clear_password_entry)
password_entry.pack()
login_btn=ttk.Button(root, text='登入', command=login)
login_btn.pack()
root.mainloop()

此處兩個 Entry 元件都呼叫 bind() 方法監聽滑鼠左鍵的按一下鈕 (Button-1) 事件, 並綁定事件處理函式. 在事件處理函式中, 呼叫 Entry 物件的 delete(0, tk.END) 方法即可刪除 Entry 內預設值或先前輸入的內容, 結果如下 : 




點擊 Entry 輸入框時預設值 "請輸入 ..." 就會自動清空, 方便我們直接輸入內容 :




Python 學習筆記 : 如何在 tkinter 的 Text 元件中搜尋字串並以高亮度顯示

這也是最近用 Tkinter 寫維運自動化軟體的總結經驗之一, 在此軟體的記錄檔管理功能中, 可以讓使用者輸入想要搜尋的字串, 然後在文字區域元件 Text 中用高亮度來標示該字串於文本中出現的地方. 以下用簡化的範例來說明作法.

下面的範例程式中, 使用簡單的 pack 排版方式放置一個 Entry 輸入框, Button 按鈕, 以及 Text 多行文字輸入框, 當按下按鈕時呼叫 search_text() 函式於 Text 元件的內容中做全文搜索, 利用索引逐步推進法找尋 Entry 輸入框中指定之字串直到文本末端, 當找到文本時就設定為高亮度顯示, 程式如下 :

import tkinter as tk
from tkinter import ttk

root=tk.Tk()  
root.title('搜尋文本高亮度顯示測試') 
root.geometry('400x300') 

def search_text():
    search_what=search_what_var.get()  # 取得搜尋標的
    text.tag_remove('highlight', '1.0', tk.END)  # 移除全部高亮度顯示標籤
    start='1.0'  # 預設起始索引為文本開頭
    while True:  # 搜尋全部文本直到末端
        start=text.search(search_what, start, stopindex=tk.END)
        if not start:  # 起始索引為 0 : 已到末端結束搜尋
            break
        end=f'{start}+{len(search_what)}c'  # 末端字串
        text.tag_add('highlight', start, end)  # 將搜尋到的字串設為高亮度
        start=end  # 後軍當前軍, 移動開始索引到目前標的尾端
        
search_what_var=tk.StringVar()   # 綁定 Entry 元件的字串變數
search_entry=tk.Entry(root, textvariable=search_what_var) 
search_entry.pack()
search_btn=ttk.Button(root, text='搜尋', command=search_text)
search_btn.pack()
text=tk.Text(root)
text.pack(fill=tk.BOTH, expand=True)
text.tag_configure('highlight', background='yellow')  # 設定 Text 高亮度為黃色
root.mainloop()

結果如下 :




處理函式 search_text() 並非我原創, 而是透過詢問 ChatGPT 改寫而來. ChatGPT 真是程式員的好幫手啊! 因為以後會重複用到, 所以特別寫個簡化範例來記錄作法. 

2023年7月5日 星期三

Python 學習筆記 : 如何替 tkinter 的元件加上工具提示 (tooltip) 文字

這周終於把維運自動化軟體寫完, 從中累積了許多 tkinter 小技巧, 本篇紀錄替元件加上 tooltip 的方法. 以下用簡化的範例來說明如何用 IDLE 編輯器的 idlelib.tooltip.Hovertip 類別來為 tkinter 的元件加上 tooltip (工具提示), 此套件為 Python 內件不需安裝, 使用之前需要用 import 匯入 :

from idlelib.tooltip import Hovertip   

加 tooltip 的語法如下 (此函式有傳回值, 但通常用不到) :

Hovertip(物件變數, '提示詞')   

例如 : 

import tkinter as tk
from tkinter import ttk
from idlelib.tooltip import Hovertip

root=tk.Tk()  
root.title('Hovertip 測試') 
root.geometry('400x300') 

entry=tk.Entry(root)  
Hovertip(entry, '這是輸入框')
entry.pack()  
tk_btn=tk.Button(root, text='OK') 
Hovertip(tk_btn, '這是 tk 按鈕')
tk_btn.pack() 
ttk_btn=ttk.Button(root, text='OK') 
Hovertip(ttk_btn, '這是 ttk 按鈕')
ttk_btn.pack()
radio=ttk.Radiobutton(root, text="男", value="M")
Hovertip(radio, '這是 radio 圓鈕')
radio.pack()
checkbutton=ttk.Checkbutton(root, text='日語')
Hovertip(checkbutton, '這是 checkbutton 方塊')
checkbutton.pack()
text=tk.Text(root) 
Hovertip(text, '這是 Text 文字區域')
text.pack()

root.mainloop()

結果如下 : 




以上僅舉常見的元件為例, 其實所有的 tkinter 元件都可以用 Hovertip() 套上工具提示. 

參考 :




 

2023年7月2日 星期日

Python 學習筆記 : 用 pyinstaller 將 .py 程式轉成 .exe 執行檔

最近兩周用 tkinter 打造的維運自動化軟體完工後要佈署到 Windows 作業環境中來運行, 當然不能用開發測試階段那樣用命令列的 python 指令去執行原始碼, 因為那必須在作業電腦中也安裝 Python, 這種使用方式不僅麻煩 (還要開啟命令提示字元視窗), 而且有時作業電腦並不允許裝 Python. 可行的方法是將原始碼轉成 .exe 可執行檔, 它會自帶 Python 執行環境, 執行此程式的別台電腦不需要安裝 Python. 參考 : 



1. 安裝 pyinstall 套件 : 

用系統管理員身分開啟命令提示字元視窗安裝 pyinstaller 套件 (我第一次不是用系統管理員身分開啟命令提示字元視窗, 結果安裝失敗) : 

pip install pyinstaller   

C:\WINDOWS\system32>pip install pyinstaller     
Collecting pyinstaller
  Downloading pyinstaller-5.13.0-py3-none-win_amd64.whl (1.3 MB)
     ---------------------------------------- 1.3/1.3 MB 3.8 MB/s eta 0:00:00
Requirement already satisfied: setuptools>=42.0.0 in c:\python311\lib\site-packages (from pyinstaller) (65.5.0)
Collecting altgraph (from pyinstaller)
  Downloading altgraph-0.17.3-py2.py3-none-any.whl (21 kB)
Collecting pyinstaller-hooks-contrib>=2021.4 (from pyinstaller)
  Downloading pyinstaller_hooks_contrib-2023.4-py2.py3-none-any.whl (271 kB)
     ---------------------------------------- 271.8/271.8 kB 16.3 MB/s eta 0:00:00
Collecting pefile>=2022.5.30 (from pyinstaller)
  Downloading pefile-2023.2.7-py3-none-any.whl (71 kB)
     ---------------------------------------- 71.8/71.8 kB 4.1 MB/s eta 0:00:00
Collecting pywin32-ctypes>=0.2.1 (from pyinstaller)
  Downloading pywin32_ctypes-0.2.2-py3-none-any.whl (30 kB)
Installing collected packages: altgraph, pywin32-ctypes, pyinstaller-hooks-contrib, pefile, pyinstaller
Successfully installed altgraph-0.17.3 pefile-2023.2.7 pyinstaller-5.13.0 pyinstaller-hooks-contrib-2023.4 pywin32-ctypes-0.2.2


2. 執行 pyinstaller 將 .py 原始碼轉成 .exe : 

如果要將原始碼轉換成單一個 .exe 執行檔要在 pyinstaller 指令後面加一個 -F 參數, pyinstaller 會在目前目錄下建一個 dist 子目錄來存放此 exe 檔 : 

pyinstaller -F 原始碼檔案.py     

例如原始碼為 test.py : 

E:\test>pyinstaller -F test.py    
662 INFO: PyInstaller: 5.13.0
662 INFO: Python: 3.11.3
674 INFO: Platform: Windows-10-10.0.19045-SP0
726 INFO: wrote E:\test\test.spec
825 INFO: Extending PYTHONPATH with paths
['E:\\test']
1148 INFO: checking Analysis
1148 INFO: Building Analysis because Analysis-00.toc is non existent
1148 INFO: Initializing module dependency graph...
1157 INFO: Caching module graph hooks...
1187 INFO: Analyzing base_library.zip ...
3265 INFO: Loading module hook 'hook-heapq.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
3389 INFO: Loading module hook 'hook-encodings.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
5530 INFO: Loading module hook 'hook-pickle.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
8728 INFO: Caching module dependency graph...
8908 INFO: running Analysis Analysis-00.toc
8935 INFO: Adding Microsoft.Windows.Common-Controls to dependent assemblies of final executable
  required by C:\Python311\python.exe
9018 INFO: Analyzing E:\test\test.py
9265 INFO: Loading module hook 'hook-idlelib.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
9454 INFO: Loading module hook 'hook-difflib.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
9802 INFO: Loading module hook 'hook-multiprocessing.util.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
9975 INFO: Loading module hook 'hook-xml.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
11103 INFO: Loading module hook 'hook-platform.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
12725 INFO: Loading module hook 'hook-sysconfig.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
12988 INFO: Processing module hooks...
13088 INFO: Loading module hook 'hook-_tkinter.py' from 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks'...
13090 INFO: checking Tree
13090 INFO: Building Tree because Tree-00.toc is non existent
13093 INFO: Building Tree Tree-00.toc
13297 INFO: checking Tree
13297 INFO: Building Tree because Tree-01.toc is non existent
13298 INFO: Building Tree Tree-01.toc
13337 INFO: checking Tree
13337 INFO: Building Tree because Tree-02.toc is non existent
13338 INFO: Building Tree Tree-02.toc
13379 INFO: Looking for ctypes DLLs
13427 INFO: Analyzing run-time hooks ...
13430 INFO: Including run-time hook 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py'
13433 INFO: Including run-time hook 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py'
13436 INFO: Including run-time hook 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py'
13439 INFO: Including run-time hook 'C:\\Python311\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth__tkinter.py'
13469 INFO: Looking for dynamic libraries
456 INFO: Extra DLL search directories (AddDllDirectory): []
456 INFO: Extra DLL search directories (PATH): ['C:\\Python311\\Scripts\\', 'C:\\Python311\\', 'C:\\Python37\\Scripts\\', 'C:\\Python37\\', 'C:\\Program Files (x86)\\Intel\\Intel(R) Management Engine Components\\iCLS\\', 'C:\\Program Files\\Intel\\Intel(R) Management Engine Components\\iCLS\\', 'C:\\WINDOWS\\system32', 'C:\\WINDOWS', 'C:\\WINDOWS\\System32\\Wbem', 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\', 'C:\\WINDOWS\\System32\\OpenSSH\\', 'C:\\Program Files (x86)\\Intel\\Intel(R) Management Engine Components\\DAL', 'C:\\Program Files\\Intel\\Intel(R) Management Engine Components\\DAL', 'C:\\Program Files\\Intel\\WiFi\\bin\\', 'C:\\Program Files\\Common Files\\Intel\\WirelessCommon\\', 'C:\\Program Files\\Git\\cmd', 'C:\\Program Files\\nodejs\\', 'C:\\ProgramData\\chocolatey\\bin', 'C:\\Users\\User\\AppData\\Local\\Microsoft\\WindowsApps', 'C:\\Program Files\\Bandizip\\', 'C:\\Users\\User\\AppData\\Local\\atom\\bin', 'C:\\Users\\User\\AppData\\Roaming\\npm']
14443 INFO: Looking for eggs
14450 INFO: Using Python library C:\Python311\python311.dll
14450 INFO: Found binding redirects:
[]
14469 INFO: Warnings written to E:\test\build\test\warn-test.txt
14510 INFO: Graph cross-reference written to E:\test\build\test\xref-test.html
15059 INFO: checking PYZ
15059 INFO: Building PYZ because PYZ-00.toc is non existent
15060 INFO: Building PYZ (ZlibArchive) E:\test\build\test\PYZ-00.pyz
17368 INFO: Building PYZ (ZlibArchive) E:\test\build\test\PYZ-00.pyz completed successfully.
17478 INFO: checking PKG
17478 INFO: Building PKG because PKG-00.toc is non existent
17480 INFO: Building PKG (CArchive) test.pkg
29536 INFO: Building PKG (CArchive) test.pkg completed successfully.
29609 INFO: Bootloader C:\Python311\Lib\site-packages\PyInstaller\bootloader\Windows-64bit-intel\run.exe
29609 INFO: checking EXE
29610 INFO: Building EXE because EXE-00.toc is non existent
29610 INFO: Building EXE from EXE-00.toc
29616 INFO: Copying bootloader EXE to E:\test\dist\test.exe.notanexecutable
29800 INFO: Copying icon to EXE
29805 INFO: Copying icons from ['C:\\Python311\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-console.ico']
29837 INFO: Writing RT_GROUP_ICON 0 resource with 104 bytes
29837 INFO: Writing RT_ICON 1 resource with 3752 bytes
29839 INFO: Writing RT_ICON 2 resource with 2216 bytes
29839 INFO: Writing RT_ICON 3 resource with 1384 bytes
29842 INFO: Writing RT_ICON 4 resource with 37019 bytes
29843 INFO: Writing RT_ICON 5 resource with 9640 bytes
29843 INFO: Writing RT_ICON 6 resource with 4264 bytes
29844 INFO: Writing RT_ICON 7 resource with 1128 bytes
29912 INFO: Copying 0 resources to EXE
29912 INFO: Embedding manifest in EXE
29914 INFO: Updating manifest in E:\test\dist\test.exe.notanexecutable
29952 INFO: Updating resource type 24 name 1 language 0
30012 INFO: Appending PKG archive to EXE
30339 INFO: Fixing EXE headers
32482 INFO: Building EXE from EXE-00.toc completed successfully.

我的 Python 環境是 3.11 版, 可見雖然上面參考的文章大都說 Python 3.9 以下都沒問題, 我實際測試 3.11 也是 OK 的. 轉換完畢會在目前資料夾下出現一個 dist 子目錄用來存放轉換得到的 .exe 檔 科切換到 dist 下看看是否能執行 :

E:\test>cd dist    

E:\test\dist>dir    
 磁碟區 E 中的磁碟沒有標籤。
 磁碟區序號:  03B3-666A

 E:\test\dist 的目錄

2023/07/02  上午 12:16    <DIR>          .
2023/07/02  上午 12:16    <DIR>          ..
2023/07/02  上午 12:16        12,179,107 test.exe   
               1 個檔案      12,179,107 位元組
               2 個目錄  399,272,574,976 位元組可用

要注意的是, 此 .exe 檔只包含執行原始碼的解譯環境, 並不包括程式會用到的資料夾或檔案, 因此若程式中有用到外部檔案 (例如 logo.ico 圖標檔案) 或目錄 (例如存放紀錄檔的 /log), 必須將它們從開發目錄複製到 dist 下面, 否則執行 test.exe 時會出現錯誤. 

注意, 如果是在檔案總管下點擊 test.exe, 發生錯誤時會閃退, 根本不知道發生甚麼事, 必須開啟命令提示字元視窗用命令列執行 test.exe 才會報出錯誤原因, 例如缺了圖標 logo.ico 會這樣 : 

E:\test\dist>test.exe   
Traceback (most recent call last):
  File "test.py", line 554, in <module>
  File "tkinter\__init__.py", line 2136, in wm_iconbitmap
_tkinter.TclError: bitmap "logo.ico" not defined
[21080] Failed to execute script 'test' due to unhandled exception!

補了 logo.ico 還缺了 /log 子目錄結果如下 :

E:\test\dist>test.exe   
Traceback (most recent call last):
  File "test.py", line 724, in <module>
  File "test.py", line 227, in load_log_list
FileNotFoundError: [WinError 3] 系統找不到指定的路徑。: './log'
[12768] Failed to execute script 'test' due to unhandled exception!

只要所需的檔案目錄都複製到 dist 下面後就可以順利執行了. 如果要以綠色軟體方式執行, 只要將 dist 目錄複製到別台電腦, 然後將 exe 傳送到桌面當捷徑, 點擊捷徑即可執行無須安裝; 如果需要打包成安裝程式, 可以用 Inno setup 之類的打包軟體來將整個 dist 下的程式, 檔案, 與目錄打包成單一個 setup.exe, 參考 :



2023-07-04 補充 : 

最近幾天數據處理工作量較大, 所幸這套軟體及時派上用場, 自動化讓作業速度提升好幾倍, 不需要像以前那樣用 Excel 或 EditPlus 修修改改, 花這兩周的時間太值得啦! 不過程式執行時會先出現一個無內容的命令提示字元視窗 (我猜是用來啟動 Python 環境), 然後才出現 Tkinter 視窗 : 




我從下面這篇文章得知, 原來在轉換時可以用 -w 參數讓命令提示字元視窗不要出現 : 


例如 : 

pyinstaller -F 原始碼檔案.py  -w      

兩周內改版至第 15 版, 原始碼 1200 行左右, 約 57KB, 轉成 .exe 檔後約 12MB. 


2023-07-24 補充 : 

經過 4 周我又寫好一個 tkinter 的 GUI 軟體 (CSR 相關), 為了避開資安查核要求, 程式不另外用 SETUP 軟體打包, 均以 portable 方式執行, 在佈署時直接將主程式傳送到桌面當捷徑, 這時就需要一個 logo.ico 來在桌面標示此程式, 這可以用小畫家等工具製作一個 32x32 的 .bmp 圖標檔, 利用下面的線上轉檔工具轉成 .ico 檔 :


如果需要將圖標圓角化, 可以先將圖檔用下列網站處理 :


參考 :


不過此 .ico 是在程式執行時顯示在左上角作為圖標, 並不會在桌面顯示, 如果要將此 logo.ico 也當作桌面捷徑圖標, 在用 pyinstaller 時需加入 --icon=logo.ico 參數, 例如 : 

pyinstaller --onefile --icon=logo.ico 原始碼檔案.py -w     

2023年6月24日 星期六

Python 學習筆記 : 如何在 Tkinter 中使用 HTML

今天在 Youtube 看到下面這個教學影片, 介紹如何利用第三方套件 tkhtmlview 在 Tkiner 視窗中使用 HTML + CSS 語法來美化視窗內容與連結網站 :





我覺得這功能很實用, 那就依樣畫葫蘆來測試一番唄!

首先用 pip install 來安裝 tkhtmlview, 注意, 要用系統管理員身分開啟命令提示字元視窗 :




Microsoft Windows [版本 10.0.19045.3086]
(c) Microsoft Corporation. 著作權所有,並保留一切權利。

C:\WINDOWS\system32>pip install tkhtmlview    
Collecting tkhtmlview
  Using cached tkhtmlview-0.2.0-py3-none-any.whl (10 kB)
Requirement already satisfied: Pillow<10.0.0,>=9.4.0 in c:\python311\lib\site-packages (from tkhtmlview) (9.5.0)
Collecting requests<3.0.0,>=2.28.2 (from tkhtmlview)
  Using cached requests-2.31.0-py3-none-any.whl (62 kB)
Requirement already satisfied: charset-normalizer<4,>=2 in c:\python311\lib\site-packages (from requests<3.0.0,>=2.28.2->tkhtmlview) (3.1.0)
Requirement already satisfied: idna<4,>=2.5 in c:\python311\lib\site-packages (from requests<3.0.0,>=2.28.2->tkhtmlview) (3.4)
Requirement already satisfied: urllib3<3,>=1.21.1 in c:\python311\lib\site-packages (from requests<3.0.0,>=2.28.2->tkhtmlview) (2.0.3)
Collecting certifi>=2017.4.17 (from requests<3.0.0,>=2.28.2->tkhtmlview)
  Using cached certifi-2023.5.7-py3-none-any.whl (156 kB)
Installing collected packages: certifi, requests, tkhtmlview
Successfully installed certifi-2023.5.7 requests-2.31.0 tkhtmlview-0.2.0

C:\WINDOWS\system32>

如果沒有用系統管理員身分開啟命令提示字元視窗, 則安裝時可能會出現無法寫入錯誤而失敗 : 

Installing collected packages: urllib3, Pillow, idna, charset-normalizer, certifi, requests, tkhtmlview
  WARNING: Failed to write executable - trying to use .deleteme logic
ERROR: Could not install packages due to an OSError: [WinError 2] 系統找不到指定的檔案。: 'C:\\Python311\\Scripts\\normalizer.exe' -> 'C:\\Python311\\Scripts\\normalizer.exe.deleteme'

目前 tkhtmlview 支援的 HTML 標籤尚未涵蓋全部 HTML5, 但常用的標籤例如 H1~H6, A, P 等都可使用, 參考教學文件 :



1. 文字效果 :  

下面範例使用 Label 元件來測試 H1~H6 的文字效果 : 


測試 1 : 在 HTMLLabel 元件上顯示 HTML 的 H1~H6 文字效果 

import tkinter as tk
from tkhtmlview import HTMLLabel

root=tk.Tk()
root.geometry('600x400')
html_1='<h1 style="color: red; text-align: center"> Hello World </h1>'
label_1=HTMLLabel(root, html=html_1)
label_1.pack()
label_1.fit_height()
html_2='<h2 style="color: green; text-align: center"> Hello World </h2>'
label_2=HTMLLabel(root, html=html_2)
label_2.pack()
label_2.fit_height()
html_3='<h3 style="color: blue; text-align: center"> Hello World </h3>'
label_3=HTMLLabel(root, html=html_3)
label_3.pack()
label_3.fit_height()
html_4='<h4 style="color: pink; text-align: center"> Hello World </h4>'
label_4=HTMLLabel(root, html=html_4)
label_4.pack()
label_4.fit_height()
html_5='<h5 style="color: cyan; text-align: center"> Hello World </h5>'
label_5=HTMLLabel(root, html=html_5)
label_5.pack()
label_5.fit_height()
html_6='<h6 style="color: black; text-align: center"> Hello World </h6>'
label_6=HTMLLabel(root, html=html_6)
label_6.pack()
label_6.fit_height()
root.mainloop()

此例使用 H1~H6 在 HTMLLabel 元件上顯示 6 種不同大小的標題, 注意, 連續用 pack() 將 HTMLLabel 放置到 root 容器時因為預設高度較大,  後面的元件會被推到是窗外而看不到, 因此必須呼叫 HTMLLabel 物件的 fit_height() 方法來調適元件高度, 結果如下 : 




HTMLLabel 已支援多國語言 (即多位元組字元例如中文) , 若將上面程式中的 "Hello World" 改成 "你是在說哈囉嗎?" 結果如下 :




2. 顯示超連結 :  

下面範例測試超連結 A 標籤 :


測試 2 : 在 HTMLLabel 元件上顯示超連結

import tkinter as tk
from tkhtmlview import HTMLLabel

root=tk.Tk()
root.geometry('400x200')
html_1='<a href="https://tw.yahoo.com/" target="_blank">雅虎奇摩</a>'
label_1=HTMLLabel(root, html=html_1)
label_1.pack(padx=5, pady=5)
label_1.fit_height()
html_2='<a href="https://www.google.com.tw/" target="_blank">Google</a>'
label_2=HTMLLabel(root, html=html_2)
label_2.pack(padx=5, pady=5)
label_2.fit_height()
html_3='<a href="https://chateverywhere.app/zh" target="_blank">Chat Everwhere</a>'
label_3=HTMLLabel(root, html=html_3)
label_3.pack(padx=5, pady=5)
label_3.fit_height()
root.mainloop()

此例在 HTMLLabel 元件上顯示 Yahoo, Google, 與 Chat Everywhere 的網址超連結, 由於使用了 target='_blank' 屬性, 因此點擊超連結會在瀏覽器中開啟新分頁來顯示網頁, 結果如下 :



 
這個功能在 Tkinter GUI 視窗需要連結網站時非常好用. 


3. 顯示清單 :  

tkhtmlview 支援 OL, UL, LI 等清單標籤, 例如 :

測試 3 : 在 HTMLLabel 元件上顯示清單超連結

import tkinter as tk
from tkhtmlview import HTMLLabel

root=tk.Tk()
root.geometry('400x400')
html='''
<ol>
  <li><a href="https://tw.yahoo.com/" target="_blank">雅虎奇摩</a></li>
  <li><a href="https://www.google.com.tw/" target="_blank">Google</a></li>
  <li><a href="https://chateverywhere.app/zh" target="_blank">Chat Everwhere</a></li>
</ol>
<ul>
  <li><a href="https://tw.yahoo.com/" target="_blank">雅虎奇摩</a></li>
  <li><a href="https://www.google.com.tw/" target="_blank">Google</a></li>
  <li><a href="https://chateverywhere.app/zh" target="_blank">Chat Everwhere</a></li>
</ul>
'''
label=HTMLLabel(root, html=html)
label.pack(padx=5, pady=5)
label.fit_height()
root.mainloop()

此例分別用 ol 與 ul 元素來呈現編號與無編號清單, 結果如下 :




4. 顯示圖片 :  

接下來測試 IMG 標籤的圖片顯示功能, 主要是透過其 src 屬性來指定圖片來源, 可以是網址或圖片在本機中的相對路徑, 下面是顯示本機程式工作目錄例如 : 


測試 4 : 在 HTMLLabel 元件上顯示圖片

import tkinter as tk
from tkhtmlview import HTMLLabel

root=tk.Tk()
root.geometry('800x700')
html_1='<img src="orchid.jpg">'
label_1=HTMLLabel(root, html=html_1)
label_1.pack(padx=5, pady=5, anchor=tk.CENTER)
label_1.fit_height()
label_2=HTMLLabel(root, html=html_2)
label_2.pack(padx=125, pady=5)
label_2.fit_height()
root.mainloop()

此例在視窗中放置兩張圖片, 第一張圖片來源為本機程式目錄下的 orchid.jpg; 第二張是放在 GitHub 的同一張圖片, 分別使用 anchor 與 padx 參數來控制圖片置中, 結果如下 :




可見 anchor 參數對於 HTMLLabel 上的圖片沒有作用, 只能用 padx 調整其水平位置. 另外, 用來給圖片加上文字標題的 figure 標籤目前也尚未支援. 


5. 顯示表格 (支援度不全) :  

tkhtmlview 對表格的支援還不足 (雖然 colspan 合併儲存格似乎可用), 連 border 都無法顯示, 例如下列範例 :


測試 5 : 在 HTMLLabel 元件上顯示表格

import tkinter as tk
from tkhtmlview import HTMLLabel

root=tk.Tk()
root.geometry('800x400')
html='''
    <table border=1>
      <caption><strong>表格標題</strong></caption>
      <tr>
        <th>第1欄</th>
        <th>第2欄</th>
        <th>第3欄</th>
      </tr>
      <tr>
        <td>第1列第1欄</td>
        <td>第1列第2欄</td>
        <td>第1列第3欄</td>
      </tr>
      <tr>
        <td>第2列第1欄</td>
        <td>第2列第2欄</td>
        <td>第2列第3欄</td>
      </tr>
      <tr>
        <td colspan="3">合併儲存格</td>
      </tr>
    </table>'''
label=HTMLLabel(root, html=html)
label.pack(padx=5, pady=5)
label.fit_height()
root.mainloop()

此處用 border=1 設定表格邊框但無作用, 結果如下 : 




希望未來 tkhtmlview 可以完善對 TABLE 的支援. 


6. 用 HTMLText 與 HTMLRender 顯示 HTML 檔內容 :  

除了用 HTMLLabel 元件直接顯示 HTML 碼外, tkhtmlview 也可以透過 HTMLRender 物件讀取外部 HTML 檔顯示於視窗中, 例如下面這個網頁 index.htm :


測試 6 : 用 HTMLRender 與 HTMLText 讀取 HTML 檔

<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
  </head>
  <body>
    <h2>Hello World!</h2>
    <img src="orchid.jpg">
  </body>
</html>

將其存放於程式工作目錄下, 然後執行下列程式碼 :

import tkinter as tk
from tkhtmlview import HTMLText, RenderHTML

root=tk.Tk()
root.geometry('800x500')
label=HTMLText(root, html=RenderHTML('index.htm'))
label.pack(padx=5, pady=5)
label.fit_height()
root.mainloop()

結果如下 :




但目前 HTMLRender 物件僅限於顯示英文資料, 無法像 HTMLLabel 那樣顯示中文內容, 例如若若將上面 index.htm 檔的 H2 標籤內容從 "Hello World!" 改為 "你是在說哈囉嗎?" 時就會顯示如下錯誤 : 

UnicodeDecodeError: 'cp950' codec can't decode byte 0xa0 in position 142: illegal multibyte sequence

可能 HTMLRender 物件尚未支援 Unicode. 

2023年6月23日 星期五

Python 學習筆記 : 如何設定 Tkinter 視窗的圖標 (icon)

Tkinter 視窗預設的圖標 (icon) 是一個羽毛, 以下面簡單的空視窗程式為例 :

import tkinter as tk
from tkinter import ttk

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('測試') # 設定視窗標題
root.geometry('300x200')  # 設定視窗尺寸

root.mainloop()

執行後可看到視窗左上角的預設羽毛圖標 :




如果要更改為自訂的圖標該怎麼做? 首先用小畫家或其他圖像編輯軟體製作一個 16*16 的圖檔, 例如我用 Tony 的 T 作為圖標 (logo.bmp) : , 然後用線上轉檔軟體轉成 .ico 檔, 例如 :


雖然此網站標題寫 'png 轉 ico', 其實 jpg 或 bmp 等都是可以的 : 


轉完後下載 logo,ico, 將其放在程式目錄下, 然後改寫程式, 呼叫根視窗物件的 iconbitmap() 方法並傳入圖標的檔名即可 :

import tkinter as tk
from tkinter import ttk

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('測試') # 設定視窗標題
root.geometry('300x200')  # 設定視窗尺寸
root.iconbitmap('logo.ico')  
root.mainloop()

結果如下 :




2023-07-16 補充 :

如果程式搬移時 .ico 檔沒有隨同搬移, 則執行時會因為讀不到檔案而發生例外, 因此需用 try except 捕捉, 例如 :

import tkinter as tk
from tkinter import ttk

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('測試') # 設定視窗標題
root.geometry('300x200')  # 設定視窗尺寸
try:  # 避免沒有 logo 時錯誤
    root.iconbitmap('logo.ico')  
except Exception as e:
    pass
root.mainloop()

這樣當 logo.ico不存在時就會使用預設的 .ico. 

2023年6月22日 星期四

Python 學習筆記 : Tkinter 的版面布局容器 Frame 與 PanedWindow

雖然 Frame 物件我還沒時間做一個較完整的測試, 但這周在用 Tkinter 開發公司的維運小軟體時直接拿來上陣使用, 發覺用 Frame 元件搭配最簡單常用的 pack 版面布局也可以將會疊串在一起的 Entry, RadionButton, CheckButton, 與 Button 等元件水平擺放得很整齊.

下面以簡化的系統登入與操作按鈕等元件來說明 Frame 在排版上的妙用. 視窗中包含帳號密碼輸入框, 登入按鈕, 指令輸入框, 執行按鈕, 以及一個執行結果文字區域等元件 : 

 
測試 1 : pack() 的堆疊式的版面布局 

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('Frame + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
account=tk.Entry(root, width=10)  # 帳號輸入框
account.pack()  # 元件放入視窗
password=tk.Entry(root, width=10, show='*')  # 密碼輸入框
password.pack()  # 元件放入視窗
login_btn=tk.Button(root, text='登入', command=login)  # 登入按鈕
login_btn.pack()  # 元件放入視窗
command=tk.Entry(root, width=20)  # 指令輸入框
command.pack()  # 元件放入視窗
run_btn=tk.Button(root, text='執行', command=run)  # 登入按鈕
run_btn.pack()  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

結果如下 : 




可見 pack 版面管理員的布局方式基本上就是由上而下堆疊, 雖然可以在呼叫 pack() 時傳入 side=tk.RIGHT/LEFT/TOP/BOTTOM 指定上下左右之垂直水平位置, 或傳入 anchor=tk.E/W/S/N/CENTER/NW/NE/SW/SE 來定位元件在父容器中的位置, 但光用 side 與 anchor 參數無法達成讓上方輸入框與按鈕元件以水平方式排在最上方, 且登入在左, 執行在右的需求. 

解決辦法之一是使用 Frame 容器來放置元件, 並且每個元件在呼叫 pack() 時傳入 side=tk.LEFT 指定由左向右水平排列, 版面結構如下圖所示 :




測試 2 : 改用 Frame 來當元件容器 

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('Frame + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
frame=tk.Frame(root)  # 外部 Frame 元件
frame.pack()
# 建立以 Frame 為父容器的元件
account=tk.Entry(frame, width=10)  # 帳號輸入框
account.pack(side=tk.LEFT)  # 元件放入視窗
password=tk.Entry(frame, width=10, show='*')  # 密碼輸入框
password.pack(side=tk.LEFT)  # 元件放入視窗
login_btn=tk.Button(frame, text='登入', command=login)  # 登入按鈕
login_btn.pack(side=tk.LEFT)  # 元件放入視窗
command=tk.Entry(frame, width=20)  # 指令輸入框
command.pack(side=tk.LEFT)  # 元件放入視窗
run_btn=tk.Button(frame, text='執行', command=run)  # 登入按鈕
run_btn.pack(side=tk.LEFT)  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

此例先建立一個 Frame 元件, 然後先後建立以此 Frame 元件為父容器 (不是 root 了) 的輸入框 Entry 與 Button 按鈕等元件, 但 Text 元件依然是以 root 為父元件. 結果如下 :



 
可見輸入框與按鈕都在 Frame 內呈水平排列. 注意, Frame 內的所有元件呼叫 pack() 時均傳入 tk.LEFT, 這表示元件是在 Frame 內由左向右依序排列之意, 並非定位在左邊 (定位要用 ANCHOR 參數); 同理, tk.RIGHT 表示由右向左排列. 如果將上面範例的 command 與 run_btn 兩個元件的 pack() 改為傳入 tk.RIGHT 會讓此兩元件順序顛倒 : 


測試 3 : 呼叫 pack() 傳入 side=tk.RIGHT 為由右向左排列

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('Frame + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
frame=tk.Frame(root)  # 外部 Frame 元件
frame.pack()

account=tk.Entry(frame, width=10)  # 帳號輸入框
account.pack(side=tk.LEFT)  # 元件放入視窗
password=tk.Entry(frame, width=10, show='*')  # 密碼輸入框
password.pack(side=tk.LEFT)  # 元件放入視窗
login_btn=tk.Button(frame, text='登入', command=login)  # 登入按鈕
login_btn.pack(side=tk.LEFT)  # 元件放入視窗
command=tk.Entry(frame, width=20)  # 指令輸入框
command.pack(side=tk.RIGHT)  # 元件放入視窗
run_btn=tk.Button(frame, text='執行', command=run)  # 登入按鈕
run_btn.pack(side=tk.RIGHT)  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

此例程式碼與上面不同之處僅在於 command 與 run_btn 這兩個元件呼叫 pack() 時傳入 side=tk.RIGHT 而已, 結果兩個元件就由右向左排列 :




所以放入 Frame 容器的元件呼叫 pack() 時一律傳入 side_tk.LEFT 即可. 

如果要讓這些 Frame 內水平排列的元件有所區隔, 可以用一個無顯示字串的 Label 來當隱形的佔位元件, 可透過 width (字元數, 不是 px) 來控制間隔距離, 例如 :


測試 4 : 利用無顯示字串的 Label 元件佔位

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('Frame + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
frame=tk.Frame(root)  # 外部 Frame 元件
frame.pack()

account=tk.Entry(frame, width=10)  # 帳號輸入框
account.pack(side=tk.LEFT)  # 元件放入視窗
password=tk.Entry(frame, width=10, show='*')  # 密碼輸入框
password.pack(side=tk.LEFT)  # 元件放入視窗
login_btn=tk.Button(frame, text='登入', command=login)  # 登入按鈕
login_btn.pack(side=tk.LEFT)  # 元件放入視窗
placeholder=tk.Label(frame, width=10)  # 佔位用的 Label
placeholder.pack(side=tk.LEFT)
command=tk.Entry(frame, width=20)  # 指令輸入框
command.pack(side=tk.LEFT)  # 元件放入視窗
run_btn=tk.Button(frame, text='執行', command=run)  # 登入按鈕
run_btn.pack(side=tk.LEFT)  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

此例在 login 鈕與 command 輸入框之間插入一個無顯示字串 (即 text='') 且設定 width=10 的 Lable 元件當佔位元件 (空元件), 這樣就在兩元件之間製造了 10 個字元寬度的間隔, 結果如下 :




注意, 間隔寬度若設太大, 撐破 Frame 的寬度時, 後面的元件可能會跳到下一行而影響版面外觀. 

除了 Frame 容器外, 也可以用 PanedWindow 來做版面布局, 茲將上面範例中的 Frame 改成 PanedWindow, 結果相同 :


測試 5 : 使用 PanedWindow 排版 

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('pw + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
pw=tk.PanedWindow(root)  # 外部 pw 元件
pw.pack()

account=tk.Entry(pw, width=10)  # 帳號輸入框
account.pack(side=tk.LEFT)  # 元件放入視窗
password=tk.Entry(pw, width=10, show='*')  # 密碼輸入框
password.pack(side=tk.LEFT)  # 元件放入視窗
login_btn=tk.Button(pw, text='登入', command=login)  # 登入按鈕
login_btn.pack(side=tk.LEFT)  # 元件放入視窗
placeholder=tk.Label(pw, width=10)  # 佔位用的 Label
placeholder.pack(side=tk.LEFT)
command=tk.Entry(pw, width=20)  # 指令輸入框
command.pack(side=tk.LEFT)  # 元件放入視窗
run_btn=tk.Button(pw, text='執行', command=run)  # 登入按鈕
run_btn.pack(side=tk.LEFT)  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

此例建立一個 PanedWindow 物件 pw, 然後底下程式碼中的 frame 全部改成 pw, 結果如下 :




可見結果與上面用 Frame 排版的效果一模一樣. 

除了直接呼叫元件的 pack() 方法將元件加入父容器 PanedWindow 物件外, 也可以呼叫該物件的 add() 方法, 它預設也是用 tk.LEFT 由左向右將元件放入容器中, 例如 :


測試 6 : 使用 PanedWindow 的 add() 方法將元件加入容器 

import tkinter as tk
from tkinter import ttk

def login():
    pass

def run():
    pass

# 建立視窗
root=tk.Tk()  # 建立視窗物件
root.title('pw + Pack 布局測試') # 設定視窗標題
root.geometry('800x600')  # 設定視窗尺寸

# 建立登入介面與指令輸入介面
pw=tk.PanedWindow(root)  # 外部 pw 元件
pw.pack()

account=tk.Entry(pw, width=10)  # 帳號輸入框
pw.add(account)  # 元件放入視窗
password=tk.Entry(pw, width=10, show='*')  # 密碼輸入框
pw.add(password)  # 元件放入視窗
login_btn=tk.Button(pw, text='登入', command=login)  # 登入按鈕
pw.add(login_btn)  # 元件放入視窗
placeholder=tk.Label(pw, width=10)  # 佔位用的 Label
pw.add(placeholder)
command=tk.Entry(pw, width=20)  # 指令輸入框
pw.add(command)  # 元件放入視窗
run_btn=tk.Button(pw, text='執行', command=run)  # 登入按鈕
pw.add(run_btn)  # 元件放入視窗
# 建立執行結果文字區域
result=tk.Text(root)
result.pack(fill=tk.BOTH, expand=True)
result.pack()

root.mainloop()

結果與上面範例相同.