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

رابط گرافیکی با tkinter

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

در این فصل می‌خوانید
  1. اولین پنجره و حلقه رویداد
  2. برچسب
  3. دکمه و تابع پاسخ
  4. فیلد متنی
  5. سه مدیر چیدمان
  6. فرم با grid
  7. قاب‌ها
  8. تیک و دکمه رادیویی
  9. فهرست انتخاب
  10. متغیر کنترلی و شمارنده
  11. پنجره‌های پیام
  12. نوار منو
  13. فرم با اعتبارسنجی
  14. ماشین‌حساب کامل

۱. اولین پنجره و حلقه رویداد

'''The smallest possible Tkinter program: one empty window.'''

import tkinter as tk


def main() -> None:
    # Tk() creates the main window. There must be exactly one per program.
    root = tk.Tk()

    root.title('A simple program')      # the text of the title bar
    root.geometry('400x300')            # 'WIDTHxHEIGHT', in pixels

    # mainloop() starts the EVENT LOOP: the program now waits for clicks and
    # key presses, and it does not return until the window is closed.
    # Nothing written after mainloop() runs before that.
    root.mainloop()


if __name__ == '__main__':
    main()

اولین پنجره: نمونه 01_window/app.py

شرح مثال

حلقه رویداد چیست

برنامه ترمینالی از بالا به پایین اجرا می‌شود و تمام. برنامه گرافیکی اما بعد از راه‌اندازی وارد یک حلقه بی‌پایان می‌شود و منتظر می‌ماند: کلیک شد؟ کلیدی فشرده شد؟ پنجره تغییر اندازه داد؟ برای هر رویداد، تابع مربوطه را صدا می‌زند و باز منتظر می‌ماند. به همین دلیل هر چه بعد از mainloop() بنویسید تا بسته شدن پنجره اجرا نمی‌شود.

۲. برچسب

برچسب متنی را نشان می‌دهد که کاربر نمی‌تواند ویرایشش کند.

def main() -> None:
    root = tk.Tk()
    root.title('Showing a label')
    root.geometry('400x300')

    # Every widget receives its PARENT as its first argument.
    label = tk.Label(root, text='Welcome to my program')

    # Creating a widget is not enough: it must be placed with a geometry
    # manager, otherwise it stays invisible.
    label.pack()

    # A widget takes many options.
    tk.Label(
        root,
        text='Kadoos Institute, Rasht',
        font=('Arial', 18, 'bold'),
        fg='white',                 # foreground = the color of the text
        bg="#224160",               # background
        padx=20,
        pady=10,
    ).pack(pady=20)

    root.mainloop()

برچسب: نمونه 02_label/app.py

شرح مثال

۳. دکمه و تابع پاسخ

'''A Button runs a function when it is clicked.

Note how the widgets are created inside main() and the callback is a NESTED
function: that way it sees `label` without needing `global`.
'''

def main() -> None:
    root = tk.Tk()
    root.title('Button and label')
    root.geometry('400x300')

    label = tk.Label(root, text='I am waiting for a click')
    label.pack(pady=20)

    def change_text() -> None:
        'This function is called by the button.'
        print('Clicked!')
        # config() changes an option of an existing widget.
        label.config(text='The button was clicked')

    # command=change_text  passes the FUNCTION ITSELF.
    # command=change_text()  would CALL it immediately and pass its result -
    # that is the classic beginner mistake.
    button = tk.Button(root, text='Click me', command=change_text)
    button.pack()

    # A counter, to show that the callback keeps its state between clicks.
    clicks = {'count': 0}

    def count_click() -> None:
        clicks['count'] += 1
        counter_label.config(text=f'Clicks: {clicks["count"]}')

    counter_label = tk.Label(root, text='Clicks: 0')
    counter_label.pack(pady=(20, 0))
    tk.Button(root, text='Count', command=count_click).pack()

    root.mainloop()

دکمه: نمونه 03_button/app.py

شرح مثال

۴. فیلد متنی

'''An Entry is a one-line text field.

    entry.get()              -> what the user typed (always a str)
    entry.delete(0, tk.END)  -> empty it
    entry.insert(0, 'text')  -> write into it
'''

def main() -> None:
    root = tk.Tk()
    root.title('Text input')
    root.geometry('400x300')

    entry = tk.Entry(root, width=30)
    entry.pack(pady=10)
    entry.focus()                 # the cursor starts in this field

    label = tk.Label(root, text='')
    label.pack(pady=10)

    def show_text() -> None:
        user_input = entry.get().strip()

        if not user_input:
            label.config(text='Please type something first.')
            return

        label.config(text='You wrote: ' + user_input)

    tk.Button(root, text='Show', command=show_text).pack()
    tk.Button(root, text='Clear',
              command=lambda: entry.delete(0, tk.END)).pack(pady=5)

    # bind() connects a keyboard or mouse event to a function.
    # '<Return>' is the Enter key.
    entry.bind('<Return>', lambda event: show_text())

    root.mainloop()

