В прошлой части мы превратили простое приложение для заметок в удобный инструмент с цветовой индикацией, сортировкой и возможностью редактирования. Однако, когда задач становится много, даже самый красивый список может превратиться в нечитаемую ленту. Согласитесь, сложно ориентироваться, когда нужно найти одну важную задачу среди сотни выполненных.
В этой статье мы добавим инструменты профессионального управления:

  • Приоритеты через выпадающий список — сделаем интерфейс добавления задач ещё удобнее.

  • Фильтр (Все / Активные / Выполненные) — чтобы сосредоточиться только на том, что действительно требует внимания.

  • Поиск — мгновенный поиск задач по тексту.

  • Экспорт в CSV — возможность выгрузить список задач для отчёта или анализа в Excel.

  • Экспорт в PDF — создание профессионально оформленных отчётов для печати или архивации.

Шаг 1. Оптимизация интерфейса: выпадающий список приоритетов

На предыдущем этапе мы использовали отдельное окно для выбора приоритета при добавлении задачи. Это было функционально, но не очень эргономично. Сделаем процесс добавления более быстрым — перенесём выбор приоритета на главную панель с помощью выпадающего списка (Combobox).

Обновляем create_widgets

Добавим ttk.Combobox на верхнюю панель, чтобы пользователь мог выбрать приоритет сразу, не открывая лишних окон. Также мы уберём кнопку "Добавить", заменив её на более интуитивную реакцию на нажатие Enter.

# В начале файла добавьте импорт ttk
from tkinter import ttk

class TodoApp:
    # ... (остальной код класса)

    def create_widgets(self):
        """Создаёт все виджеты окна"""
        # Верхняя панель: поле ввода, выбор приоритета и кнопка добавления
        top_frame = tk.Frame(self.root)
        top_frame.pack(pady=10, padx=10, fill=tk.X)

        # Поле ввода текста задачи
        self.entry = tk.Entry(top_frame, font=("Arial", 12))
        self.entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
        self.entry.bind("<Return>", lambda event: self.add_task())

        # Выпадающий список для выбора приоритета
        tk.Label(top_frame, text="Приоритет:").pack(side=tk.LEFT, padx=(5, 2))
        self.priority_var = tk.StringVar(value="medium")
        priority_combo = ttk.Combobox(
            top_frame, 
            textvariable=self.priority_var, 
            values=["high", "medium", "low"],
            state="readonly",
            width=8
        )
        priority_combo.pack(side=tk.LEFT, padx=(0, 5))
        # Кастомизируем отображение текста в выпадающем списке (опционально)
        priority_combo.set("medium")

        # Кнопка добавления
        self.add_button = tk.Button(top_frame, text="➕ Добавить", command=self.add_task, bg="light blue")
        self.add_button.pack(side=tk.RIGHT)
        
        # ... (остальной код создания списка и нижней панели без изменений)

Упрощаем метод add_task

Теперь метод добавления задачи не требует создания модального окна. Мы просто берём текст из entry и приоритет из priority_combo.

def add_task(self):
    """Добавляет новую задачу с использованием полей на главном окне"""
    text = self.entry.get().strip()
    if not text:
        messagebox.showwarning("Предупреждение", "Введите текст задачи")
        return

    next_id = get_next_id(self.tasks)
    new_task = {
        "id": next_id,
        "text": text,
        "completed": False,
        "created_at": datetime.now().isoformat(),
        "priority": self.priority_var.get()  # Берём приоритет из выпадающего списка
    }
    self.tasks.append(new_task)
    save_tasks(self.tasks)
    
    # Очищаем поле ввода и обновляем список
    self.entry.delete(0, tk.END)
    self.entry.focus()
    self.refresh_task_list()
    messagebox.showinfo("Успех", f"Задача добавлена (ID: {next_id})")

Шаг 2. Добавляем фильтр по статусу

Фильтр позволит скрывать выполненные задачи или, наоборот, показывать только их. Для этого нам понадобятся радиокнопки (или чекбоксы), которые будут управлять режимом отображения.

Модифицируем init и добавляем переменную фильтра

Добавим в init атрибут self.filter_mode, который будет хранить текущий режим: "all", "active" или "completed".

def __init__(self, root):
    # ... (предыдущий код инициализации)
    self.tasks = load_tasks()
    self.filter_mode = "all"  # Режим фильтрации: all, active, completed
    self.create_widgets()
    self.refresh_task_list()

Добавляем панель фильтров в create_widgets

Разместим элементы управления фильтром между списком задач и нижней панелью.

def create_widgets(self):
    # ... (код создания top_frame и list_frame)

    # Панель фильтров
    filter_frame = tk.Frame(self.root)
    filter_frame.pack(pady=5, padx=10, fill=tk.X)
    
    tk.Label(filter_frame, text="Фильтр:").pack(side=tk.LEFT, padx=(0, 10))
    
    self.filter_var = tk.StringVar(value="all")
    
    tk.Radiobutton(filter_frame, text="Все", variable=self.filter_var, 
                   value="all", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)
    tk.Radiobutton(filter_frame, text="Активные", variable=self.filter_var, 
                   value="active", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)
    tk.Radiobutton(filter_frame, text="Выполненные", variable=self.filter_var, 
                   value="completed", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)
    
    # ... (код создания bottom_frame)

Обновляем метод get_sorted_tasks

Теперь этот метод должен учитывать не только сортировку, но и выбранный фильтр.

def get_sorted_tasks(self):
    """Возвращает задачи с учётом фильтрации и сортировки"""
    # Сначала фильтруем
    if self.filter_mode == "active":
        filtered_tasks = [task for task in self.tasks if not task["completed"]]
    elif self.filter_mode == "completed":
        filtered_tasks = [task for task in self.tasks if task["completed"]]
    else:  # "all"
        filtered_tasks = self.tasks.copy()
    
    # Затем сортируем: активные сверху, выполненные снизу
    active_tasks = [task for task in filtered_tasks if not task["completed"]]
    completed_tasks = [task for task in filtered_tasks if task["completed"]]
    
    active_tasks.sort(key=lambda x: x["id"])
    completed_tasks.sort(key=lambda x: x["id"])
    
    return active_tasks + completed_tasks

