前情提要:在尋找怎麼用Python寫有GUI的小程式的時候找到了可以用tkinter

本來我最熟悉的GUI應該只有網頁了,但是要求的環境是單機,想說沒有必要這樣弄出一個單機的網頁,這樣很麻煩,而且上次的經驗是,我的開發經驗太少,做網頁會有不同的瀏覽器環境跟作業系統,這樣要多寫很多tags(我都手code…),在學會用方便一點的套件之前,還是不要好了。另一個主要原因是跟我一起合作的夥伴比較熟系Python。

Python tkinter安裝

聽起來很蠢,但是我們在安裝的時候就發生問題了。tkinter的模組是依附在Python裡面的,所以理論上不需要用pip多安裝東西。我們都是使用python3

我跟夥伴總共有三個開發環境是Ubuntu on windows,windows 10 , MacOS,在三個環境下執行都不一樣

Ubuntu上面是執行

apt-get install python3-tk

MacOS 跟 windows10理論上都不用安裝,但是我在MacOS使用scroll的功能的時候會出現這個錯誤

self.tk.mainloop(n) UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: incalid start byte

查了一番 Stackoverflow一番 ,發現是Homebrew在搞事,某一個由Homebrew發布的python3裡面的Tk/Tcl是有錯的。於是我就更新了一下Homebrew

brew update
brew upgrade python3

但是怎麼搞的都會出現一樣的錯誤 我後來解決方法是…把brew版本的python3弄掉,用Xcode原生提供的Framework

brew uninstall python3

確認一下是由Xcode提供的python

> where python3
/Library/Frameworks/Python.framework/Versions/3.8/bin/python3
/usr/local/bin/python3
/usr/bin/python3

原本以為沒事了,結果在win10上面測試的時候又出現問題了,我要安裝Python時出現問題

因為電腦上只裝了python2,在Powershell上面執行python3會直接打開Microsoft store的python3.7頁面然後要我安裝。但是不知道怎樣Microsoft store下載之後必定安裝錯誤,完全搞不懂,微軟搞事= =。

最後我用Python官網下載以後安裝,然後要記得修改環境參數。 在windows下,使用GUI改動環境參數比較簡單,搜尋「環境變數」,點選「編輯系統環境變數」,或是從

設定 > 系統 > 關於 > 系統資訊 > 變更設定 > 環境變數 > PATH > 編輯

這樣設定會比在Powershell上面下指令好懂也方便,主要是windows的制令超難用…

經過一番折磨終於可以使用Tkinter了。

Tkinter 簡介

TKinter是古老的GUI指令Tk/Tcl在python環境下的module,可以參考的文件都滿破碎的,這邊記下,之後可以供自己參考。

Tkinter的基本觀念是由物件組成的,每個東西都是小部件(widgets),呼叫後對每個物件操作。 然後對應物件,將物件掛在物件上面,之後才會講到如何定位

import tkinter as tk # Ptyhon3.x tkinter, Python2.x Tkinter
from tkinter import ttk
app = tk.Tk()
# Frame created under app
frame = ttk.Frame(app)
# Button created under Frame
button = ttk.Button(frame,text="button",ommand=func())

但是由於Python是直譯的語言,不像是HTML這樣的tag包起來,不容易讀物件在哪裡,而且tkinter做出的物件還會需要再加上各種函式、調整位置的時候也要對那個物件操作,所以code會很醜,主要呼叫物件的部分一定要跟其他運算e.g.按下按鈕的操作之類的分開,不然整理畫面跟函式的時候會崩潰。

那tk跟ttk的主要差異是,ttk是themed tk,也就是比較好看,可以對ttk的物件加上style,同時大量的改變外觀,不然tk預設的外觀真的很醜。

Widgets

