tkinter پیشرفته
در فصل قبل «پنجرهای با چند دکمه» ساختیم. در این فصل به یک برنامه دسکتاپ واقعی میرسیم: باز و ذخیره کردن فایل، تصویر، جدول، پنجره دوم، زمانسنج، و در پایان یک برنامه مدیریت کتابخانه که روی پایگاه داده کار میکند.
- کلاس، به جای global
- باز کردن فایل و ویجت Text
- دفترچه یادداشت: ذخیره و تغییرات ذخیرهنشده
- تصویر و دام نامرئی شدن
- لغزنده و جعبه چرخشی
- ttk و فهرست بازشو
- زبانهها و نوار پیشرفت
- تنظیمات پنجره
- وضعیت ویجتها
- پنجره دوم: دیالوگ مودال
- جدول Treeview
- بوم نقاشی و رویدادهای ماوس
- after و قانون طلایی
- پروژه: مدیریت کتابخانه
۱. کلاس، به جای global
در فصل قبل توابع پاسخ را داخل main() مینوشتیم تا
ویجتها را ببینند. برای برنامههای بزرگتر راه بهتری هست: کل پنجره را
در یک کلاس بگذاریم. ویجتها صفت شیء میشوند
(self.text_area) و همه متدها به آنها دسترسی دارند، بدون
حتی یک global.
class FileViewer:
'''A tiny read-only viewer for text files.'''
def __init__(self, root: tk.Tk) -> None:
self.root = root
self.root.title('File viewer')
self.root.geometry('600x500')
...
def open_file(self) -> None:
...
def main() -> None:
root = tk.Tk()
FileViewer(root)
root.mainloop()
ساختار استاندارد این فصل
از این به بعد همه نمونههای این فصل همین شکل را دارند. این همان شیءگرایی فصل بیست و یکم است که حالا کاربرد واقعی پیدا کرده: یک شیء که هم داده دارد (ویجتها و وضعیت برنامه) و هم رفتار (متدهای پاسخ).
۲. باز کردن فایل و ویجت Text
import tkinter as tk
from tkinter import filedialog, messagebox
class FileViewer:
def __init__(self, root: tk.Tk) -> None:
...
# A frame keeps the text area and its scrollbar side by side.
frame = tk.Frame(root)
frame.pack(fill='both', expand=True, padx=10, pady=10)
scrollbar = tk.Scrollbar(frame)
scrollbar.pack(side='right', fill='y')
# wrap='none' = do not break long lines; that is what a code viewer wants.
self.text_area = tk.Text(frame, wrap='none',
yscrollcommand=scrollbar.set)
self.text_area.pack(side='left', fill='both', expand=True)
# Second half of the connection: moving the bar scrolls the text.
scrollbar.config(command=self.text_area.yview)
def open_file(self) -> None:
'''Ask for a file and show its content in the text area.'''
# Each filter is a (label, pattern) pair.
file_path = filedialog.askopenfilename(
title='Open',
filetypes=[('Python files', '*.py'),
('Text files', '*.txt'),
('All files', '*.*')],
)
# Cancel gives back an empty string.
if not file_path:
return
try:
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
except OSError as error:
# A missing file, a folder, or no permission: never crash the GUI.
messagebox.showerror('Cannot open the file', str(error))
return
except UnicodeDecodeError:
messagebox.showerror('Cannot open the file',
'This file is not UTF-8 text (a picture?).')
return
# '1.0' is the start, tk.END is the end: clear, then insert.
self.text_area.delete('1.0', tk.END)
self.text_area.insert('1.0', content)
self.root.title(f'File viewer - {file_path}')
نمایشگر فایل: نمونه 01_open_file/app.py
شرح مثال
askopenfilenameپنجره استاندارد «باز کردن» سیستمعامل را نشان میدهد و مسیر انتخابشده را برمیگرداند. اگر کاربر انصراف بزند، رشته خالی میدهد. همیشه بررسی کنید.- ویجت
Textچندخطی است و موقعیتهایش رشتههایی به شکل'سطر.ستون'هستند. سطرها از یک شمرده میشوند، پس ابتدای متن'1.0'است نه'0.0'. - نوار پیمایش باید در هر دو جهت وصل شود: از ویجت به نوار با
yscrollcommand، و از نوار به ویجت باcommand. با یک طرف، نوار حرکت میکند ولی متن نه. - در برنامه گرافیکی هرگز نباید خطا به شکل ردیابی خام در ترمینال بیفتد. کاربر ترمینال را نمیبیند. هر خطا باید به یک
messageboxتبدیل شود.
۳. دفترچه یادداشت: ذخیره و تغییرات ذخیرهنشده
def _is_modified(self) -> bool:
'''True when the text changed since the last save.'''
return bool(self.text.edit_modified())
def _mark_saved(self) -> None:
'''Tell Tk "the current content is the saved content".'''
self.text.edit_modified(False)
def _confirm_discard(self) -> bool:
'''Ask before losing unsaved work. True = it is safe to continue.'''
if not self._is_modified():
return True
answer = messagebox.askyesnocancel(
'Unsaved changes',
'Save the changes before continuing?')
if answer is None: # Cancel
return False
if answer: # Yes -> save first
return self.save_file()
return True # No -> throw the changes away
def _write_to(self, path: str) -> bool:
# 'end-1c' = everything except the newline Tk always keeps at the end.
content = self.text.get('1.0', 'end-1c')
try:
with open(path, 'w', encoding='utf-8') as file:
file.write(content)
except OSError as error:
messagebox.showerror('Cannot save the file', str(error))
return False
self._mark_saved()
return True
def on_close(self) -> None:
if self._confirm_discard():
self.root.destroy()
دفترچه: نمونه 02_save_file/app.py
شرح مثال
edit_modified()میگوید آیا متن از آخرین باری که پرچم را صفر کردیم تغییر کرده است. هر ویرایشگر واقعی با همین میفهمد کی باید بپرسد «ذخیره شود؟».askyesnocancelسه پاسخ دارد:True،FalseوNoneبرای انصراف. دقت کنید کهif answer is Noneباید قبل ازif answerبیاید، چونNoneهم نادرستنماست.'end-1c'یعنی «تا انتها منهای یک کاراکتر». Tk همیشه یک خط جدید در انتهایTextنگه میدارد و بدون این ترفند، فایل شما با هر ذخیره یک خط خالی بیشتر پیدا میکند.root.protocol('WM_DELETE_WINDOW', func)دکمه ضربدر پنجره را میدزدد و تابع ما را صدا میزند، پس میتوانیم جلوی بسته شدن را بگیریم.- توابعی که
TrueیاFalseبرمیگردانند، زنجیره تصمیمها را ساده میکنند: «اگر ذخیره موفق بود، ادامه بده».
۴. تصویر و دام نامرئی شدن
'''Showing a picture -- and the famous "my image is invisible" trap.
`tk.PhotoImage` understands GIF, PNG, PGM and PPM out of the box.
(For JPEG you need the external package Pillow: `pip install pillow`.)
THE TRAP
--------
Tk does not keep a Python reference to the image; only the C level of Tk knows
about it. If the only Python name that points to the PhotoImage disappears,
the garbage collector frees the object and the label shows... nothing.
'''
# The picture sits next to this file, so the program works no matter which
# folder you run it from.
IMAGE_PATH = Path(__file__).parent / 'picture.gif'
class ImageWindow:
def __init__(self, root: tk.Tk) -> None:
...
try:
# str() because Tk wants a plain string, not a Path.
self.photo = tk.PhotoImage(file=str(IMAGE_PATH))
except tk.TclError:
# Wrong format or missing file: say so instead of crashing.
tk.Label(root,
text=f'Cannot load {IMAGE_PATH.name}',
fg='red').pack(pady=40)
return
# `self.photo` is what keeps the image alive.
tk.Label(root, image=self.photo).pack(padx=10, pady=10)
تصویر: نمونه 03_image/app.py
Tk خودش هیچ ارجاعی به تصویر نگه نمیدارد. اگر تنها نام پایتونی که
به PhotoImage اشاره میکند از بین برود (مثلاً یک
متغیر محلی که تابع تمام میشود)، زبالهروب شیء را آزاد میکند و
برچسب هیچ چیز نشان نمیدهد. نه خطایی، نه پیامی؛
فقط یک کادر خالی. راه حل: تصویر را روی self یا روی
خود ویجت نگه دارید: label.image = photo.
نکته دوم همان اندازه مهم است:
Path(__file__).parent / 'picture.gif'. مسیر نسبی ساده
به پوشه جاری وابسته است، نه به محل فایل برنامه. اگر
برنامه را از پوشه دیگری اجرا کنید، تصویر پیدا نمیشود. این را در فصل
هجدهم دیدید و در فصل سی و ششم موقع ساخت فایل اجرایی باز به آن
برمیگردیم.
۵. لغزنده و جعبه چرخشی
class FontSizeDemo:
'''A label whose font size follows a slider.'''
def __init__(self, root: tk.Tk) -> None:
self.label = tk.Label(root, text='Sample text', font=('Arial', 14))
self.label.pack(pady=20)
scale = tk.Scale(root, from_=10, to=40, orient='horizontal',
length=250, label='Font size',
command=self.update_font)
scale.set(14) # this also fires update_font once
scale.pack()
def update_font(self, value: str) -> None:
'''Tk passes the slider position as a string.'''
self.label.config(font=('Arial', int(value)))
لغزنده: نمونه 04_scale/app.py
def show_total(self) -> None:
raw = self.spinbox.get()
# The user may have typed anything inside the box.
if not raw.isdigit():
self.result.config(text='Please choose a whole number.', fg='red')
return
count = int(raw)
total = count * self.PRICE
self.result.config(text=f'{count} x {self.PRICE:,} = {total:,} toman',
fg='black')
جعبه چرخشی: نمونه 05_spinbox/app.py
شرح مثال
- تابعی که به
commandیکScaleداده میشود، یک آرگومان میگیرد: مقدار تازه. و آن مقدار همیشه رشته است، مثل'14'، نه عدد. Spinboxهمget()اش رشته میدهد، و بدتر: کاربر میتواند داخل جعبه هر چیزی تایپ کند. پس پیش از تبدیل، اعتبارسنجی لازم است.scale.set(14)علاوه بر تنظیم مقدار اولیه، یک بار هم تابع پاسخ را اجرا میکند. این یعنی وضعیت اولیه صفحه درست نمایش داده میشود.- درسی که در کل این فصل تکرار میشود: هر چیزی که از ویجت بیرون میآید متن است. با آن مثل ورودی کاربر رفتار کنید.
۶. ttk و فهرست بازشو
ماژول tkinter.ttk نسخههای تازهتر چند ویجت را دارد که
شبیه دکمههای خود سیستمعامل به نظر میرسند، و ویجتهایی میآورد که
tkinter ساده اصلاً ندارد:
Combobox، Notebook،
Treeview و Progressbar.
import tkinter as tk
from tkinter import ttk
CITIES = {
'Rasht': 'Gilan',
'Bandar Anzali': 'Gilan',
'Lahijan': 'Gilan',
'Tabriz': 'East Azerbaijan',
'Shiraz': 'Fars',
}
class ComboboxDemo:
'''Choose a city, see its province.'''
def __init__(self, root: tk.Tk) -> None:
ttk.Label(root, text='City:').pack(pady=(20, 5))
self.city_var = tk.StringVar()
combo = ttk.Combobox(root, textvariable=self.city_var,
values=list(CITIES), state='readonly', width=25)
combo.current(0) # select the first entry
combo.pack()
# A virtual event: it fires every time the selection changes.
combo.bind('<<ComboboxSelected>>', self.on_select)
self.answer = ttk.Label(root, text='', font=('Arial', 12))
self.answer.pack(pady=20)
self.on_select() # show the first answer at once
def on_select(self, event: 'tk.Event | None' = None) -> None:
'''`bind` calls us with an event object; `self.on_select()` does not.'''
city = self.city_var.get()
self.answer.config(text=f'{city} is in {CITIES[city]} province.')
فهرست بازشو: نمونه 06_combobox/app.py
شرح مثال
- قاعده ساده این دوره: هر جا
ttkویجت را دارد از آن استفاده کنید، و برایTextوCanvasوListboxازtkساده، چونttkنسخهای از آنها ندارد. state='readonly'جلوی تایپ کردن مقدار دلخواه را میگیرد. بدون آن کاربر میتواند شهری بنویسد که در دیکشنری نیست و برنامه باKeyErrorبمیرد.<<ComboboxSelected>>با دو جفت کروشه، یک رویداد مجازی است. رویدادهای مجازی را باbindمیگیریم، نه باcommand.- پارامتر
event=Noneترفند مفیدی است: همbindمیتواند تابع را با یک آرگومان صدا بزند و هم خودمان بدون آرگومان. combo.current(0)روی فهرست خالی خطا ایجاد میکند. این یکی از باگهای نسخه اصلی برنامه کتابخانه بود.
۷. زبانهها و نوار پیشرفت
class NotebookDemo:
'''A window with three tabs.'''
def __init__(self, root: tk.Tk) -> None:
notebook = ttk.Notebook(root)
notebook.pack(fill='both', expand=True, padx=10, pady=10)
notebook.add(self._build_welcome_tab(notebook), text='Welcome')
notebook.add(self._build_form_tab(notebook), text='Form')
notebook.add(self._build_progress_tab(notebook), text='Progress')
def _build_form_tab(self, parent: ttk.Notebook) -> ttk.Frame:
frame = ttk.Frame(parent)
ttk.Label(frame, text='Name:').grid(row=0, column=0, padx=10, pady=10)
self.name_var = tk.StringVar()
ttk.Entry(frame, textvariable=self.name_var).grid(row=0, column=1)
self.hello = ttk.Label(frame, text='')
self.hello.grid(row=1, column=0, columnspan=2, pady=20)
ttk.Button(frame, text='Say hello',
command=self.say_hello).grid(row=2, column=0, columnspan=2)
return frame
def _build_progress_tab(self, parent: ttk.Notebook) -> ttk.Frame:
frame = ttk.Frame(parent)
self.bar = ttk.Progressbar(frame, length=300, mode='determinate')
self.bar.pack(pady=40)
ttk.Button(frame, text='+10 %', command=self.step_bar).pack()
return frame
def step_bar(self) -> None:
# The bar stops at 100; start again from zero.
self.bar['value'] = (self.bar['value'] + 10) % 110
زبانهها: نمونه 07_notebook/app.py
هر زبانه فقط یک Frame است که به notebook.add() داده میشود، و هر چه بلدیم داخلش کار میکند. توجه کنید که هر زبانه در یک متد جداگانه ساخته شده است. این عادت را از در همین مرحله داشته باشید: یک __init__ دویستخطی، کابوس است.
۸. تنظیمات پنجره
def centre(window: tk.Misc, width: int, height: int) -> None:
'''Place `window` in the middle of the screen with the given size.'''
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = (screen_width - width) // 2
y = (screen_height - height) // 2
# The format is 'WIDTHxHEIGHT+X+Y'.
window.geometry(f'{width}x{height}+{x}+{y}')
وسطچین کردن پنجره: نمونه 08_window_options/app.py
| دستور | وظیفه آن |
|---|---|
geometry('WxH+X+Y') | اندازه و موقعیت روی صفحه |
resizable(False, False) | ممنوع کردن تغییر اندازه، افقی و عمودی |
minsize() و maxsize() | حد و مرز تغییر اندازه |
attributes('-topmost', True) | همیشه روی پنجرههای دیگر |
winfo_screenwidth() | عرض صفحه نمایش، برای وسطچین کردن |
iconify() و deiconify() | کوچک کردن و برگرداندن |
protocol('WM_DELETE_WINDOW', f) | گرفتن دکمه ضربدر پنجره |
پنجرهای که وسط صفحه باز شود، حرفهایتر به نظر میرسد. فرمول ساده است: عرض صفحه منهای عرض پنجره، تقسیم بر دو.
۹. وضعیت ویجتها
دکمه خاکستریشدهای که کاربر نمیتواند فشارش دهد، بسیار مهربانتر از دکمهای است که با فشردن، پیام خطا ایجاد میکند. قاعده: هر چه الان معنا ندارد را غیرفعال کنید و به محض اینکه معنا پیدا کرد فعالش کنید.
def update_state(self) -> None:
'''The button is usable only while the box is ticked.'''
self.submit.config(state='normal' if self.accepted.get() else 'disabled')
def write_log(self, message: str) -> None:
'''A read-only Text has to be unlocked before writing.'''
self.log.config(state='normal')
self.log.insert(tk.END, message + '\n')
self.log.see(tk.END) # scroll down to the new line
self.log.config(state='disabled')
وضعیتها: نمونه 09_enable_disable/app.py
شرح مثال
state='disabled'ویجت را خاکستری میکند و ماوس را نادیده میگیرد.state='readonly'رویEntryوComboboxاجازه خواندن و کپی میدهد ولی تغییر نه.- یک
Textغیرفعال را خود برنامه هم نمیتواند بنویسد. پس الگو همیشه سه قدم است: باز کن، بنویس، دوباره قفل کن. see(tk.END)نمایش را تا آخرین خط پایین میبرد. بدون آن، پیامهای تازه زیر لبه پنجره پنهان میمانند.
۱۰. پنجره دوم: دیالوگ مودال
tk.Tk() فقط یک بار ساخته میشود، برای
پنجره اصلی. هر پنجره دیگری tk.Toplevel(parent) است. سه
فراخوانی، آن را به یک دیالوگ واقعی تبدیل میکنند.
class SettingsDialog(tk.Toplevel):
'''Ask for a user name and a font size; return them, or None on Cancel.'''
def __init__(self, parent: tk.Misc, username: str, size: int) -> None:
super().__init__(parent)
self.title('Settings')
self.resizable(False, False)
# None means "the user cancelled".
self.result: tuple[str, int] | None = None
self.name_var = tk.StringVar(value=username)
self.size_var = tk.IntVar(value=size)
...
self.transient(parent) # keep it above the main window
self.grab_set() # make it modal
# Closing with the X button is the same as pressing Cancel.
self.protocol('WM_DELETE_WINDOW', self.destroy)
def on_ok(self) -> None:
name = self.name_var.get().strip()
if not name:
self.title('Settings - the name cannot be empty')
return
self.result = (name, self.size_var.get())
self.destroy()
دیالوگ: نمونه 10_toplevel_dialog/app.py
def open_settings(self) -> None:
dialog = SettingsDialog(self.root, self.username, self.size)
# Nothing below this line runs until the dialog is closed.
self.root.wait_window(dialog)
if dialog.result is not None:
self.username, self.size = dialog.result
self.refresh()
استفاده از دیالوگ
| فراخوانی | وظیفه آن |
|---|---|
transient(parent) | بالای پنجره والد میماند و با آن کوچک میشود |
grab_set() | بقیه برنامه دیگر کلیک نمیگیرد. یعنی مودال |
wait_window(dialog) | صداکننده تا بسته شدن دیالوگ منتظر میماند |
شرح مثال
- چون منتظر میمانیم، دیالوگ میتواند مقدار برگرداند، درست مثل یک تابع. نتیجه در صفت
self.resultنگه داشته میشود. Noneیعنی کاربر انصراف داد. این قرارداد را در همه دیالوگهایتان یکسان نگه دارید.- دیالوگ از
tk.Toplevelارث میبرد. این جایی است که وراثت فصل بیست و سوم واقعاً به کار میآید. - بستن با ضربدر باید دقیقاً مثل زدن دکمه انصراف عمل کند، وگرنه کاربر گیج میشود.
۱۱. جدول Treeview
ttk.Treeview ویجت جدول است و قلب پروژه پایانی این فصل.
class TableDemo:
'''A searchable, sortable table.'''
def __init__(self, root: tk.Tk) -> None:
self.people = list(PEOPLE) # the real data lives here
self.sort_column = 'name'
self.sort_reverse = False
...
self.tree = ttk.Treeview(root, columns=COLUMNS, show='headings',
selectmode='browse')
for column in COLUMNS:
# `lambda c=column:` freezes the current value of `column`;
# without it every heading would sort by the LAST column.
self.tree.heading(column, text=TITLES[column],
command=lambda c=column: self.sort_by(c))
self.tree.column(column, width=120, anchor='center')
self.tree.pack(fill='both', expand=True, padx=10)
def visible_people(self) -> list[dict]:
'''The rows that match the search box.'''
needle = self.search_var.get().strip().lower()
if not needle:
return self.people
return [person for person in self.people
if needle in person['name'].lower()
or needle in person['city'].lower()]
def refresh(self) -> None:
'''Rebuild the table from scratch: clear, then insert.'''
self.tree.delete(*self.tree.get_children())
rows = sorted(self.visible_people(),
key=lambda person: person[self.sort_column],
reverse=self.sort_reverse)
for person in rows:
self.tree.insert('', tk.END,
values=tuple(person[c] for c in COLUMNS))
self.status.config(text=f'{len(rows)} of {len(self.people)} rows')
def sort_by(self, column: str) -> None:
'''Click the same title twice to reverse the order.'''
if column == self.sort_column:
self.sort_reverse = not self.sort_reverse
else:
self.sort_column = column
self.sort_reverse = False
self.refresh()
جدول: نمونه 11_treeview/app.py
شرح مثال
show='headings'ستون درختی اضافهای را که Treeview به طور پیشفرض سمت چپ میکشد پنهان میکند. تقریباً همیشه لازمش دارید.- الگوی
refreshساده و مطمئن است: همه چیز را پاک کن، بعد از روی داده واقعی دوباره بساز. هم مرتبسازی و هم جستوجو با همین یک متد کار میکنند. - باز هم دام اتصال دیرهنگام:
lambda c=column:. بدونc=columnهمه عنوانها بر اساس آخرین ستون مرتب میکنند. - مرتبسازی با
sorted(key=...)از فصل بیست و ششم انجام میشود، در پایتون، نه در ویجت.
Treeview برای نمایش، همه چیز را به متن تبدیل
میکند. پس item(...)['values'] ممکن است جایی که شما
عدد گذاشته بودید رشته بدهد، یا کلمه yes بدهد جایی که
مقدار واقعی 1 بوده است. داده واقعی را در پایتون نگه
دارید، اینجا در self.people. نسخه اصلی برنامه
کتابخانه دقیقاً همین اشتباه را میکرد و موقع ویرایش یک کتاب، کلمه
yes را در ستون year ذخیره میکرد.
۱۲. بوم نقاشی و رویدادهای ماوس
class PaintApp:
'''A very small drawing program.'''
def __init__(self, root: tk.Tk) -> None:
self.colour = 'black'
self.width = 3
# The point the line is drawn from; None while the button is up.
self.last: tuple[int, int] | None = None
...
self.canvas = tk.Canvas(root, bg='white')
self.canvas.pack(fill='both', expand=True, padx=5, pady=5)
self.canvas.bind('<Button-1>', self.start_stroke)
self.canvas.bind('<B1-Motion>', self.draw)
self.canvas.bind('<ButtonRelease-1>', self.end_stroke)
def start_stroke(self, event: tk.Event) -> None:
self.last = (event.x, event.y)
def draw(self, event: tk.Event) -> None:
if self.last is None:
return
# A stroke is a chain of short straight lines between two mouse events.
self.canvas.create_line(self.last[0], self.last[1], event.x, event.y,
fill=self.colour, width=self.width,
capstyle='round', smooth=True)
self.last = (event.x, event.y)
def end_stroke(self, event: tk.Event) -> None:
self.last = None
نقاشی: نمونه 12_canvas_paint/app.py
| رویداد | کی رخ میدهد |
|---|---|
<Button-1> | کلیک چپ |
<B1-Motion> | حرکت ماوس با دکمه چپ نگهداشته |
<ButtonRelease-1> | رها شدن دکمه چپ |
<Double-1> | دوبار کلیک |
<Return> | کلید Enter |
<Key> | هر کلید |
نکته جالب اینکه «خط» در عملیت وجود ندارد: آنچه میبینید زنجیرهای از پارهخطهای کوتاه بین دو رویداد متوالی ماوس است. متدهای create_line، create_oval، create_rectangle و create_text شناسه آیتم برمیگردانند که بعداً میتوانید با آن آیتم را جابهجا، رنگ یا حذف کنید.
۱۳. after و قانون طلایی
هرگز داخل یک تابع پاسخ، time.sleep()
صدا نزنید و حلقه طولانی اجرا نکنید. تا وقتی تابع شما در حال اجراست،
حلقه رویداد متوقف است: پنجره دیگر بازترسیم
نمیشود، دکمهها جواب نمیدهند، و سیستمعامل میگوید برنامه پاسخ
نمیدهد.
def tick_clock(self) -> None:
'''Show the time, then ask to be called again in one second.'''
self.clock_label.config(text=datetime.now().strftime('%H:%M:%S'))
self.root.after(1000, self.tick_clock)
def start_countdown(self) -> None:
self.stop_countdown() # never leave an old job running
self.remaining = 10
self.tick_countdown()
def tick_countdown(self) -> None:
minutes, seconds = divmod(self.remaining, 60)
self.timer_label.config(text=f'{minutes:02d}:{seconds:02d}')
if self.remaining == 0:
self.timer_label.config(fg='red')
self.countdown_job = None
return
self.remaining -= 1
# Keep the id so that Stop can cancel this job.
self.countdown_job = self.root.after(1000, self.tick_countdown)
def stop_countdown(self) -> None:
if self.countdown_job is not None:
self.root.after_cancel(self.countdown_job)
self.countdown_job = None
def freeze(self) -> None:
'''What NOT to do: the clock above stops for three whole seconds.'''
time.sleep(3)
ساعت و شمارش معکوس: نمونه 13_after_clock/app.py
شرح مثال
widget.after(میلیثانیه, تابع)از حلقه رویداد میخواهد تابع را بعداً صدا بزند و بلافاصله برمیگردد. برنامه آزاد میماند.- تابعی که در انتهای خودش دوباره
afterمیزند، یک ساعت یا زمانسنج یا انیمیشن میسازد. afterیک شناسه کار برمیگرداند. آن را نگه دارید تا بتوانید باafter_cancelلغوش کنید. همیشه پیش از زمانبندی کار تازه، قبلی را لغو کنید، وگرنه دو زمانسنج همزمان میشمارند و عدد دوتا دوتا میپرد.- دکمه «انجماد» در همین نمونه عمداً گذاشته شده است: آن را بزنید و ببینید ساعت بالای پنجره سه ثانیه کامل میایستد. یک بار دیدن این، از ده صفحه توضیح بهتر است.
- برای کار واقعاً سنگین در پسزمینه (دانلود، محاسبه طولانی)
afterکافی نیست و به نخ نیاز دارید: فصل سی و هفتم.
۱۴. پروژه: مدیریت کتابخانه
پروژه این فصل همه چیز را کنار هم میگذارد: زبانهها، جدولها، دیالوگ مودال، فهرست بازشو، و پایگاه داده SQLite فصل سی و یکم. مهمترین درسش اما معماری است. برنامه سه فایل دارد:
| فایل | نقش |
|---|---|
database.py | همه SQL. اصلاً tkinter را import نمیکند |
dialogs.py | دو فرم مودال افزودن و ویرایش |
app.py | پنجره اصلی. یک خط SQL هم ندارد |
'''The data layer of the library application.
Everything that talks to SQLite lives HERE, and nothing in this file imports
tkinter. That separation is the point of the example:
* `database.py` knows about tables and rows, not about windows;
* `app.py` knows about windows, not about SQL.
You could plug the very same `Database` class into a Flask site (session 38)
or into a PyQt window (session 36) without changing one line.
'''
class Database:
'''A thin wrapper around one SQLite connection.'''
def __init__(self, path: Path = DB_PATH) -> None:
self.connection = sqlite3.connect(path)
self.connection.row_factory = sqlite3.Row
# Without this line ON DELETE CASCADE is silently ignored.
self.connection.execute('PRAGMA foreign_keys = ON')
# executescript runs several statements at once.
self.connection.executescript(SCHEMA)
self.connection.commit()
def list_books(self, search: str = '') -> list[sqlite3.Row]:
if search:
pattern = f'%{search}%'
return self.query(
'''SELECT * FROM books
WHERE title LIKE ? OR author LIKE ? OR isbn LIKE ?
ORDER BY title''',
(pattern, pattern, pattern))
return self.query('SELECT * FROM books ORDER BY title')
لایه داده: نمونه 14_library_app/database.py
و مهمترین متد کل پروژه، امانت دادن کتاب است:
def borrow(self, book_id: int, member_id: int) -> None:
'''Lend a book. Two statements, ONE transaction.
If the UPDATE failed after the INSERT succeeded, the book would be
borrowed and still marked as available. `with self.connection:`
commits when the block ends normally and rolls back on any exception.
'''
now = datetime.now().strftime('%Y-%m-%d %H:%M')
with self.connection:
# The WHERE ... AND available = 1 makes the check atomic.
cursor = self.connection.execute(
'UPDATE books SET available = 0 WHERE id = ? AND available = 1',
(book_id,))
if cursor.rowcount == 0:
raise ValueError('That book is not available any more.')
self.connection.execute(
'INSERT INTO borrowings (book_id, member_id, borrow_date) '
'VALUES (?, ?, ?)',
(book_id, member_id, now))
امانت دادن: تراکنش اتمی
پنج اشتباهی که در نسخه اصلی این پروژه بود
-
برنامه مقدارها را از داخل Treeview میخواند تا
کتاب را ویرایش کند. جدول برای نمایش،
availableرا بهyesوnoتبدیل میکرد، پس ویرایش یک کتاب، کلمهyesرا در ستونyearمینوشت. نسخه بازنویسیشده سطرهای واقعی را در یک دیکشنری نگه میدارد. -
امانت دادن، دو دستور با دو commit جداگانه بود.
اگر دومی شکست میخورد، کتاب هم امانت داده شده بود و هم موجود
میماند. حالا هر دو در یک تراکنشاند و بررسی «هنوز آزاد است؟» جزو
خود
UPDATEاست. -
PRAGMA foreign_keys = ONنوشته نشده بود، پسON DELETEاصلاً هیچ کاری نمیکرد. -
هر فراخوانی یک اتصال تازه باز و بسته میکرد، و یک
except Exceptionخالی همه خطاها را میبلعید. -
فیلد سال هر چیزی را میپذیرفت، و فهرست بازشوی خالی
current(0)را میترکاند.
چون database.py هیچ چیز از پنجره نمیداند، همان کلاس
را میتوانید بدون تغییر یک خط، زیر یک برنامه PySide6 در فصل سی و
ششم یا زیر یک سایت Flask در فصل سی و هشتم بگذارید. این دقیقاً
کاری است که در پروژه پایانی فصل سی و نهم انجام میدهیم. هر وقت
دیدید کد رابط کاربری دارد SQL مینویسد، بدانید جایی از طراحی
اشتباه رفته است.
پوشه S35-tkinter-advanced شامل چهارده پوشه است، از
01_open_file تا 14_library_app. پروژه
را با python app.py در پوشه چهاردهم اجرا کنید؛ فایل
library.db را خودش کنار خودش میسازد و برای شروع از
صفر کافی است پاکش کنید. روی لینوکس گاهی باید tkinter را جداگانه
نصب کنید: sudo apt install python3-tk.
خلاصه فصل
| ابزار | وظیفه آن |
|---|---|
filedialog.askopenfilename | انتخاب فایل. انصراف یعنی رشته خالی |
filedialog.asksaveasfilename | پرسیدن محل ذخیره |
Text و '1.0' و 'end-1c' | ویجت چندخطی و موقعیتهایش |
edit_modified() | آیا متن از آخرین ذخیره تغییر کرده |
Scrollbar | باید در هر دو جهت وصل شود |
PhotoImage | GIF و PNG. مرجعش را نگه دارید |
Scale و Spinbox | هر دو مقدار را به شکل رشته میدهند |
ttk.Combobox | فهرست بازشو، با <<ComboboxSelected>> |
ttk.Notebook | زبانهها. هر زبانه یک Frame |
ttk.Progressbar | نوار پیشرفت |
ttk.Treeview | جدول. انبار داده شما نیست |
Toplevel + grab_set + wait_window | دیالوگ مودال که مقدار برمیگرداند |
state='disabled' | غیرفعال کردن ویجت |
Canvas و bind | ترسیم و رویدادهای ماوس |
after() و after_cancel() | کار زمانبندیشده بدون قفل شدن پنجره |
time.sleep() در تابع پاسخ | همیشه باگ است |
تمرینهای فصل سی و پنج
- به نمایشگر فایل، دکمه «شمارش کلمات» اضافه کنید که تعداد کلمهها و خطهای فایل باز را نشان دهد.
- در دفترچه یادداشت، سطر و ستون فعلی مکاننما را در یک نوار وضعیت پایین پنجره نشان دهید.
- به نمونه تصویر، دکمههای بعدی و قبلی اضافه کنید که همه فایلهای تصویری یک پوشه را بگردند.
- مبدل واحد بسازید: یک
Comboboxبرای انتخاب واحد، یکEntryبرای مقدار و یک برچسب برای پاسخ. - ترکیبکننده رنگ بسازید: سه لغزنده قرمز و سبز و آبی که رنگ پسزمینه یک قاب را زنده عوض کنند و کد رنگ را نشان دهند.
- شمارش معکوس را به یک زمانسنج پومودورو تبدیل کنید: بیست و پنج دقیقه کار، پنج دقیقه استراحت، با پیام بین آنها.
- به جدول نمونه یازدهم، دکمه افزودن با دیالوگ مودال و دکمه حذف با تأیید اضافه کنید.
- سطرهای جدول را رنگی کنید: نمره زیر ده با پسزمینه قرمز کمرنگ، با
tag_configure. - به پروژه کتابخانه، زبانه «تاریخچه امانت» اضافه کنید که امانتهای بستهشده را هم نشان دهد.
- قانونی بگذارید که یک عضو نتواند بیش از سه کتاب همزمان امانت بگیرد. قانون را در
database.pyبنویسید، نه در رابط کاربری. - موعد بازگشت چهاردهروزه اضافه کنید و امانتهای دیرکرددار را قرمز نشان دهید.
- زبانه «آمار» بسازید: تعداد کتابها، اعضا، کتابهای بیرون، و پنج عنوان پرامانتتر.
- توضیح دهید چرا پروژه، SQL را در
database.pyنگه میدارد. برای بردن همین برنامه به PySide6 چه چیزی باید عوض شود؟ - اگر دکمه امانت را خیلی سریع دو بار بزنید چه میشود؟ کدام خط
database.pyاز شما محافظت میکند؟