برنامه‌نویسی پایتون فهرست مطالب
فصل ۳۵

tkinter پیشرفته

در فصل قبل «پنجره‌ای با چند دکمه» ساختیم. در این فصل به یک برنامه دسکتاپ واقعی می‌رسیم: باز و ذخیره کردن فایل، تصویر، جدول، پنجره دوم، زمان‌سنج، و در پایان یک برنامه مدیریت کتابخانه که روی پایگاه داده کار می‌کند.

در این فصل می‌خوانید
  1. کلاس، به جای global
  2. باز کردن فایل و ویجت Text
  3. دفترچه یادداشت: ذخیره و تغییرات ذخیره‌نشده
  4. تصویر و دام نامرئی شدن
  5. لغزنده و جعبه چرخشی
  6. ttk و فهرست بازشو
  7. زبانه‌ها و نوار پیشرفت
  8. تنظیمات پنجره
  9. وضعیت ویجت‌ها
  10. پنجره دوم: دیالوگ مودال
  11. جدول Treeview
  12. بوم نقاشی و رویدادهای ماوس
  13. after و قانون طلایی
  14. پروژه: مدیریت کتابخانه

۱. کلاس، به جای 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

شرح مثال

۳. دفترچه یادداشت: ذخیره و تغییرات ذخیره‌نشده

    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

شرح مثال

۴. تصویر و دام نامرئی شدن

'''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

مشهورترین باگ tkinter

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

شرح مثال

۶. 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

شرح مثال

۷. زبانه‌ها و نوار پیشرفت

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

شرح مثال

۱۰. پنجره دوم: دیالوگ مودال

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)صداکننده تا بسته شدن دیالوگ منتظر می‌ماند

شرح مثال

۱۱. جدول 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

شرح مثال

ویجت انبار داده شما نیست

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

شرح مثال

۱۴. پروژه: مدیریت کتابخانه

پروژه این فصل همه چیز را کنار هم می‌گذارد: زبانه‌ها، جدول‌ها، دیالوگ مودال، فهرست بازشو، و پایگاه داده 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))

امانت دادن: تراکنش اتمی

پنج اشتباهی که در نسخه اصلی این پروژه بود

چرا لایه‌بندی اینقدر مهم است

چون 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باید در هر دو جهت وصل شود
PhotoImageGIF و 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() در تابع پاسخهمیشه باگ است

تمرین‌های فصل سی و پنج

  1. به نمایشگر فایل، دکمه «شمارش کلمات» اضافه کنید که تعداد کلمه‌ها و خط‌های فایل باز را نشان دهد.
  2. در دفترچه یادداشت، سطر و ستون فعلی مکان‌نما را در یک نوار وضعیت پایین پنجره نشان دهید.
  3. به نمونه تصویر، دکمه‌های بعدی و قبلی اضافه کنید که همه فایل‌های تصویری یک پوشه را بگردند.
  4. مبدل واحد بسازید: یک Combobox برای انتخاب واحد، یک Entry برای مقدار و یک برچسب برای پاسخ.
  5. ترکیب‌کننده رنگ بسازید: سه لغزنده قرمز و سبز و آبی که رنگ پس‌زمینه یک قاب را زنده عوض کنند و کد رنگ را نشان دهند.
  6. شمارش معکوس را به یک زمان‌سنج پومودورو تبدیل کنید: بیست و پنج دقیقه کار، پنج دقیقه استراحت، با پیام بین آنها.
  7. به جدول نمونه یازدهم، دکمه افزودن با دیالوگ مودال و دکمه حذف با تأیید اضافه کنید.
  8. سطرهای جدول را رنگی کنید: نمره زیر ده با پس‌زمینه قرمز کم‌رنگ، با tag_configure.
  9. به پروژه کتابخانه، زبانه «تاریخچه امانت» اضافه کنید که امانت‌های بسته‌شده را هم نشان دهد.
  10. قانونی بگذارید که یک عضو نتواند بیش از سه کتاب همزمان امانت بگیرد. قانون را در database.py بنویسید، نه در رابط کاربری.
  11. موعد بازگشت چهارده‌روزه اضافه کنید و امانت‌های دیرکرددار را قرمز نشان دهید.
  12. زبانه «آمار» بسازید: تعداد کتاب‌ها، اعضا، کتاب‌های بیرون، و پنج عنوان پرامانت‌تر.
  13. توضیح دهید چرا پروژه، SQL را در database.py نگه می‌دارد. برای بردن همین برنامه به PySide6 چه چیزی باید عوض شود؟
  14. اگر دکمه امانت را خیلی سریع دو بار بزنید چه می‌شود؟ کدام خط database.py از شما محافظت می‌کند؟
فصل قبل: رابط گرافیکی با tkinter فصل بعد: PySide6 و ساخت فایل اجرایی