這邊就記些widgets,主要是我們這次有用的。沒用到的請參考Tkdocs,是我找到目前最完整的資源。另外全部有哪些widgets請參考Tk/Tcl documents或是用Python內建的dir()來列出來。

  • Tk
    • the main Tk
    • # creating the application main window and its name
      app = tk.Tk()
      app.geometry('600x300')
      app.title("Color2Texture")
      
  • Frame
    • like <div> in HTML, for containing widgets together, easy to organize groups of widgets, not visible default
    • # the Frame is under app, the main window
      frame = ttk.Frame(app)
      
  • Button
    • Just a button can change text on it and append function on it
    • # text shows the woords on the button,command if the button is pressed
      load = ttk.Button(frame, text="Load", command=lambda: load_func(event))
      
  • CheckButton
    • [] just a check button
    • # var, variablet that the checkbutton controls,on/offvalue,command
      cont = ttk.Checkbutton(frame, text="Contour", var=cont_state, onvalue=1, offvalue=0, command=lambda: chk_contour(cont_state))
      
  • Scale
    • A scale that can be dragged, can be vertical or horizontal
    • # similar to checkbutton, orient for ver/hor,from_/to,variable as variable
      canny = ttk.Scale(frame, orient="horizontal", from_=1, to=100, variable=var_canny, command=lambda var_canny: adjst_edge(var_canny))
      
  • Label
    • A text block that shows text, not pretty sure the difference with text
    • label_scl = ttk.Label(frame, text="the text used for scale")
      
  • Canvas
    • canvas only supports tk, because it can’t be themed I supposed.
    • a canvas bloack that can draw and create graphics, too complicated noted under.
    • # height and width is for the griding systeml,not px. bg for background's color,can be "black" or "#AAA", "#a145ff"
      canvas = tk.Canvas(frame, , height=4, width=4, bg="#AAA")
      

Basic tkinter concepts

接近寫完自己的小app,打這篇文章的時候才找到其實tkinter book有線上…可以參考tkinterook introduction 下面就記下我自己整理的

架構 Structure

Object structure

Main window(`Tk()`)
├─── Widgets (share the same grid)
│     └ widgets (share the same grid under same parent)
│
├────New Windows `Toplevel()`
│
└─── Standard dialogs

Coding structure

initial part:
	# create the app
	app = tk.Tk()
	# call the widget,can init options or command if the widget is modifyed
	widget = tk.widget(parent,options, command= func())
	# modify options
	widget.configure(option='value')
	widget['option']='value'
	# bind the fuctions on buttons
	# WARNING: Buttons differs by OS, please check
	widget.bind("<Button-1>",func())
	# bind more fuction on same button and widget
	widget.bind("<Button-1",func2(),add="+")
	# Show the widgets by calling grid()
	# also you can modify their position by grid
	widget.grid(row=1,column=1)

at last:
	# This starts up everything
	app.mainloop()

# out of main loop
function part:
	# user did some aciton
	def func():
		# change some widget
		widget.standardfunction(args)
		widget.configure(option='value')
		# new widget
		widget = tk.widget(parent,options, command= func())
		# call tkinter(usually the main app Tk()) to update the change, if necessary
		app.update_idletasks()
		# new Window
		new_window = Toplevel()
		# may have to use update tu pop up the new windows if there are tasks after, be carefule with update()
		new_window.update()
		# some tasks running
		pass

Tkinter的架構大致上就是這樣,物件上的層級沒有很複雜,通常在主要的Tk之下有很多widgets,你可以輕易的用呼叫建立他們,然後綁到一些函式,這樣基本的GUI app就完成了。

為了定位方便,可以利用frame來區分不同區塊的物件,這樣較容易同時對多個物件做事。

function的部分我猜測tkinter的運作模式是

mainloop --call--> function --done-> mainloop

這樣的方式,就是主要的回圈會跳出去執行完function之後再會來,而GUI的更新會在回到主要迴圈才做,所以如果在外面的function寫一些UI更新的東西,注意,他會在函式結束才更新畫面。所以會需要特別用update_idletasks()來更新畫面。

Commands , basic Tk to Tkinter

這段是參考python3 docs

Tkinter 是Tk/Tcl在python上面的實作module,所以在找不到文件的時候可以去翻Tk/Tcl的文件,對我幫助滿大的。

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox
object_name = tk.Widget(parent,option='value',option='value',command=func())
object_name = ttk.Widget(parent,option='value',option='value',command=func())
object_name.standardfunction()
# Standard windows
warning = messagebox.showwarning(message="WARNING: this is a warning")

options 參數

TKinter的widgets通常都有很多參數可以設定,除了在呼叫的時候定義以外,有兩種改變的方式

# set the option
widget['option'] = value
# standard function configure
widget.configure(option=value)
# show the value
print(widget['option'])

參數改動後,tkinter會更新這個物件。

Event handling 事件處理

按鈕按下應該要有改變,不然就是空的UI而已。我們會需要在物件上面綁定函式,除了一開始定義的,可以在不同按鈕按到時有不同的函式。實際上tkinter運作是有事件迴圈event loop在等待這個事件發生,如果發生了就會有一個callback,而我們就會將對應的code放在這個callback內。所以在無限迴圈中盡量不要呼叫無限迴圈,除非你知道自己在幹嘛。