فیلد متنی: نمونه 04_entry/app.py

شرح مثال

۵. سه مدیر چیدمان

در tkinter سه راه برای قرار دادن ویجت‌ها وجود دارد. انتخاب درست، نصف کار طراحی رابط است.

مدیرچه می‌کندکی به کار می‌آید
pack()ویجت‌ها را پشت سر هم می‌چیندچیدمان‌های ساده، نوارها، ستون تکی
grid()جدولی از سطر و ستونفرم‌ها، ماشین‌حساب، هر چیز جدولی
place()مختصات دقیق پیکسلیبه‌ندرت. با تغییر اندازه پنجره خراب می‌شود
هرگز دو مدیر را در یک ظرف قاطی نکنید

اگر روی ویجت‌هایی که والد مشترک دارند هم pack() و هم grid() به کار ببرید، برنامه بی‌هیچ پیامی قفل می‌شود. نه خطایی می‌بینید و نه پنجره‌ای؛ فقط برنامه هنگ می‌کند. این یکی از سخت‌ترین باگ‌ها برای تازه‌کارهاست. راه حل: هر قاب، مدیر مخصوص خودش را دارد.

گزینه‌های پرکاربرد این دو مدیر را در یک جا ببینید:

گزینهمال کداموظیفه آن
sidepackجهت چیدن: top، left، right، bottom
fillpackکشیده شدن در عرض یا ارتفاع: x، y، both
expandpackگرفتن فضای اضافه باقی‌مانده
row و columngridشماره سطر و ستون، از صفر
columnspangridکشیده شدن روی چند ستون
stickygridچسبیدن به جهت‌ها: n s e w و ترکیبشان
padx و padyهر دوفاصله افقی و عمودی

۶. فرم با grid

'''grid() places the widgets in a table of rows and columns.

It is the right manager for a form. Never use pack() and grid() on widgets
that share the same parent: the program then freezes.
'''

def main() -> None:
    root = tk.Tk()
    root.title('Login form')
    root.geometry('400x300')

    # sticky='e' pushes the label to the East (the right) of its cell.
    tk.Label(root, text='Username:').grid(row=0, column=0,
                                          padx=10, pady=5, sticky='e')
    entry_user = tk.Entry(root, width=25)
    entry_user.grid(row=0, column=1, padx=10, pady=5)

    tk.Label(root, text='Password:').grid(row=1, column=0,
                                          padx=10, pady=5, sticky='e')
    # show='*' hides what is typed.
    entry_pass = tk.Entry(root, width=25, show='*')
    entry_pass.grid(row=1, column=1, padx=10, pady=5)

    label_result = tk.Label(root, text='')
    label_result.grid(row=3, column=0, columnspan=2)

    def login() -> None:
        username = entry_user.get().strip()
        password = entry_pass.get()

        if not username or not password:
            label_result.config(text='Both fields are required.', fg='red')
            return

        if username == 'admin' and password == '1234':
            label_result.config(text=f'Welcome, {username}!', fg='green')
        else:
            label_result.config(text='Wrong username or password.', fg='red')

    # columnspan=2 makes the button as wide as the two columns.
    tk.Button(root, text='Login', command=login).grid(row=2, column=0,
                                                      columnspan=2, pady=10)

    root.mainloop()

فرم ورود: نمونه 05_grid_login/app.py

شرح مثال

۷. قاب‌ها

قاب یک جعبه نامرئی برای گروه‌بندی ویجت‌هاست. هر قاب مدیر چیدمان مخصوص خودش را دارد، پس یک بخش پنجره می‌تواند pack باشد و بخش دیگر grid.

def main() -> None:
    root = tk.Tk()
    root.title('Using frames')
    root.geometry('400x300')

    # fill='x' -> the frame takes the whole width.
    top_frame = tk.Frame(root, bg='lightblue', height=100)
    top_frame.pack(fill='x')

    tk.Label(top_frame, text='Top section', bg='lightblue').pack(pady=30)

    # fill='both' + expand=True -> the frame takes all the remaining space.
    bottom_frame = tk.Frame(root, bg='darkred')
    bottom_frame.pack(fill='both', expand=True)

    # side= decides where pack() puts the widget inside its parent.
    tk.Button(bottom_frame, text='Left button').pack(side='left',
                                                     padx=10, pady=10)
    tk.Button(bottom_frame, text='Right button').pack(side='right',
                                                      padx=10, pady=10)

    root.mainloop()