Важно: Не забудьте обновить refresh_task_list, чтобы он использовал self.filter_var.get() для установки self.filter_mode (или можно передавать фильтр напрямую в get_sorted_tasks). Добавим это в начало refresh_task_list:

def refresh_task_list(self):
    """Обновляет отображение списка задач"""
    self.filter_mode = self.filter_var.get()  # Обновляем режим фильтра
    self.task_listbox.delete(0, tk.END)
    sorted_tasks = self.get_sorted_tasks()
    # ... (остальной код без изменений)

Шаг 3. Реализуем поиск по задачам

Поиск — это функция, которая работает поверх фильтра. Мы добавим поле ввода, при вводе в которое список будет динамически обновляться, показывая только задачи, содержащие искомый текст.

Добавляем поле поиска в create_widgets

Разместим его на отдельной панели под фильтрами или объединим с ними для компактности.

def create_widgets(self):
    # ... (код создания filter_frame)

    # Панель поиска
    search_frame = tk.Frame(self.root)
    search_frame.pack(pady=(0, 5), padx=10, fill=tk.X)
    
    tk.Label(search_frame, text="Поиск:").pack(side=tk.LEFT, padx=(0, 5))
    self.search_entry = tk.Entry(search_frame, font=("Arial", 10))
    self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
    self.search_entry.bind("<KeyRelease>", lambda event: self.refresh_task_list())  # Поиск при каждом вводе
    
    # Кнопка очистки поиска
    clear_btn = tk.Button(search_frame, text="✖", command=self.clear_search, width=3)
    clear_btn.pack(side=tk.RIGHT, padx=(5, 0))
    
    # ... (остальной код)

Добавляем метод clear_search

def clear_search(self):
    """Очищает поле поиска и обновляет список"""
    self.search_entry.delete(0, tk.END)
    self.refresh_task_list()

Обновляем логику фильтрации в get_sorted_tasks

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

def get_sorted_tasks(self):
    """Возвращает задачи с учётом фильтрации, поиска и сортировки"""
    # 1. Фильтр по статусу
    if self.filter_mode == "active":
        filtered_tasks = [task for task in self.tasks if not task["completed"]]
    elif self.filter_mode == "completed":
        filtered_tasks = [task for task in self.tasks if task["completed"]]
    else:
        filtered_tasks = self.tasks.copy()
    
    # 2. Фильтр по тексту (поиск)
    search_text = self.search_entry.get().strip().lower()
    if search_text:
        filtered_tasks = [task for task in filtered_tasks if search_text in task["text"].lower()]
    
    # 3. Сортировка
    active_tasks = [task for task in filtered_tasks if not task["completed"]]
    completed_tasks = [task for task in filtered_tasks if task["completed"]]
    
    active_tasks.sort(key=lambda x: x["id"])
    completed_tasks.sort(key=lambda x: x["id"])
    
    return active_tasks + completed_tasks

Шаг 4. Экспорт задач в CSV

Добавим возможность сохранять текущий отфильтрованный и отсортированный список задач в файл CSV. Это полезно для бэкапов или анализа данных.

Добавляем кнопку экспорта в bottom_frame

def create_widgets(self):
    # ... (код создания bottom_frame)
    
    self.export_button = tk.Button(bottom_frame, text="📎 Экспорт в CSV", command=self.export_to_csv, bg="light cyan")
    self.export_button.pack(side=tk.LEFT, padx=5)
    
    # ... (остальные кнопки)

Реализуем метод export_to_csv

Для работы с CSV используем встроенный модуль csv. В начале файла добавим импорт: import csv.

def export_to_csv(self):
    """Экспортирует текущий список задач в CSV-файл"""
    from tkinter import filedialog
    
    # Получаем текущий отображаемый список (с учётом фильтров и поиска)
    tasks_to_export = self.get_sorted_tasks()
    
    if not tasks_to_export:
        messagebox.showinfo("Информация", "Нет задач для экспорта")
        return
    
    # Диалог выбора места сохранения
    filename = filedialog.asksaveasfilename(
        defaultextension=".csv",
        filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
        title="Сохранить как CSV"
    )
    
    if not filename:
        return
    
    try:
        with open(filename, 'w', encoding='utf-8-sig', newline='') as f:
            writer = csv.writer(f, delimiter=';')
            # Заголовки
            writer.writerow(['ID', 'Текст', 'Статус', 'Приоритет', 'Дата создания'])
            
            for task in tasks_to_export:
                status = "Выполнена" if task["completed"] else "Активна"
                
                # Форматируем дату для читаемости
                try:
                    created = datetime.fromisoformat(task["created_at"])
                    date_str = created.strftime("%d.%m.%Y %H:%M:%S")
                except:
                    date_str = task["created_at"]
                
                # Маппинг приоритетов для читаемости
                priority_map = {"high": "Высокий", "medium": "Средний", "low": "Низкий"}
                priority_display = priority_map.get(task["priority"], task["priority"])
                
                writer.writerow([
                    task["id"],
                    task["text"],
                    status,
                    priority_display,
                    date_str
                ])
        
        messagebox.showinfo("Успех", f"Задачи экспортированы в файл:\n{filename}")
    
    except Exception as e:
        messagebox.showerror("Ошибка", f"Не удалось экспортировать задачи:\n{str(e)}")

Шаг 5. Экспорт задач в PDF

Помимо CSV-экспорта, добавим возможность создавать профессионально оформленные PDF-отчёты. Это особенно полезно для печати, отправки по электронной почте или архивации выполненных задач в читаемом формате.

Установка необходимой библиотеки

Для работы с PDF нам потребуется библиотека reportlab. Установите её через pip:

pip install reportlab

Добавляем кнопку экспорта в PDF

Добавим новую кнопку на нижнюю панель рядом с кнопкой экспорта в PDF:

def create_widgets(self):
    # ... (код создания bottom_frame)
    
    self.export_csv_btn = tk.Button(bottom_frame, text="📎 Экспорт в CSV", command=self.export_to_csv, bg="light cyan")
    self.export_csv_btn.pack(side=tk.LEFT, padx=5)
    
    self.export_pdf_btn = tk.Button(bottom_frame, text="📄 Экспорт в PDF", command=self.export_to_pdf, bg="light pink")
    self.export_pdf_btn.pack(side=tk.LEFT, padx=5)
    
    # ... (остальные кнопки)

Реализуем метод export_to_pdf

Для создания PDF-документа используем возможности reportlab. Создадим отчёт с заголовком, датой генерации, таблицей задач и статистикой. Ключевая особенность — использование Paragraph для автоматического переноса текста в ячейках.

def export_to_pdf(self):
    """Экспортирует текущий список задач в PDF-файл с форматированием и переносом текста"""
    from tkinter import filedialog
    from reportlab.lib import colors
    from reportlab.lib.pagesizes import A4, landscape
    from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
    from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    import os
    
    # Регистрируем шрифт, поддерживающий кириллицу
    # Пробуем использовать системные шрифты Windows
    font_paths = [
        "C:/Windows/Fonts/arial.ttf",           # Arial
        "C:/Windows/Fonts/times.ttf",           # Times New Roman
        "C:/Windows/Fonts/calibri.ttf",         # Calibri
        "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",  # Linux
        "/System/Library/Fonts/Arial.ttf",      # macOS
    ]
    
    font_registered = False
    for font_path in font_paths:
        if os.path.exists(font_path):
            try:
                pdfmetrics.registerFont(TTFont('RussianFont', font_path))
                font_registered = True
                break
            except:
                continue
    
    if not font_registered:
        messagebox.showwarning(
            "Предупреждение", 
            "Не найден шрифт для отображения русского текста.\n"
            "Текст может отображаться некорректно."
        )
        # Используем стандартный шрифт (не будет отображать русский текст)
        font_name = 'Helvetica'
    else:
        font_name = 'RussianFont'
    
    # Получаем текущий отображаемый список
    tasks_to_export = self.get_sorted_tasks()
    
    if not tasks_to_export:
        messagebox.showinfo("Информация", "Нет задач для экспорта")
        return
    
    # Диалог выбора места сохранения
    filename = filedialog.asksaveasfilename(
        defaultextension=".pdf",
        filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
        title="Сохранить как PDF"
    )
    
    if not filename:
        return
    
    try:
        # Используем альбомную ориентацию для лучшего отображения длинных текстов
        doc = SimpleDocTemplate(filename, pagesize=landscape(A4),
                                rightMargin=50, leftMargin=50,
                                topMargin=50, bottomMargin=50)
        
        # Стили
        styles = getSampleStyleSheet()
        
        # Создаём базовый стиль с поддержкой русского шрифта
        base_style = ParagraphStyle(
            'BaseStyle',
            parent=styles['Normal'],
            fontName=font_name,
            fontSize=9,
            leading=12
        )
        
        # Создаём стиль для ячеек с поддержкой переноса текста
        cell_style = ParagraphStyle(
            'CellStyle',
            parent=base_style,
            fontSize=9,
            leading=12,  # Межстрочный интервал
            alignment=0,  # Выравнивание по левому краю
            wordWrap='CJK'  # Включаем перенос слов
        )
        
        title_style = ParagraphStyle(
            'CustomTitle',
            parent=styles['Heading1'],
            fontName=font_name,
            fontSize=24,
            textColor=colors.HexColor('#2c3e50'),
            spaceAfter=30,
            alignment=1  # Центрирование
        )
        
        subtitle_style = ParagraphStyle(
            'CustomSubtitle',
            parent=styles['Normal'],
            fontName=font_name,
            fontSize=12,
            textColor=colors.HexColor('#7f8c8d'),
            spaceAfter=20,
            alignment=1
        )
        
        header_style = ParagraphStyle(
            'HeaderStyle',
            parent=styles['Normal'],
            fontName=font_name,
            fontSize=10,
            textColor=colors.whitesmoke,
            alignment=0
        )
        
        normal_style = ParagraphStyle(
            'NormalStyle',
            parent=styles['Normal'],
            fontName=font_name,
            fontSize=10
        )
        
        # Собираем контент
        story = []
        
        # Заголовок
        story.append(Paragraph("Менеджер задач - Отчёт", title_style))
        
        # Дата и время генерации
        now = datetime.now()
        date_string = now.strftime("%d.%m.%Y %H:%M:%S")
        story.append(Paragraph(f"Дата создания отчёта: {date_string}", subtitle_style))
        
        # Информация о фильтрах
        filter_info = []
        if self.filter_mode == "active":
            filter_info.append("Только активные")
        elif self.filter_mode == "completed":
            filter_info.append("Только выполненные")
        else:
            filter_info.append("Все задачи")
        
        search_text = self.search_entry.get().strip()
        if search_text:
            filter_info.append(f"Поиск: \"{search_text}\"")
        
        story.append(Paragraph(f"Фильтры: {', '.join(filter_info)}", normal_style))
        story.append(Spacer(1, 20))
        
        # Статистика
        total_tasks = len(tasks_to_export)
        completed_count = sum(1 for t in tasks_to_export if t["completed"])
        active_count = total_tasks - completed_count
        high_priority = sum(1 for t in tasks_to_export if t["priority"] == "high" and not t["completed"])
        
        stats_text = f"""
        <b>Статистика:</b><br/>
        • Всего задач: {total_tasks}<br/>
        • Активных: {active_count}<br/>
        • Выполненных: {completed_count}<br/>
        • Активных с высоким приоритетом: {high_priority}
        """
        
        story.append(Paragraph(stats_text, normal_style))
        story.append(Spacer(1, 20))
        
        # Подготовка данных для таблицы
        table_data = []
        
        # Заголовки таблицы
        headers = ["ID", "Задача", "Статус", "Приоритет", "Дата создания"]
        header_cells = []
        for h in headers:
            header_cells.append(Paragraph(f"<b>{h}</b>", header_style))
        table_data.append(header_cells)
        
        # Добавляем задачи
        for task in tasks_to_export:
            # Форматируем статус
            status_text = "✓ Выполнена" if task["completed"] else "○ Активна"
            status_color = "#27ae60" if task["completed"] else "#e67e22"
            
            # Форматируем приоритет с цветом
            priority_map = {
                "high": ("🔴 Высокий", "#e74c3c"), 
                "medium": ("🟠 Средний", "#f39c12"), 
                "low": ("⚪ Низкий", "#95a5a6")
            }
            priority_display, priority_color = priority_map.get(
                task["priority"], 
                (task["priority"], "#000000")
            )
            
            # Форматируем дату
            try:
                created = datetime.fromisoformat(task["created_at"])
                date_str = created.strftime("%d.%m.%Y %H:%M")
            except:
                date_str = task["created_at"]
            
            # Создаём ячейки с Paragraph для автоматического переноса текста
            id_cell = Paragraph(str(task["id"]), cell_style)
            task_cell = Paragraph(task["text"], cell_style)
            status_cell = Paragraph(
                f'<font color="{status_color}">{status_text}</font>', 
                cell_style
            )
            priority_cell = Paragraph(
                f'<font color="{priority_color}">{priority_display}</font>', 
                cell_style
            )
            date_cell = Paragraph(date_str, cell_style)
            
            table_data.append([id_cell, task_cell, status_cell, priority_cell, date_cell])
        
        # Настраиваем ширину колонок для альбомной ориентации
        page_width = landscape(A4)[0] - 100  # Вычитаем поля
        col_widths = [
            page_width * 0.08,   # ID - 8%
            page_width * 0.52,   # Задача - 52% (основное пространство)
            page_width * 0.12,   # Статус - 12%
            page_width * 0.13,   # Приоритет - 13%
            page_width * 0.15    # Дата - 15%
        ]
        
        # Создаём таблицу
        table = Table(
            table_data, 
            colWidths=col_widths, 
            repeatRows=1,  # Повторять заголовок на каждой странице
            rowHeights=None  # Автоматическая высота строк
        )
        
        # Стиль таблицы
        table.setStyle(TableStyle([
            # Заголовок
            ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#34495e')),
            ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
            ('ALIGN', (0, 0), (-1, 0), 'CENTER'),
            ('VALIGN', (0, 0), (-1, 0), 'MIDDLE'),
            ('FONTSIZE', (0, 0), (-1, 0), 10),
            ('BOTTOMPADDING', (0, 0), (-1, 0), 8),
            ('TOPPADDING', (0, 0), (-1, 0), 8),
            
            # Общие настройки для всех ячеек
            ('VALIGN', (0, 1), (-1, -1), 'TOP'),  # Выравнивание по верхнему краю
            ('LEFTPADDING', (0, 0), (-1, -1), 5),
            ('RIGHTPADDING', (0, 0), (-1, -1), 5),
            ('TOPPADDING', (0, 1), (-1, -1), 6),
            ('BOTTOMPADDING', (0, 1), (-1, -1), 6),
            
            # Сетка
            ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
            
            # Выравнивание отдельных колонок
            ('ALIGN', (0, 0), (0, -1), 'CENTER'),  # ID по центру
            ('ALIGN', (2, 0), (2, -1), 'CENTER'),  # Статус по центру
            ('ALIGN', (3, 0), (3, -1), 'CENTER'),  # Приоритет по центру
            ('ALIGN', (4, 0), (4, -1), 'CENTER'),  # Дата по центру
        ]))
        
        # Чередование цветов строк
        for i in range(1, len(table_data)):
            if i % 2 == 0:
                table.setStyle(TableStyle([
                    ('BACKGROUND', (0, i), (-1, i), colors.HexColor('#f8f9fa'))
                ]))
        
        story.append(table)
        
        # Добавляем подвал с номерами страниц
        def add_page_number(canvas, doc):
            """Добавляет номера страниц"""
            page_num = canvas.getPageNumber()
            text = f"Страница {page_num}"
            canvas.saveState()
            canvas.setFont(font_name, 8)
            canvas.drawCentredString(doc.width / 2, 20, text)
            canvas.restoreState()
        
        # Добавляем итоговую информацию
        story.append(Spacer(1, 30))
        
        footer_text = f"""
        <font size=8>
        <b>Примечания:</b><br/>
        • Отчёт сгенерирован программой "Менеджер задач"<br/>
        • Всего задач в отчёте: {total_tasks}<br/>
        • Дата генерации: {date_string}<br/>
        • Формат: PDF (Portable Document Format)
        </font>
        """
        
        story.append(Paragraph(footer_text, normal_style))
        
        # Создаём PDF с номерами страниц
        doc.build(story, onFirstPage=add_page_number, onLaterPages=add_page_number)
        
        messagebox.showinfo("Успех", f"PDF-отчёт успешно создан с поддержкой русского языка:\n{filename}")
    
    except ImportError:
        messagebox.showerror(
            "Ошибка", 
            "Библиотека reportlab не установлена.\nУстановите её командой: pip install reportlab"
        )
    except Exception as e:
        messagebox.showerror("Ошибка", f"Не удалось экспортировать PDF:\n{str(e)}")