button = ttk.Button(app,text="button",lambda event: command=func(event))
# bind to button1
button.bind(<Button-1>, button.configure(text="I am clicked"))
# add different funciton to same event 
# covers the before fuctions if add="+" is not givrn.
button.bind(<Button-1>, func2(button), add="+")

Geometry manager 繪製畫面

Tkinter的畫面管理不太直觀,相較我習慣的HTML配上CSS,他有三種管理畫面的方式,我這次幾乎全部都用frame+grid()來完成。下面也會說明。

1. pack()

稍舊的tkinter會看到pack(),它的用處是讓tkinter知道要重新render他的大小,依照裡面的字體等等,然後顯示在畫面上。如果沒有調整,所有東西都是預設大小

但是他依靠的是相對位置,然後有下面幾種狀況

  1. f填滿他的容器(frame或是主畫面等容器),參數fill:有以下的值:BOTH,X,Y,參數expand:非零參數代表需要多餘空間
  2. 上下疊放
  3. 左右擺放
label = ttk.Label(app,text="label")
label1 = ttk.Label(app,text="label1")
label2 = ttk.Label(app,text="label2")
# fill the whole container
label.pack(fill=BOTH, expand=1)
# do nothing the stack with others(up and down)
label1.pack()
# side by side with other widgets
label2.pack(side=LEFT)
button = ttk.Label(app,text="button")
button.pack(side=LEFT)

2. grid()

我幾乎都用這個,因為他比較直覺也比較好控制,他的概念是用表格直行橫列來擺放,你指定他在哪一格,grid()就會自動幫你把預設大小的物件置中的擺在你定義的行列,而沒有任何物件的空行列會被忽略。他的概念跟HTML的table一樣,較容易理解。

語法大概如下

label = ttk.Label(app,text="label")
label1 = ttk.Label(app,text="label1")
e1 = Entry(app)
e2 = Entry(app)
label.grid(row=0,column=0)
# row and column defaults to 0
label.grid(row=1)
e1.grid(row=0,column=1)
e1.grid(row=1,column=1)

這會創造四格的畫面,分別填入物件。

預設的物件都會置中在自己的空格當中,所以如果想要偏向哪邊的話要用sticky這個選項,而使用的值是N,S,W,E,非常的智障….,會什麼不用上下左右要用東西南北完全搞不懂。可以四個方向都篇向,這樣沒有固定大小的物件就會自動的展開到填滿

# stick to left
label.grid(row=0,sticky="w")
# stick to all direction
label1.grid(row=0,sticky="nswe")

有些文件會說要用N+W+E+S這樣的語法,但是我執行時會出錯。

如同<table>你也可以橫跨好幾個空格

# add a image at the right which is 2 rows tall
image.grid(row=0,column=2,rowspan=2)
# or sth 3 columns width
label2.grid(row=2,column=0,columnspan=3)

3. place()

我在寫這篇的時候才查到這個,模式是定義絕對座標,可以參考這裡tkinterbook。因為我也沒用過就直接參考記下

Canvas and scrollbar

這段是參考tkdocs canvas

Canvas是tkinter裡面比較特別的物件,他可以如同畫布在上面畫圖,是操作空間比較大的物件。可以在上面建立基本圖形,如方塊、圓形、線段都是可行的,而我們這次用到是在上面呈現圖案,並且可以在上面畫線。為了方便使用,圖案是可以放大縮小的,所以內容是會比canvas的大小還大,這時候我們就需要用到滾動條,tkinter是沒有內建的,所以如果物件內部的內容比預設的大小還多,就會發生看不到的狀況。除了利用bind()的full,或是grid()的sticky選項填滿容器物件以外,還是會發生需要捲動的情況就會用到捲動條了,要呼叫scrollbar這個物件並且對應到需要捲動的物件上。

定義的時候如下:

# parent is a frame
canvas = tk.Canvas(canvas_frame, height=4, width=4, bg="#AAA")
canvas_h_scroll = ttk.Scrollbar(canvas_frame, orient=HORIZONTAL)
canvas_v_scroll = ttk.Scrollbar(canvas_frame, orient=VERTICAL)
canvas['scrollregion'] = (0,0,0,0)
canvas['yscrollcommand'] = canvas_v_scroll.set
canvas['xscrollcommand'] = canvas_h_scroll.set
canvas_h_scroll['command'] = canvas.xview
canvas_v_scroll['command'] = canvas.yview
canvas_h_scroll.grid(row=1,column=0, sticky="we")
canvas_v_scroll.grid(row=0,column=1, sticky="ns")