قاب: نمونه 06_frame/app.py

قاب‌بندی، کلید ساختن رابط‌های بزرگ است. هر بخش منطقی برنامه یک قاب می‌شود و بعد قاب‌ها کنار هم چیده می‌شوند. ترکیب fill='both' با expand=True یعنی «همه فضای باقی‌مانده را بگیر» و پرکاربردترین ترکیب pack است.

۸. تیک و دکمه رادیویی

'''Checkbutton (several independent choices) and Radiobutton (one choice).

Both need a CONTROL VARIABLE - IntVar, StringVar, BooleanVar - which holds the
current value and is read with get().

Every radio button of a group shares the SAME variable; what tells them apart
is their `value`.
'''

def main() -> None:
    root = tk.Tk()
    root.title('Options')
    root.geometry('400x300')

    label_status = tk.Label(root, text='')
    label_color = tk.Label(root, text='')

    var_check = tk.IntVar()                    # 0 or 1
    var_radio = tk.StringVar(value='red')      # the selected value

    def show_selection() -> None:
        'Called every time one of the widgets changes.'
        if var_check.get():
            label_status.config(text='Enabled')
        else:
            label_status.config(text='Disabled')

        label_color.config(text='Selected colour: ' + var_radio.get())

    tk.Checkbutton(root, text='Enable', variable=var_check,
                   command=show_selection).pack(pady=(10, 5))

    for colour in ('red', 'blue', 'green'):
        tk.Radiobutton(root, text=colour, variable=var_radio, value=colour,
                       command=show_selection).pack(anchor='w', padx=140)

    label_status.pack(pady=(15, 0))
    label_color.pack()

    show_selection()          # show the initial state

    root.mainloop()

تیک و رادیویی: نمونه 07_check_radio/app.py

شرح مثال

۹. فهرست انتخاب

'''A Listbox shows a list the user can choose from.

    insert(tk.END, item)  -> add at the end
    curselection()        -> a TUPLE of the selected indexes (empty when none)
    get(index)            -> the text of one line
    delete(index)         -> remove a line
'''

def main() -> None:
    root = tk.Tk()
    root.title('Listbox')
    root.geometry('400x350')

    listbox = tk.Listbox(root, height=6)
    for item in ['Tehran', 'Isfahan', 'Shiraz', 'Tabriz', 'Mashhad', 'Rasht']:
        listbox.insert(tk.END, item)
    listbox.pack(pady=10)

    label = tk.Label(root, text='')
    label.pack()

    def show_selected() -> None:
        selected = listbox.curselection()

        # curselection() gives an empty tuple when nothing is selected -
        # reading selected[0] without this test raises IndexError.
        if selected:
            label.config(text='Your choice: ' + listbox.get(selected[0]))
        else:
            label.config(text='Nothing is selected')

    def delete_selected() -> None:
        selected = listbox.curselection()
        if selected:
            listbox.delete(selected[0])

    tk.Button(root, text='Show the selection', command=show_selected).pack(pady=5)
    tk.Button(root, text='Delete the selection', command=delete_selected).pack()

    # A double click also shows the selection.
    listbox.bind('<Double-Button-1>', lambda event: show_selected())

    root.mainloop()

فهرست: نمونه 08_listbox/app.py

curselection() یک تاپل از شماره‌های انتخاب‌شده می‌دهد و وقتی چیزی انتخاب نشده، تاپل خالی است. خواندن selected[0] بدون بررسی، مستقیم به IndexError می‌رسد. این دقیقاً همان درس فصل نهم است که حالا در دنیای گرافیکی تکرار می‌شود.

۱۰. متغیر کنترلی و شمارنده

تا اینجا هر بار متن برچسب را با config() عوض می‌کردیم. راه تمیزتر متغیر کنترلی است: برچسب به متغیر وصل می‌شود و هر وقت متغیر عوض شود، برچسب خودش به‌روز می‌شود.

'''Control variables: the clean way to keep a widget and a value in agreement.

With `textvariable=`, the label updates ITSELF whenever the variable changes:
we never call label.config() again.
'''