Финальный код

import json
import os
import csv
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import datetime

# ==================== МОДЕЛЬ ДАННЫХ ====================
TASKS_FILE = "tasks.json"

def load_tasks():
    """Загружает задачи из JSON-файла с обратной совместимостью"""
    if not os.path.exists(TASKS_FILE):
        return []
    with open(TASKS_FILE, "r", encoding="utf-8") as f:
        try:
            tasks = json.load(f)
            for task in tasks:
                if "created_at" not in task:
                    task["created_at"] = datetime.now().isoformat()
                if "priority" not in task:
                    task["priority"] = "medium"
            return tasks
        except json.JSONDecodeError:
            return []

def save_tasks(tasks):
    """Сохраняет задачи в JSON-файл"""
    with open(TASKS_FILE, "w", encoding="utf-8") as f:
        json.dump(tasks, f, ensure_ascii=False, indent=2)

def get_next_id(tasks):
    """Возвращает следующий доступный ID для новой задачи"""
    if not tasks:
        return 1
    return max(task["id"] for task in tasks) + 1

# ==================== ПРИЛОЖЕНИЕ ====================
class TodoApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Менеджер задач Pro")
        self.root.geometry("700x600")
        self.root.resizable(False, False)

        self.tasks = load_tasks()
        self.filter_mode = "all"
        
        self.create_widgets()
        self.refresh_task_list()

    def create_widgets(self):
        """Создаёт все виджеты окна"""
        # Верхняя панель: добавление задачи
        top_frame = tk.Frame(self.root)
        top_frame.pack(pady=10, padx=10, fill=tk.X)

        self.entry = tk.Entry(top_frame, font=("Arial", 12))
        self.entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
        self.entry.bind("<Return>", lambda event: self.add_task())

        tk.Label(top_frame, text="Приоритет:").pack(side=tk.LEFT, padx=(5, 2))
        self.priority_var = tk.StringVar(value="medium")
        priority_combo = ttk.Combobox(
            top_frame, 
            textvariable=self.priority_var, 
            values=["high", "medium", "low"],
            state="readonly",
            width=8
        )
        priority_combo.pack(side=tk.LEFT, padx=(0, 5))
        
        self.add_button = tk.Button(top_frame, text="➕ Добавить", command=self.add_task, bg="light blue")
        self.add_button.pack(side=tk.RIGHT)

        # Список задач
        list_frame = tk.Frame(self.root)
        list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        scrollbar = tk.Scrollbar(list_frame)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        self.task_listbox = tk.Listbox(
            list_frame,
            font=("Arial", 10),
            yscrollcommand=scrollbar.set,
            selectmode=tk.SINGLE,
            height=15,
            activestyle="none"
        )
        self.task_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.config(command=self.task_listbox.yview)
        self.task_listbox.bind("<Double-Button-1>", lambda event: self.edit_task())

        # Панель фильтров
        filter_frame = tk.Frame(self.root)
        filter_frame.pack(pady=5, padx=10, fill=tk.X)
        
        tk.Label(filter_frame, text="Фильтр:").pack(side=tk.LEFT, padx=(0, 10))
        self.filter_var = tk.StringVar(value="all")
        
        tk.Radiobutton(filter_frame, text="Все", variable=self.filter_var, 
                       value="all", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)
        tk.Radiobutton(filter_frame, text="Активные", variable=self.filter_var, 
                       value="active", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)
        tk.Radiobutton(filter_frame, text="Выполненные", variable=self.filter_var, 
                       value="completed", command=self.refresh_task_list).pack(side=tk.LEFT, padx=5)

        # Панель поиска
        search_frame = tk.Frame(self.root)
        search_frame.pack(pady=(0, 5), padx=10, fill=tk.X)
        
        tk.Label(search_frame, text="Поиск:").pack(side=tk.LEFT, padx=(0, 5))
        self.search_entry = tk.Entry(search_frame, font=("Arial", 10))
        self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
        self.search_entry.bind("<KeyRelease>", lambda event: self.refresh_task_list())
        
        clear_btn = tk.Button(search_frame, text="✖", command=self.clear_search, width=3)
        clear_btn.pack(side=tk.RIGHT, padx=(5, 0))

        # Нижняя панель: кнопки действий
        bottom_frame = tk.Frame(self.root)
        bottom_frame.pack(pady=10, padx=10, fill=tk.X)

        self.edit_button = tk.Button(bottom_frame, text="✏ Редактировать", command=self.edit_task, bg="light yellow")
        self.edit_button.pack(side=tk.LEFT, padx=5)

        self.done_button = tk.Button(bottom_frame, text="✅ Выполнено", command=self.mark_done, bg="light green")
        self.done_button.pack(side=tk.LEFT, padx=5)

        self.delete_button = tk.Button(bottom_frame, text="🗑 Удалить", command=self.delete_task, bg="light coral")
        self.delete_button.pack(side=tk.LEFT, padx=5)

        self.export_csv_btn = tk.Button(bottom_frame, text="📎 Экспорт в CSV", command=self.export_to_csv, bg="light cyan")
        self.export_csv_btn.pack(side=tk.LEFT, padx=5)
        
        self.export_pdf_btn = tk.Button(bottom_frame, text="📄 Экспорт в PDF", command=self.export_to_pdf, bg="light pink")
        self.export_pdf_btn.pack(side=tk.LEFT, padx=5)

        self.exit_button = tk.Button(bottom_frame, text="Выход", command=self.root.quit, bg="light gray")
        self.exit_button.pack(side=tk.RIGHT, padx=5)

        # Информационная строка
        info_label = tk.Label(self.root, text="Совет: Двойной клик по задаче - редактирование | Поиск работает в реальном времени", 
                             font=("Arial", 8), fg="gray")
        info_label.pack(side=tk.BOTTOM, pady=5)

    def get_sorted_tasks(self):
        """Возвращает задачи с учётом фильтрации, поиска и сортировки"""
        # Фильтр по статусу
        if self.filter_mode == "active":
            filtered = [task for task in self.tasks if not task["completed"]]
        elif self.filter_mode == "completed":
            filtered = [task for task in self.tasks if task["completed"]]
        else:
            filtered = self.tasks.copy()
        
        # Поиск по тексту
        search_text = self.search_entry.get().strip().lower()
        if search_text:
            filtered = [task for task in filtered if search_text in task["text"].lower()]
        
        # Сортировка
        active = [task for task in filtered if not task["completed"]]
        completed = [task for task in filtered if task["completed"]]
        active.sort(key=lambda x: x["id"])
        completed.sort(key=lambda x: x["id"])
        
        return active + completed

    def refresh_task_list(self):
        """Обновляет отображение списка задач"""
        self.filter_mode = self.filter_var.get()
        self.task_listbox.delete(0, tk.END)
        sorted_tasks = self.get_sorted_tasks()

        for task in sorted_tasks:
            status = "✓" if task["completed"] else "○"
            
            try:
                created = datetime.fromisoformat(task["created_at"])
                date_str = created.strftime("%d.%m.%y %H:%M")
            except:
                date_str = "??.??.??"
            
            priority_symbol = ""
            if task["priority"] == "high":
                priority_symbol = "🔴 "
            elif task["priority"] == "medium":
                priority_symbol = "🟠 "
            elif task["priority"] == "low":
                priority_symbol = "⚪ "
            
            display_text = f"{status} {priority_symbol}[{date_str}] {task['id']}. {task['text']}"
            self.task_listbox.insert(tk.END, display_text)
            
            bg_color = "white"
            fg_color = "black"
            
            if task["completed"]:
                fg_color = "#006400"
                bg_color = "#E8E8E8"
            else:
                if task["priority"] == "high":
                    fg_color = "#8B0000"
                    bg_color = "#FFE6E6"
                elif task["priority"] == "medium":
                    fg_color = "#CC7000"
                    bg_color = "#FFF0E6"
            
            self.task_listbox.itemconfig(tk.END, bg=bg_color, fg=fg_color)

    def add_task(self):
        """Добавляет новую задачу"""
        text = self.entry.get().strip()
        if not text:
            messagebox.showwarning("Предупреждение", "Введите текст задачи")
            return

        next_id = get_next_id(self.tasks)
        new_task = {
            "id": next_id,
            "text": text,
            "completed": False,
            "created_at": datetime.now().isoformat(),
            "priority": self.priority_var.get()
        }
        self.tasks.append(new_task)
        save_tasks(self.tasks)
        
        self.entry.delete(0, tk.END)
        self.entry.focus()
        self.refresh_task_list()
        messagebox.showinfo("Успех", f"Задача добавлена (ID: {next_id})")

    def edit_task(self):
        """Открывает окно для редактирования выбранной задачи"""
        selection = self.task_listbox.curselection()
        if not selection:
            messagebox.showwarning("Предупреждение", "Выберите задачу для редактирования")
            return

        sorted_tasks = self.get_sorted_tasks()
        selected_task = sorted_tasks[selection[0]]
        task_id = selected_task["id"]

        task_to_edit = None
        for task in self.tasks:
            if task["id"] == task_id:
                task_to_edit = task
                break

        if not task_to_edit:
            return

        edit_window = tk.Toplevel(self.root)
        edit_window.title(f"Редактирование задачи #{task_id}")
        edit_window.geometry("500x320")
        edit_window.resizable(False, False)
        edit_window.grab_set()

        tk.Label(edit_window, text="Текст задачи:", font=("Arial", 10, "bold")).pack(pady=(15, 5), anchor=tk.W, padx=15)
        text_entry = tk.Text(edit_window, height=5, width=55, font=("Arial", 10))
        text_entry.pack(pady=5, padx=15, fill=tk.BOTH, expand=True)
        text_entry.insert("1.0", task_to_edit["text"])
        text_entry.focus()

        try:
            created = datetime.fromisoformat(task_to_edit["created_at"])
            date_info = f"Создано: {created.strftime('%d.%m.%Y %H:%M:%S')}"
        except:
            date_info = "Дата создания неизвестна"
        
        tk.Label(edit_window, text=date_info, font=("Arial", 8), fg="gray").pack(pady=(5, 0), anchor=tk.W, padx=15)

        tk.Label(edit_window, text="Приоритет:", font=("Arial", 10, "bold")).pack(pady=(10, 5), anchor=tk.W, padx=15)
        priority_var = tk.StringVar(value=task_to_edit["priority"])
        priority_frame = tk.Frame(edit_window)
        priority_frame.pack(pady=5, padx=15, anchor=tk.W)

        tk.Radiobutton(priority_frame, text="🔴 Высокий", variable=priority_var, 
                      value="high", fg="red").pack(side=tk.LEFT, padx=10)
        tk.Radiobutton(priority_frame, text="🟠 Средний", variable=priority_var, 
                      value="medium", fg="orange").pack(side=tk.LEFT, padx=10)
        tk.Radiobutton(priority_frame, text="⚪ Низкий", variable=priority_var, 
                      value="low").pack(side=tk.LEFT, padx=10)

        button_frame = tk.Frame(edit_window)
        button_frame.pack(pady=20)

        def save_edit():
            new_text = text_entry.get("1.0", "end-1c").strip()
            if not new_text:
                messagebox.showwarning("Предупреждение", "Текст задачи не может быть пустым", parent=edit_window)
                return

            task_to_edit["text"] = new_text
            task_to_edit["priority"] = priority_var.get()
            save_tasks(self.tasks)
            self.refresh_task_list()
            edit_window.destroy()
            messagebox.showinfo("Успех", f"Задача #{task_id} обновлена")

        tk.Button(button_frame, text="Сохранить", command=save_edit, 
                 bg="light green", width=12).pack(side=tk.LEFT, padx=10)
        tk.Button(button_frame, text="Отмена", command=edit_window.destroy, 
                 bg="light gray", width=12).pack(side=tk.LEFT, padx=10)

    def mark_done(self):
        """Отмечает выбранную задачу как выполненную"""
        selection = self.task_listbox.curselection()
        if not selection:
            messagebox.showwarning("Предупреждение", "Выберите задачу")
            return

        sorted_tasks = self.get_sorted_tasks()
        selected_task = sorted_tasks[selection[0]]
        task_id = selected_task["id"]

        for task in self.tasks:
            if task["id"] == task_id:
                if task["completed"]:
                    messagebox.showinfo("Информация", "Задача уже выполнена")
                else:
                    task["completed"] = True
                    save_tasks(self.tasks)
                    self.refresh_task_list()
                    messagebox.showinfo("Успех", f"Задача #{task_id} отмечена как выполненная")
                return

    def delete_task(self):
        """Удаляет выбранную задачу"""
        selection = self.task_listbox.curselection()
        if not selection:
            messagebox.showwarning("Предупреждение", "Выберите задачу")
            return

        sorted_tasks = self.get_sorted_tasks()
        selected_task = sorted_tasks[selection[0]]
        task_id = selected_task["id"]
        task_text = selected_task["text"]

        if messagebox.askyesno("Подтверждение", f"Удалить задачу #{task_id}\n\"{task_text[:50]}\"?"):
            for i, task in enumerate(self.tasks):
                if task["id"] == task_id:
                    del self.tasks[i]
                    break
            save_tasks(self.tasks)
            self.refresh_task_list()
            messagebox.showinfo("Успех", f"Задача #{task_id} удалена")

    def clear_search(self):
        """Очищает поле поиска и обновляет список"""
        self.search_entry.delete(0, tk.END)
        self.refresh_task_list()

    def export_to_csv(self):
        """Экспортирует текущий список задач в CSV-файл"""
        tasks_to_export = self.get_sorted_tasks()
        
        if not tasks_to_export:
            messagebox.showinfo("Информация", "Нет задач для экспорта")
            return
        
        filename = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
            title="Сохранить как CSV"
        )
        
        if not filename:
            return
        
        try:
            with open(filename, 'w', encoding='utf-8-sig', newline='') as f:
                writer = csv.writer(f, delimiter=';')
                writer.writerow(['ID', 'Текст', 'Статус', 'Приоритет', 'Дата создания'])
                
                for task in tasks_to_export:
                    status = "Выполнена" if task["completed"] else "Активна"
                    
                    try:
                        created = datetime.fromisoformat(task["created_at"])
                        date_str = created.strftime("%d.%m.%Y %H:%M:%S")
                    except:
                        date_str = task["created_at"]
                    
                    priority_map = {"high": "Высокий", "medium": "Средний", "low": "Низкий"}
                    priority_display = priority_map.get(task["priority"], task["priority"])
                    
                    writer.writerow([
                        task["id"],
                        task["text"],
                        status,
                        priority_display,
                        date_str
                    ])
            
            messagebox.showinfo("Успех", f"Задачи экспортированы в файл:\n{filename}")
        
        except Exception as e:
            messagebox.showerror("Ошибка", f"Не удалось экспортировать задачи:\n{str(e)}")

    def export_to_pdf(self):
        """Экспортирует текущий список задач в PDF-файл с форматированием и переносом текста"""
        from tkinter import filedialog
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, landscape
        from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
        from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
        
        # Получаем текущий отображаемый список
        tasks_to_export = self.get_sorted_tasks()
        
        if not tasks_to_export:
            messagebox.showinfo("Информация", "Нет задач для экспорта")
            return
        
        # Диалог выбора места сохранения
        filename = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
            title="Сохранить как PDF"
        )
        
        if not filename:
            return
        
        try:
            # Используем альбомную ориентацию для лучшего отображения длинных текстов
            doc = SimpleDocTemplate(filename, pagesize=landscape(A4),
                                    rightMargin=50, leftMargin=50,
                                    topMargin=50, bottomMargin=50)
            
            # Стили
            styles = getSampleStyleSheet()
            
            # Создаём стиль для ячеек с поддержкой переноса текста
            cell_style = ParagraphStyle(
                'CellStyle',
                parent=styles['Normal'],
                fontSize=9,
                leading=12,  # Межстрочный интервал
                alignment=0,  # Выравнивание по левому краю
                wordWrap='CJK'  # Включаем перенос слов
            )
            
            title_style = ParagraphStyle(
                'CustomTitle',
                parent=styles['Heading1'],
                fontSize=24,
                textColor=colors.HexColor('#2c3e50'),
                spaceAfter=30,
                alignment=1  # Центрирование
            )
            
            subtitle_style = ParagraphStyle(
                'CustomSubtitle',
                parent=styles['Normal'],
                fontSize=12,
                textColor=colors.HexColor('#7f8c8d'),
                spaceAfter=20,
                alignment=1
            )
            
            header_style = ParagraphStyle(
                'HeaderStyle',
                parent=styles['Normal'],
                fontSize=10,
                textColor=colors.whitesmoke,
                alignment=0,
                fontName='Helvetica-Bold'
            )
            
            # Собираем контент
            story = []
            
            # Заголовок
            story.append(Paragraph("Менеджер задач - Отчёт", title_style))
            
            # Дата и время генерации
            now = datetime.now()
            date_string = now.strftime("%d.%m.%Y %H:%M:%S")
            story.append(Paragraph(f"Дата создания отчёта: {date_string}", subtitle_style))
            
            # Информация о фильтрах
            filter_info = []
            if self.filter_mode == "active":
                filter_info.append("Только активные")
            elif self.filter_mode == "completed":
                filter_info.append("Только выполненные")
            else:
                filter_info.append("Все задачи")
            
            search_text = self.search_entry.get().strip()
            if search_text:
                filter_info.append(f"Поиск: \"{search_text}\"")
            
            story.append(Paragraph(f"Фильтры: {', '.join(filter_info)}", styles['Normal']))
            story.append(Spacer(1, 20))
            
            # Статистика
            total_tasks = len(tasks_to_export)
            completed_count = sum(1 for t in tasks_to_export if t["completed"])
            active_count = total_tasks - completed_count
            high_priority = sum(1 for t in tasks_to_export if t["priority"] == "high" and not t["completed"])
            
            stats_text = f"""
            <b>Статистика:</b><br/>
            • Всего задач: {total_tasks}<br/>
            • Активных: {active_count}<br/>
            • Выполненных: {completed_count}<br/>
            • Активных с высоким приоритетом: {high_priority}
            """
            
            story.append(Paragraph(stats_text, styles['Normal']))
            story.append(Spacer(1, 20))
            
            # Подготовка данных для таблицы
            table_data = []
            
            # Заголовки таблицы
            headers = ["ID", "Задача", "Статус", "Приоритет", "Дата создания"]
            table_data.append([
                Paragraph(f"<b>{h}</b>", header_style) for h in headers
            ])
            
            # Добавляем задачи
            for task in tasks_to_export:
                # Форматируем статус
                status_text = "✓ Выполнена" if task["completed"] else "○ Активна"
                status_color = "#27ae60" if task["completed"] else "#e67e22"
                
                # Форматируем приоритет с цветом
                priority_map = {
                    "high": ("🔴 Высокий", "#e74c3c"), 
                    "medium": ("🟠 Средний", "#f39c12"), 
                    "low": ("⚪ Низкий", "#95a5a6")
                }
                priority_display, priority_color = priority_map.get(
                    task["priority"], 
                    (task["priority"], "#000000")
                )
                
                # Форматируем дату
                try:
                    created = datetime.fromisoformat(task["created_at"])
                    date_str = created.strftime("%d.%m.%Y %H:%M")
                except:
                    date_str = task["created_at"]
                
                # Создаём ячейки с Paragraph для автоматического переноса текста
                id_cell = Paragraph(str(task["id"]), cell_style)
                task_cell = Paragraph(task["text"], cell_style)
                status_cell = Paragraph(
                    f'<font color="{status_color}">{status_text}</font>', 
                    cell_style
                )
                priority_cell = Paragraph(
                    f'<font color="{priority_color}">{priority_display}</font>', 
                    cell_style
                )
                date_cell = Paragraph(date_str, cell_style)
                
                table_data.append([id_cell, task_cell, status_cell, priority_cell, date_cell])
            
            # Настраиваем ширину колонок для альбомной ориентации
            page_width = landscape(A4)[0] - 100  # Вычитаем поля
            col_widths = [
                page_width * 0.08,   # ID - 8%
                page_width * 0.52,   # Задача - 52% (основное пространство)
                page_width * 0.12,   # Статус - 12%
                page_width * 0.13,   # Приоритет - 13%
                page_width * 0.15    # Дата - 15%
            ]
            
            # Создаём таблицу
            table = Table(
                table_data, 
                colWidths=col_widths, 
                repeatRows=1,  # Повторять заголовок на каждой странице
                rowHeights=None  # Автоматическая высота строк
            )
            
            # Стиль таблицы
            table.setStyle(TableStyle([
                # Заголовок
                ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#34495e')),
                ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
                ('ALIGN', (0, 0), (-1, 0), 'CENTER'),
                ('VALIGN', (0, 0), (-1, 0), 'MIDDLE'),
                ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
                ('FONTSIZE', (0, 0), (-1, 0), 10),
                ('BOTTOMPADDING', (0, 0), (-1, 0), 8),
                ('TOPPADDING', (0, 0), (-1, 0), 8),
                
                # Общие настройки для всех ячеек
                ('VALIGN', (0, 1), (-1, -1), 'TOP'),  # Выравнивание по верхнему краю
                ('LEFTPADDING', (0, 0), (-1, -1), 5),
                ('RIGHTPADDING', (0, 0), (-1, -1), 5),
                ('TOPPADDING', (0, 1), (-1, -1), 6),
                ('BOTTOMPADDING', (0, 1), (-1, -1), 6),
                
                # Сетка
                ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
                
                # Выравнивание отдельных колонок
                ('ALIGN', (0, 0), (0, -1), 'CENTER'),  # ID по центру
                ('ALIGN', (2, 0), (2, -1), 'CENTER'),  # Статус по центру
                ('ALIGN', (3, 0), (3, -1), 'CENTER'),  # Приоритет по центру
                ('ALIGN', (4, 0), (4, -1), 'CENTER'),  # Дата по центру
            ]))
            
            # Чередование цветов строк
            for i in range(1, len(table_data)):
                if i % 2 == 0:
                    table.setStyle(TableStyle([
                        ('BACKGROUND', (0, i), (-1, i), colors.HexColor('#f8f9fa'))
                    ]))
            
            story.append(table)
            
            # Добавляем подвал с номерами страниц
            def add_page_number(canvas, doc):
                """Добавляет номера страниц"""
                page_num = canvas.getPageNumber()
                text = f"Страница {page_num}"
                canvas.saveState()
                canvas.setFont('Helvetica', 8)
                canvas.drawCentredString(doc.width / 2, 20, text)
                canvas.restoreState()
            
            # Добавляем итоговую информацию
            story.append(Spacer(1, 30))
            
            footer_text = f"""
            <font size=8>
            <b>Примечания:</b><br/>
            • Отчёт сгенерирован программой "Менеджер задач"<br/>
            • Всего задач в отчёте: {total_tasks}<br/>
            • Дата генерации: {date_string}<br/>
            • Формат: PDF (Portable Document Format)
            </font>
            """
            
            story.append(Paragraph(footer_text, styles['Normal']))
            
            # Создаём PDF с номерами страниц
            doc.build(story, onFirstPage=add_page_number, onLaterPages=add_page_number)
            
            messagebox.showinfo("Успех", f"PDF-отчёт успешно создан с переносом текста:\n{filename}")
        
        except ImportError:
            messagebox.showerror(
                "Ошибка", 
                "Библиотека reportlab не установлена.\nУстановите её командой: pip install reportlab"
            )
        except Exception as e:
            messagebox.showerror("Ошибка", f"Не удалось экспортировать PDF:\n{str(e)}")

# Запуск приложения
if __name__ == "__main__":
    root = tk.Tk()
    app = TodoApp(root)
    root.mainloop()

Мы значительно расширили функциональность менеджера задач. Теперь приложение позволяет не только управлять списком дел, но и эффективно организовывать его с помощью гибкой системы фильтрации и поиска. Возможность экспорта данных в форматы CSV и PDF открывает новые возможности.
В следующие разы сделаем exe файл и попробуем переделать это приложение под современный вид с использованием PyQt.