上面這段定義出了Canvas本身和兩個scrollbar的widget,grid的部分就在canvas的右邊下邊加上各別直的橫的卷軸。

scrollregion這項變數是宣告canvas可捲動的區域,也就是定義一個矩形範圍的左上右下1是可以捲動的區域,如果小於canvas的大小,什麼事情都不會發生,而大於canvas的大小,scrollbar就會可以拖曳canvas裡面這個矩形。可以理解為定義出canvas內容的真正大小。

Create items on canvas

當我們有canvas的時候就可以在上面畫圖了,因為我們只要畫簡單的線,所以如果是畫上矩形、圓形等形狀請參考其他資料,基本概念是一樣的。

# bind the funcitons
canvas.bind("<Button-1>",lambda event, canvas=canvas,item=0: xy(event, canvas))
canvas.bind("<B1-Motion>",lambda event, canvas=canvas: add_line(event, canvas))
canvas.bind("<B1-ButtonRelease>",lambda event, canvas=canvas: done_stroke(event, canvas))

# use to identify the actual x,y on canvas that is pressed
lastx, lasty = 0,0
radius = 10
def xy(event,canvas):
    global lastx,lasty,radius
    lastx, lasty = canvas.canvasx(event.x),canvas.canvasy(event.y)
    # draw the circle first
    canvas.create_oval((lastx-radius, lasty-radius, lastx+radius, lasty+radius,),fill="white", tags='eraser',outline="")

# add line by white to be a eraser
def add_line(event, canvas):
    global lastx,lasty,radius
    x, y = canvas.canvasx(event.x),canvas.canvasy(event.y)
    # the tkinter draw
    canvas.create_line((lastx,lasty,x,y), fill="white",width=radius*2,tags='eraser')
    canvas.create_oval((x-radius, y-radius, x+radius, y+radius,),fill="white", tags='eraser',outline="")
    lastx, lasty = x, y

# Do something when the line is done
def done_stroke(event, canvas):
	# draw the circle last
	global lastx,lasty,radius
	canvas.create_oval((lastx-radius, lasty-radius, lastx+radius, lasty+radius,),fill="white", tags='eraser',outline="")
    # whe configure the line after that 
    pass

首先,因為我們的canvas是可以捲動的,所以得到的座標需要利用canvasx() canvasy()這兩個來得到canvas上面正確的座標,這樣畫布移動的時候他才會畫在正確的地方。然後我們偵測滑鼠移動的時候就會畫出線段,create_line()這個函式是可以給一長串座標最後在畫線的,這樣畫出的線條會必較圓潤也不會因為大轉折破碎。但是因為我們需要的是及時可以看到的線條,所以我們的做法是畫上圓圈,在點下去跟放開滑鼠的時候畫上圓圈,中間畫的時候也加上這樣的線條看起來就會非常圓滑。

其他創建圖形基本上也差不多

canvas.create_item((x,y)...,option='value'...)

有這些東西可以創建:

  • ARC ITEMS
  • BITMAP ITEMS
  • IMAGE ITEMS
  • LINE ITEMS
  • OVAL ITEMS
  • POLYGON ITEMS
  • TEXT ITEMS
  • WINDOW ITEMS

所以可以在canvas做出很多變化。

Canvas tag

那上面那段可以看到我們給我們的線段加上tag="eraser"這樣的tag,所有在canvas建立的圖形、線都是物件,所以你可以對單一的圖案進行刪除、上下疊加的不同。那最方便的就是加上很像HTML的class的東西,進行一次多項的變更。

例如你想對建立的白色線段(還有圓圈)改變粗細、顏色或是刪除它們,可以這樣做

# change width,doesn't work with oval
canvas.itemconfigure('eraser',width=1)
# change color
canvas.itemconfigure('eraser',fill="blue")
# delete all items with tag 'eraser'
canvas.delete('eraser')

如果想對tag做操作也是可以的

# delete the tag with the items selected
# dtag(tagOrId,tagToDelet)
# delete eraser tag on all items
canvas.dtag('all','eraser')
# add tags see manpage for more info
# add white tag to items with eraser tag
canvas.addtag('white','withtag','eraser')

更強的是,可以在這些items上bind函式,也就是理論上可以用canvas畫出整個UI介面,不過這樣就比較難管理

cavas.tag_bind('eraser',"<Button-1", lambda x: func(x))

Theme

this will be finished, last edited at 2020/2/29, I may want to move the Theme section to another post.