def main() -> None:
    root = tk.Tk()
    root.title('Counter')
    root.geometry('400x300')

    counter_var = tk.IntVar(value=0)

    def increment() -> None:
        counter_var.set(counter_var.get() + 1)

    def decrement() -> None:
        counter_var.set(counter_var.get() - 1)

    def reset() -> None:
        counter_var.set(0)

    tk.Label(root, textvariable=counter_var,
             font=('Arial', 24)).pack(pady=20)

    button_frame = tk.Frame(root)
    button_frame.pack()

    tk.Button(button_frame, text='+', command=increment,
              width=5).pack(side='left', padx=5)
    tk.Button(button_frame, text='-', command=decrement,
              width=5).pack(side='left', padx=5)
    tk.Button(button_frame, text='Reset', command=reset,
              width=6).pack(side='left', padx=5)

    root.mainloop()

شمارنده: نمونه 09_counter/app.py

متغیر کنترلیبرای چه
tk.StringVar()متن
tk.IntVar()عدد صحیح
tk.DoubleVar()عدد اعشاری
tk.BooleanVar()درست یا نادرست
.get() و .set(x)خواندن و نوشتن مقدار
textvariable=گره زدن ویجت به متغیر

دکمه‌های + و - داخل یک قاب جداگانه گذاشته شده‌اند تا در یک ردیف افقی بنشینند، در حالی که بقیه پنجره عمودی چیده شده است. این همان فایده قاب است که در بخش هفتم گفتیم.

۱۱. پنجره‌های پیام

'''messagebox: the standard dialog boxes.

    showinfo / showwarning / showerror  -> just an OK button
    askyesno / askokcancel              -> return True or False
    askquestion                         -> returns 'yes' or 'no'
'''

import tkinter as tk
from tkinter import messagebox


def main() -> None:
    root = tk.Tk()
    root.title('Message boxes')
    root.geometry('400x300')

    def show_info() -> None:
        messagebox.showinfo('Information', 'This is an information message')

    def ask_question() -> None:
        # The answer is used to decide what to do next.
        if messagebox.askyesno('Question', 'Do you want to close the program?'):
            root.destroy()

    tk.Button(root, text='Show information', command=show_info).pack(pady=5)
    tk.Button(root, text='Ask and quit', command=ask_question).pack(pady=5)

    root.mainloop()

پنجره پیام: نمونه 10_messagebox/app.py

توجه کنید که messagebox باید جداگانه import شود؛ با import tkinter as tk به تنهایی در دسترس نیست. پنجره‌های پرسشی True یا False برمی‌گردانند و همان‌جا در if به کار می‌روند. پیش از هر کار برگشت‌ناپذیر مثل حذف، حتماً یک askyesno بگذارید.

۱۲. نوار منو

'''A menu bar.

    Menu(root)                 -> the bar itself
    root.config(menu=menubar)  -> attach it to the window
    add_cascade                -> a menu that opens a sub-menu
    add_command                -> an entry that runs a function
    tearoff=0                  -> remove the old dashed line at the top
'''

def main() -> None:
    root = tk.Tk()
    root.title('A program with a menu')
    root.geometry('400x300')

    label = tk.Label(root, text='Use the menu above', font=('Arial', 14))
    label.pack(pady=50)

    def new_file() -> None:
        label.config(text='New file')

    def show_about() -> None:
        messagebox.showinfo('About', 'This program is a teaching example.')

    menubar = tk.Menu(root)
    root.config(menu=menubar)

    file_menu = tk.Menu(menubar, tearoff=0)
    menubar.add_cascade(label='File', menu=file_menu)
    file_menu.add_command(label='New', command=new_file)
    file_menu.add_separator()
    # root.destroy() closes the window properly; root.quit() only stops the loop.
    file_menu.add_command(label='Exit', command=root.destroy)

    help_menu = tk.Menu(menubar, tearoff=0)
    menubar.add_cascade(label='Help', menu=help_menu)
    help_menu.add_command(label='About', command=show_about)

    root.mainloop()

منو: نمونه 11_menu/app.py

شرح مثال

۱۳. فرم با اعتبارسنجی

مهم‌ترین بخش هر برنامه گرافیکی، رسیدگی به ورودی اشتباه است. هر چیزی که از Entry بیرون می‌آید متن است و float() می‌تواند شکست بخورد.

def calculate() -> None:
    try:
        length = float(entry_length.get())
        width = float(entry_width.get())
    except ValueError:
        result.config(text='Please enter two numbers.', fg='red')
        return

    if length <= 0 or width <= 0:
        result.config(text='The values must be positive.', fg='red')
        return

    area = length * width
    perimeter = (length + width) * 2
    result.config(text=f'Area: {area:g}   Perimeter: {perimeter:g}',
                  fg='green')

اعتبارسنجی: نمونه 12_rectangle/app.py

شرح مثال

۱۴. ماشین‌حساب کامل

import tkinter as tk

ALLOWED_CHARACTERS = set('0123456789+-*/(). ')

