Глава 16 · Создаём классные приложения с Tkinter

Tip Calculator Pro: финальная версия

Тот же проект, что и в разделе 16.8 — но теперь со всем, что мы изучили после него.

Вернёмся к калькулятору чаевых (раздел 16.8) и соберём в нём всё, что изучили в этой главе — по стадиям, а не одним огромным финальным куском кода.

Путь от фундамента до Pro
V1–V2 (16.8)
Entry + Button + grid()
чистая формула чаевых
V3
Combobox с типовыми процентами вместо Entry
V4
поле «количество человек» — делим чаевые
V5
validate_positive_amount/validate_positive_int (16.23)
V6
меню «Файл» с пунктом «Выход» (16.6)
V7
load_settings/save_settings — запоминаем последний процент (16.25)
V8
класс TipCalculatorApp — всё внутри одного объекта (16.25)
Настройки сохраняются относительно текущей рабочей директории
SETTINGS_PATH = Path("tip_calculator_settings.json") — относительный путь: файл появится там, откуда запущена программа (глава 15, раздел 15.7 про CWD). Для учебного проекта это осознанный, явный выбор — не забытая неопределённость. В реальном распространяемом приложении для этого обычно выбирают выделенную папку данных приложения; здесь это намеренно оставлено простым.
tip_calculator_pro.py
import json
import tkinter as tk
from tkinter import ttk
from pathlib import Path

SETTINGS_PATH = Path("tip_calculator_settings.json")
DEFAULT_SETTINGS = {"last_percent": "15"}

def load_settings():
    if not SETTINGS_PATH.exists():
        return dict(DEFAULT_SETTINGS)
    with SETTINGS_PATH.open("r", encoding="utf-8") as f:
        return json.load(f)

def save_settings(settings):
    with SETTINGS_PATH.open("w", encoding="utf-8") as f:
        json.dump(settings, f, ensure_ascii=False, indent=2)

def parse_number(text):
    text = text.strip()
    if not text:
        return False, None, "Поле не должно быть пустым"
    try:
        return True, float(text), ""
    except ValueError:
        return False, None, "Введите число"

def validate_positive_amount(text):
    ok, value, message = parse_number(text)
    if not ok:
        return False, message
    if value <= 0:
        return False, "Число должно быть больше нуля"
    return True, ""

def validate_positive_int(text):
    ok, value, message = parse_number(text)
    if not ok:
        return False, message
    if value != int(value):
        return False, "Введите целое число"
    if int(value) < 1:
        return False, "Число должно быть не меньше 1"
    return True, ""

def calculate_tip(amount, percent, people):
    return (amount * percent / 100) / people

class TipCalculatorApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Tip Calculator Pro")
        self.settings = load_settings()
        self.amount_var = tk.StringVar()
        self.percent_var = tk.StringVar(value=self.settings["last_percent"])
        self.people_var = tk.StringVar(value="1")
        self.result_var = tk.StringVar()
        self.build_ui()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    def build_ui(self):
        frame = ttk.Frame(self.root, padding=12)
        frame.grid(row=0, column=0, sticky="nsew")
        self.root.columnconfigure(0, weight=1)

        ttk.Label(frame, text="Сумма счёта:").grid(row=0, column=0, sticky="w")
        ttk.Entry(frame, textvariable=self.amount_var).grid(row=0, column=1, sticky="ew")

        ttk.Label(frame, text="Процент чаевых:").grid(row=1, column=0, sticky="w")
        ttk.Combobox(frame, textvariable=self.percent_var, values=["10", "15", "20"],
            state="readonly").grid(row=1, column=1, sticky="ew")

        ttk.Label(frame, text="Количество человек:").grid(row=2, column=0, sticky="w")
        ttk.Entry(frame, textvariable=self.people_var).grid(row=2, column=1, sticky="ew")

        ttk.Button(frame, text="Посчитать", command=self.on_calculate).grid(
            row=3, column=0, columnspan=2, pady=8)
        ttk.Label(frame, textvariable=self.result_var).grid(row=4, column=0, columnspan=2)
        frame.columnconfigure(1, weight=1)

    def on_calculate(self):
        ok, message = validate_positive_amount(self.amount_var.get())
        if not ok:
            self.result_var.set(message)
            return
        ok_people, message_people = validate_positive_int(self.people_var.get())
        if not ok_people:
            self.result_var.set("Количество человек: " + message_people)
            return
        chaevye = calculate_tip(
            float(self.amount_var.get()),
            float(self.percent_var.get()),
            int(self.people_var.get()),
        )
        self.result_var.set(f"Чаевые с человека: {chaevye:.2f}")
        self.settings["last_percent"] = self.percent_var.get()

    def on_close(self):
        save_settings(self.settings)
        self.root.destroy()

root = tk.Tk()
app = TipCalculatorApp(root)
root.mainloop()
Количество человек — положительное целое, не любое число
2.5 человека не бывает. validate_positive_int (раздел 16.23) принимает 1, 2, 5, 10 — и отвергает 0, -1, 2.5, "abc" и пустую строку с понятным сообщением об ошибке.
Окно Tip Calculator Pro с суммой счёта 1000, процентом 15, количеством человек 2 и результатом 75.00
Результат выполнения кода выше — настоящее окно, а не иллюстрация. Пример внешнего вида на среде курса. На вашей ОС шрифты, размеры и тема могут немного отличаться.
app : TipCalculatorApproot = Tksettings = dictamount_var = StringVarpercent_var = StringVarpeople_var = StringVarresult_var = StringVar
Финальный объектный граф: одно приложение, явные зависимости, ни одной глобальной переменной.
Практика: Tip Calculator Pro
Модуль tkinter открывает нативное окно Python — выполните локально в VS Code, PyCharm или Jupyter
Практика выполняется локально
Открыть практику →