BUTTONS = [
    ('7', 1, 0), ('8', 1, 1), ('9', 1, 2), ('/', 1, 3),
    ('4', 2, 0), ('5', 2, 1), ('6', 2, 2), ('*', 2, 3),
    ('1', 3, 0), ('2', 3, 1), ('3', 3, 2), ('-', 3, 3),
    ('0', 4, 0), ('.', 4, 1), ('=', 4, 2), ('+', 4, 3),
]


def main() -> None:
    root = tk.Tk()
    root.title('Calculator')
    root.geometry('300x400')

    display = tk.Entry(root, font=('Arial', 18), justify='right')
    display.grid(row=0, column=0, columnspan=4, sticky='we', padx=5, pady=5)

    def press(key: str) -> None:
        'Add one character at the end of the display.'
        display.insert(tk.END, str(key))

    def calculate() -> None:
        expression = display.get()

        # Refuse anything that is not a number or an operator.
        if not expression or not set(expression) <= ALLOWED_CHARACTERS:
            display.delete(0, tk.END)
            display.insert(tk.END, 'Error')
            return

        try:
            result = eval(expression)
        except (SyntaxError, ZeroDivisionError, ValueError):
            display.delete(0, tk.END)
            display.insert(tk.END, 'Error')
            return

        display.delete(0, tk.END)
        display.insert(tk.END, str(result))

    for text, row, column in BUTTONS:
        if text == '=':
            button = tk.Button(root, text=text, width=5, height=2,
                               command=calculate)
        else:
            # t=text captures the CURRENT value of text.
            button = tk.Button(root, text=text, width=5, height=2,
                               command=lambda t=text: press(t))
        button.grid(row=row, column=column, padx=2, pady=2)

    # The columns share the extra space when the window is resized.
    for column in range(4):
        root.grid_columnconfigure(column, weight=1)

    root.mainloop()

ماشین‌حساب: نمونه 13_calculator/app.py

سه درس این برنامه

grid_columnconfigure(column, weight=1) باعث می‌شود با بزرگ شدن پنجره، ستون‌ها فضای اضافه را بین خودشان تقسیم کنند. بدون آن، دکمه‌ها گوشه بالا چپ جمع می‌شوند و پنجره زشت می‌شود.

فایل‌های تمرینی این فصل

پوشه S34-tkinter-basics شامل سیزده پوشه از 01_window تا 13_calculator است. در هر پوشه یک app.py هست که با python app.py اجرا می‌شود. هیچ نصبی ضرورتی ندارد، چون tkinter جزو خود پایتون است.

خلاصه فصل

ابزاروظیفه آن
tk.Tk()پنجره اصلی، یکی در هر برنامه
title و geometryعنوان و اندازه پنجره
mainloop()شروع حلقه رویداد
Labelنمایش متن
Buttonدکمه، با command=func بدون پرانتز
Entryفیلد یک‌خطی. get همیشه رشته می‌دهد
Frameگروه‌بندی ویجت‌ها
Checkbutton و Radiobuttonانتخاب چندگانه و انتخاب یگانه
Listboxفهرست. curselection ممکن است خالی باشد
pack و grid و placeسه مدیر چیدمان. در یک ظرف فقط یکی
IntVar و StringVarمتغیر کنترلی، با textvariable
config()تغییر گزینه یک ویجت موجود
bind('<Return>', f)اتصال رویداد به تابع
messageboxپنجره‌های پیام و پرسش
Menu و add_cascadeنوار منو
root.destroy()بستن درست برنامه

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

  1. پنجره‌ای بسازید که نام شما در عنوانش باشد و یک برچسب خوشامد داشته باشد.
  2. فرمی بسازید که نام بپرسد و در یک برچسب بنویسد Hello <name>!.
  3. مبدل دما از سلسیوس به فارنهایت بسازید، با اعتبارسنجی کامل ورودی.
  4. پنجره‌ای با سه دکمه رادیویی بسازید که رنگ پس‌زمینه را عوض کنند.
  5. فهرست خرید بسازید: یک Entry، دکمه افزودن، یک Listbox و دکمه حذف.
  6. به یکی از برنامه‌هایتان منوی File > Exit و Help > About اضافه کنید.
  7. پیش از بسته شدن برنامه، با askyesno از کاربر تأیید بگیرید.
  8. بازی حدس عدد را با رابط گرافیکی بنویسید.
  9. به ماشین‌حساب، دکمه پاک کردن و دکمه حذف آخرین کاراکتر اضافه کنید.
فصل قبل: الگوهای طراحی فصل بعد: tkinter پیشرفته