"""SysDevCleaner — интерфейс на Dear PyGui.""" from __future__ import annotations import os import queue import subprocess import sys import threading import time import dearpygui.dearpygui as dpg from ..core import cleaner as cleanmod from ..core import programs as progmod from ..core.advisor import default_selection, plan_summary, report_text from ..core.model import Category, Finding, Program, Risk, ScanResult from ..core.scanner import (ScanOptions, default_project_roots, full_scan, normalize_drives) from ..core.util import Cancel, disk_usage, drives, human, is_admin, open_in_explorer from ..winui import BOOT_TITLE, icon_path, set_window_title from . import icons as I from .dpg_theme import (DARK, LIGHT, build_accent_button_theme, build_bar_theme, build_card_theme, build_theme, risk_color) from .fonts import MISSING_FONT_MESSAGE, Fonts PRIMARY = "primary" ALL_CATEGORIES = [ Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT, Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS, ] FOOTER_H = 78 class App: def __init__(self) -> None: self.result: ScanResult | None = None self.cancel = Cancel() self.q: queue.Queue = queue.Queue() self.busy = False self.findings: list[Finding] = [] # индекс -> находка self.visible: list[int] = [] # индексы, показанные сейчас self.cat_index: dict[Category, list[int]] = {} self.programs: list[Program] = [] self.dark = True self.p = DARK self.roots: list[str] = default_project_roots() self.fonts = Fonts(18) # Диски: список вида ['C:', 'D:']. Отмеченные — те, что будут сканироваться. self.all_drives: list[str] = [d.rstrip("\\/").upper() for d in drives()] self.rec_items: list[int] = [] # тексты рекомендаций — для переносов строк self._title_set = False self.theme_dark = 0 self.theme_light = 0 self.th_accent = 0 self.th_danger = 0 self.th_card = 0 self.th_bar: dict[str, int] = {} # ================================================================ запуск = def run(self) -> None: dpg.create_context() # Шрифты — строго до setup_dearpygui(), иначе атлас глифов уже собран # и кириллица с иконками в него не попадут. self.fonts.build() self._build_themes() self._build_ui() # Заголовок ставим служебный, ASCII: Dear PyGui передаёт его в ANSI-функцию # Windows как UTF-8 байты, и русский текст превратился бы в кракозябры. # Настоящее имя подставим через SetWindowTextW, как только окно появится. ico = icon_path() dpg.create_viewport(title=BOOT_TITLE, width=1420, height=900, min_width=1100, min_height=680, small_icon=ico, large_icon=ico) dpg.setup_dearpygui() dpg.show_viewport() dpg.set_primary_window(PRIMARY, True) dpg.set_viewport_resize_callback(self._on_resize) self._apply_theme() self._on_resize() self._title_set = False if not self.fonts.ok: self._alert("Шрифт не найден", MISSING_FONT_MESSAGE) self._refresh_disks() self._load_journals() while dpg.is_dearpygui_running(): if not self._title_set: self._title_set = set_window_title(self.window_title()) self._drain() dpg.render_dearpygui_frame() self.cancel.cancel() dpg.destroy_context() @staticmethod def window_title() -> str: suffix = "" if is_admin() else " (без прав администратора)" return f"SysDevCleaner — анализ и очистка системы{suffix}" def _build_themes(self) -> None: self.theme_dark = build_theme(DARK) self.theme_light = build_theme(LIGHT) self.th_card = build_card_theme(self.p) self.th_accent = build_accent_button_theme(self.p, "accent") self.th_danger = build_accent_button_theme(self.p, "danger") for key in ("accent", "ok", "warn", "danger"): self.th_bar[key] = build_bar_theme(self.p, key) def _apply_theme(self) -> None: dpg.bind_theme(self.theme_dark if self.dark else self.theme_light) def _on_resize(self, *_a) -> None: """Подгоняем высоту прокручиваемых областей, чтобы подвал всегда был виден. Вычитаемое для каждой вкладки — это высота того, что стоит выше и ниже прокручиваемой области: шапка, полоса вкладок, панель инструментов, панель подробностей, строка действий и сам подвал. """ try: h = dpg.get_viewport_client_height() w = dpg.get_viewport_client_width() except Exception: return detail_h = min(190, max(96, int(h * 0.15))) if dpg.does_item_exist("cl.detailbox"): dpg.configure_item("cl.detailbox", height=detail_h) heights = { "ov.left": h - 350, # шапка + полоса дисков + карточки + подвал "ov.right": h - 350, "cl.body": h - 347 - detail_h, # + инструменты, подробности, действия "pg.body": h - 315, # + сортировка и подсказка "set.body": h - 245, "jr.body": h - 245, } for tag, value in heights.items(): if dpg.does_item_exist(tag): dpg.configure_item(tag, height=max(150, int(value))) # кнопки шапки прижимаем к правому краю if dpg.does_item_exist("hdr.spacer"): left = _rect_w("hdr.left", 420) right = _rect_w("hdr.right", 430) dpg.configure_item("hdr.spacer", width=max(16, int(w - left - right - 36))) # карточки делят ширину поровну gap = 10 card_w = max(190, int((w - 28 - gap * 3) / 4)) for key in ("total", "safe", "dev", "free"): tag = f"ov.card.{key}" if dpg.does_item_exist(tag): dpg.configure_item(tag, width=card_w) # переносы строк — по фактической ширине панелей rec_wrap = max(320, int(w - _rect_w("ov.left", 650) - 90)) for item in self.rec_items: if dpg.does_item_exist(item): dpg.configure_item(item, wrap=rec_wrap) if dpg.does_item_exist("cl.detail"): dpg.configure_item("cl.detail", wrap=max(400, w - 70)) if dpg.does_item_exist("jr.errors"): dpg.configure_item("jr.errors", wrap=max(400, w - 70)) # ============================================================= разметка == def _build_ui(self) -> None: with dpg.window(tag=PRIMARY): self._header() dpg.add_separator() with dpg.tab_bar(tag="tabs"): self._tab_overview() self._tab_clean() self._tab_programs() self._tab_settings() self._tab_journal() self._footer() self._build_dialogs() def _header(self) -> None: with dpg.group(horizontal=True): with dpg.group(tag="hdr.left"): t = dpg.add_text(f"{I.BROOM} SysDevCleaner") self.fonts.apply(t, self.fonts.h1) admin = ("права администратора получены" if is_admin() else "запущено без прав администратора") s = dpg.add_text(f"Полный анализ кешей, мусора и остатков разработки " f"· {admin}", color=self.p["fg_dim"]) self.fonts.apply(s, self.fonts.small) dpg.add_spacer(width=40, tag="hdr.spacer") with dpg.group(horizontal=True, tag="hdr.right"): b = dpg.add_button(label=I.btn(I.SEARCH, "Сканировать"), tag="btn.scan", callback=self.start_scan, width=185) dpg.bind_item_theme(b, self.th_accent) dpg.add_button(label=I.btn(I.STOP, "Стоп"), tag="btn.stop", callback=self.stop, width=110, enabled=False) dpg.add_button(label=I.btn(I.MOON, "Тема"), tag="btn.theme", callback=self.toggle_theme, width=110) dpg.add_spacer(height=2) with dpg.group(horizontal=True, tag="hdr.drives"): dpg.add_text(f"{I.HDD} Сканировать диски:", color=self.p["fg_dim"]) for d in self.all_drives: u = disk_usage(d + "\\") label = f"{d}" + (f" ({human(u['total'])})" if u["total"] else "") dpg.add_checkbox(label=label, tag=f"drv.{d[0]}", default_value=True, callback=self._on_drive_toggle) dpg.add_spacer(width=8) dpg.add_button(label="все", width=70, callback=lambda: self._set_drives(None)) dpg.add_text("", tag="hdr.scope", color=self.p["fg_dim"]) self._update_scope() # ---------------------------------------------------------- выбор дисков - def selected_drives(self) -> set[str]: """Отмеченные диски. Пустое множество = ограничения нет (все диски).""" checked = {d for d in self.all_drives if dpg.get_value(f"drv.{d[0]}")} if not checked or len(checked) == len(self.all_drives): return set() return normalize_drives(checked) def _set_drives(self, only: str | None) -> None: """``only=None`` — отметить все диски, иначе оставить только указанный.""" for d in self.all_drives: dpg.set_value(f"drv.{d[0]}", only is None or d == only) self._update_scope() def _on_drive_toggle(self, *_a) -> None: if not any(dpg.get_value(f"drv.{d[0]}") for d in self.all_drives): # снять все нельзя — это значило бы «не сканировать ничего» self._set_drives(None) return self._update_scope() def _update_scope(self) -> None: sel = self.selected_drives() if not dpg.does_item_exist("hdr.scope"): return dpg.set_value("hdr.scope", "" if not sel else f" только {', '.join(sorted(sel))}") def scan_only(self, drive: str) -> None: """Быстрый сценарий с вкладки «Обзор»: сканировать один диск.""" self._set_drives(drive.rstrip("\\/").upper()) dpg.set_value("tabs", dpg.get_item_children("tabs", 1)[1]) self.start_scan() def _footer(self) -> None: dpg.add_separator() dpg.add_progress_bar(tag="st.pb", default_value=0.0, width=-1, height=8) with dpg.group(horizontal=True): dpg.add_text("Готов к сканированию", tag="st.msg", color=self.p["fg_dim"]) dpg.add_spacer(width=20) dpg.add_text("", tag="st.sel", color=self.p["accent_hi"]) # ------------------------------------------------------------- Обзор ---- def _tab_overview(self) -> None: with dpg.tab(label=I.btn(I.CHART, "Обзор")): with dpg.group(horizontal=True, tag="ov.cards"): for key, title in (("total", "Найдено мусора"), ("safe", "Безопасно удалить"), ("dev", "Кеши разработки"), ("free", "Свободно на системном диске")): with dpg.child_window(width=310, height=104, tag=f"ov.card.{key}", no_scrollbar=True): v = dpg.add_text("—", tag=f"ov.{key}") self.fonts.apply(v, self.fonts.big) c = dpg.add_text(title, tag=f"ov.cap.{key}", color=self.p["fg_dim"]) self.fonts.apply(c, self.fonts.small) dpg.bind_item_theme(f"ov.card.{key}", self.th_card) with dpg.group(horizontal=True): with dpg.child_window(tag="ov.left", width=650, height=500): h = dpg.add_text(f"{I.HDD} Диски") self.fonts.apply(h, self.fonts.h2) dpg.add_group(tag="ov.disks") dpg.add_spacer(height=12) h2 = dpg.add_text(f"{I.LIST} По категориям") self.fonts.apply(h2, self.fonts.h2) dpg.add_group(tag="ov.cats") with dpg.child_window(tag="ov.right", width=-1, height=500): with dpg.group(horizontal=True): h = dpg.add_text(f"{I.LIGHTBULB} Рекомендации") self.fonts.apply(h, self.fonts.h2) dpg.add_spacer(width=14) dpg.add_button(label=I.btn(I.SAVE, "Сохранить отчёт"), callback=lambda: dpg.show_item("dlg.report")) dpg.add_separator() dpg.add_group(tag="ov.recs") self.rec_items.append(dpg.add_text( "Нажмите «Сканировать», чтобы проанализировать систему.\n\n" "Что будет проверено:\n" " · временные файлы, дампы, отчёты об ошибках Windows;\n" " · кеши всех установленных браузеров;\n" " · кеши npm, pip, Gradle, Maven, Cargo, NuGet, Go и других;\n" " · node_modules, target, build, __pycache__ внутри проектов;\n" " · кеши IDE, игровых лаунчеров и мессенджеров;\n" " · установленные программы с реальным размером на диске.", parent="ov.recs", color=self.p["fg_dim"], wrap=640)) # ------------------------------------------------------------ Очистка --- def _tab_clean(self) -> None: with dpg.tab(label=I.btn(I.TRASH, "Очистка")): with dpg.group(horizontal=True): dpg.add_button(label=I.btn(I.SHIELD, "Безопасное"), tag="cl.b1", callback=lambda: self._bulk("safe"), width=190) dpg.add_button(label=I.btn(I.CHECK, "+ низкий риск"), tag="cl.b2", callback=lambda: self._bulk("low"), width=210) dpg.add_button(label=I.btn(I.CLOSE, "Снять всё"), tag="cl.b3", callback=lambda: self._bulk("none"), width=160) dpg.add_spacer(width=6) dpg.add_text(I.FILTER) dpg.add_input_text(tag="cl.filter", hint="название или путь", width=220, callback=lambda *_: self._render_findings()) dpg.add_checkbox(label="скрыть < 10 МБ", tag="cl.small", callback=lambda *_: self._render_findings()) dpg.add_child_window(tag="cl.body", height=420, width=-1) with dpg.child_window(tag="cl.detailbox", height=118, width=-1): dpg.add_text("Выберите строку, чтобы увидеть подробности.", tag="cl.detail", color=self.p["fg_dim"], wrap=1280) with dpg.group(horizontal=True): dpg.add_checkbox(label="удалять в Корзину (можно восстановить)", tag="cl.recycle", default_value=True) dpg.add_checkbox(label="пробный запуск — ничего не удалять", tag="cl.dry", default_value=False) dpg.add_spacer(width=20) b = dpg.add_button(label=I.btn(I.TRASH, "Очистить выбранное"), tag="btn.clean", callback=self.ask_clean, width=280, enabled=False) dpg.bind_item_theme(b, self.th_danger) # ----------------------------------------------------------- Программы -- def _tab_programs(self) -> None: with dpg.tab(label=I.btn(I.FOLDER, "Программы")): with dpg.group(horizontal=True): dpg.add_text("Сортировать:", color=self.p["fg_dim"]) dpg.add_combo(("по размеру", "по простою", "по имени", "по издателю"), default_value="по размеру", width=190, tag="pg.sort", callback=lambda *_: self._render_programs()) dpg.add_input_text(tag="pg.filter", hint="поиск по названию", width=250, callback=lambda *_: self._render_programs()) dpg.add_spacer(width=10) dpg.add_button(label=I.btn(I.TRASH, "Удалить программу"), callback=self.ask_uninstall, width=250) dpg.add_button(label=I.btn(I.GEAR, "Приложения Windows"), callback=self._open_appwiz, width=240) dpg.add_text("Двойной клик по строке открывает папку установки. " "«Простой» — сколько дней не менялись файлы программы.", color=self.p["fg_dim"]) dpg.add_child_window(tag="pg.body", height=520, width=-1) # ----------------------------------------------------------- Настройки -- def _tab_settings(self) -> None: with dpg.tab(label=I.btn(I.GEAR, "Настройки")): with dpg.child_window(tag="set.body", height=560, width=-1): with dpg.group(horizontal=True): with dpg.group(): h = dpg.add_text(f"{I.LIST} Что искать") self.fonts.apply(h, self.fonts.h2) for cat in ALL_CATEGORIES: dpg.add_checkbox(label=f"{I.cat(cat)}{cat.label}", tag=f"set.cat.{int(cat)}", default_value=True) dpg.add_spacer(height=14) h = dpg.add_text(f"{I.SEARCH} Дополнительный анализ") self.fonts.apply(h, self.fonts.h2) with dpg.group(horizontal=True): dpg.add_checkbox(label="крупные файлы, больше", tag="set.big") dpg.add_input_int(tag="set.bigmb", default_value=200, width=110, min_value=1, min_clamped=True, step=50) dpg.add_text("МБ", color=self.p["fg_dim"]) with dpg.group(horizontal=True): dpg.add_checkbox(label="дубликаты, больше", tag="set.dup") dpg.add_input_int(tag="set.dupmb", default_value=10, width=110, min_value=1, min_clamped=True, step=5) dpg.add_text("МБ (заметно дольше)", color=self.p["fg_dim"]) dpg.add_checkbox(label="собирать список установленных программ", tag="set.progs", default_value=True) dpg.add_checkbox(label="измерять реальный размер программ (дольше)", tag="set.progsize", default_value=True) dpg.add_spacer(width=40) with dpg.group(): h = dpg.add_text(f"{I.FOLDER} Где искать проекты и крупные файлы") self.fonts.apply(h, self.fonts.h2) dpg.add_text("По умолчанию — папки разработки в профиле " "и несистемные диски.", color=self.p["fg_dim"]) dpg.add_listbox(self.roots, tag="set.roots", width=620, num_items=12) with dpg.group(horizontal=True): dpg.add_button(label=I.btn(I.PLUS, "Добавить папку"), callback=lambda: dpg.show_item("dlg.root"), width=220) dpg.add_button(label=I.btn(I.MINUS, "Убрать"), callback=self._del_root, width=150) dpg.add_button(label=I.btn(I.UNDO, "По умолчанию"), callback=self._reset_roots, width=210) # ------------------------------------------------------------- Журнал --- def _tab_journal(self) -> None: with dpg.tab(label=I.btn(I.HISTORY, "Журнал")): with dpg.child_window(tag="jr.body", height=560, width=-1): h = dpg.add_text(f"{I.HISTORY} История очисток") self.fonts.apply(h, self.fonts.h2) dpg.add_text("Каждая очистка пишется в файл — видно, что и когда удалено.", color=self.p["fg_dim"]) dpg.add_group(tag="jr.list") dpg.add_spacer(height=16) h = dpg.add_text(f"{I.WARNING} Ошибки доступа при последнем сканировании") self.fonts.apply(h, self.fonts.h2) dpg.add_text("—", tag="jr.errors", color=self.p["fg_dim"], wrap=1280) # ------------------------------------------------------------ диалоги --- def _build_dialogs(self) -> None: with dpg.file_dialog(directory_selector=True, show=False, tag="dlg.root", callback=self._add_root, width=760, height=480, label="Папка для поиска проектов"): pass with dpg.file_dialog(directory_selector=False, show=False, tag="dlg.report", callback=self._save_report, width=760, height=480, default_filename=time.strftime("sysdevcleaner-%Y%m%d-%H%M"), label="Сохранить отчёт"): dpg.add_file_extension(".txt", color=(120, 200, 255, 255)) dpg.add_file_extension(".*") def _alert(self, title: str, message: str) -> None: tag = "modal.alert" if dpg.does_item_exist(tag): dpg.delete_item(tag) with dpg.window(label=title, modal=True, tag=tag, width=740, height=440, pos=(340, 200), no_resize=False): with dpg.child_window(height=-46, width=-1): dpg.add_text(message, wrap=680) dpg.add_button(label="Закрыть", width=150, callback=lambda: dpg.delete_item(tag)) def _confirm(self, title: str, message: str, on_yes, yes_label: str = "Да", danger: bool = False) -> None: tag = "modal.confirm" if dpg.does_item_exist(tag): dpg.delete_item(tag) def go(): dpg.delete_item(tag) on_yes() with dpg.window(label=title, modal=True, tag=tag, width=820, height=560, pos=(300, 150)): with dpg.child_window(height=-46, width=-1): dpg.add_text(message, wrap=760) with dpg.group(horizontal=True): b = dpg.add_button(label=yes_label, width=230, callback=go) dpg.bind_item_theme(b, self.th_danger if danger else self.th_accent) dpg.add_button(label="Отмена", width=160, callback=lambda: dpg.delete_item(tag)) # =========================================================== сканирование def start_scan(self) -> None: if self.busy: return cats = {c for c in ALL_CATEGORIES if dpg.get_value(f"set.cat.{int(c)}")} if not cats: self._alert("Нечего искать", "Выберите хотя бы одну категорию на вкладке «Настройки».") return opts = ScanOptions( categories=cats, project_roots=list(self.roots), scan_big_files=dpg.get_value("set.big"), big_file_min=max(1, dpg.get_value("set.bigmb")) * 1024 * 1024, scan_duplicates=dpg.get_value("set.dup"), duplicate_min=max(1, dpg.get_value("set.dupmb")) * 1024 * 1024, scan_programs=dpg.get_value("set.progs"), measure_program_size=dpg.get_value("set.progsize"), drives=self.selected_drives(), ) self.busy = True self.cancel.reset() dpg.configure_item("btn.scan", enabled=False) dpg.configure_item("btn.stop", enabled=True) dpg.configure_item("btn.clean", enabled=False) dpg.delete_item("cl.body", children_only=True) self.findings.clear() dpg.set_value("st.msg", "Сканирование…") dpg.set_value("st.pb", 0.0) def work() -> None: try: res = full_scan(opts, self.cancel, lambda m, f: self.q.put(("progress", (m, f)))) self.q.put(("scan_done", res)) except Exception as e: # noqa: BLE001 self.q.put(("error", f"Сбой сканирования: {e}")) threading.Thread(target=work, daemon=True).start() def stop(self) -> None: self.cancel.cancel() dpg.set_value("st.msg", "Останавливаю…") def _drain(self) -> None: """Забираем сообщения рабочих потоков. Вызывается каждый кадр.""" try: while True: kind, payload = self.q.get_nowait() if kind == "progress": msg, frac = payload dpg.set_value("st.msg", msg) dpg.set_value("st.pb", frac) elif kind == "clean_progress": msg, frac = payload dpg.set_value("st.msg", f"Очистка: {msg}") dpg.set_value("st.pb", frac) elif kind == "scan_done": self._scan_done(payload) elif kind == "clean_done": self._clean_done(payload) elif kind == "error": self._finish_busy() dpg.set_value("st.msg", "Ошибка") self._alert("Ошибка", str(payload)) except queue.Empty: pass def _finish_busy(self) -> None: self.busy = False dpg.configure_item("btn.scan", enabled=True) dpg.configure_item("btn.stop", enabled=False) dpg.configure_item("btn.clean", enabled=bool(self.findings)) def _scan_done(self, res: ScanResult) -> None: self.result = res self.findings = list(res.findings) self.programs = list(res.programs) default_selection(self.findings) self._finish_busy() dpg.set_value("st.pb", 1.0) self._render_findings() self._render_overview() self._render_programs() self._render_errors() self._refresh_disks() self._update_sel() tail = " · прервано" if self.cancel.cancelled else "" dpg.set_value("st.msg", f"Найдено {human(res.total())} в " f"{len(res.findings)} местах · {res.duration:.0f} с{tail}") # ========================================================= список находок def _render_findings(self) -> None: dpg.delete_item("cl.body", children_only=True) self.cat_index.clear() if not self.findings: dpg.add_text("Пока ничего не найдено — запустите сканирование.", parent="cl.body", color=self.p["fg_dim"]) return needle = (dpg.get_value("cl.filter") or "").strip().lower() floor = 10 * 1024 * 1024 if dpg.get_value("cl.small") else 0 buckets: dict[Category, list[int]] = {} for idx, f in enumerate(self.findings): if f.size < floor: continue if needle and needle not in f.title.lower() and needle not in f.path.lower(): continue buckets.setdefault(f.category, []).append(idx) if not buckets: dpg.add_text("Под фильтр ничего не подходит.", parent="cl.body", color=self.p["fg_dim"]) return order = sorted(buckets.items(), key=lambda kv: -sum(self.findings[i].size for i in kv[1])) for cat, idxs in order: idxs.sort(key=lambda i: -self.findings[i].size) self.cat_index[cat] = idxs total = sum(self.findings[i].size for i in idxs) label = f"{I.cat(cat)}{cat.label} — {human(total)} ({len(idxs)})" opened = cat in (Category.DEV_PROJECT, Category.DEV_CACHE, Category.WINDOWS) with dpg.collapsing_header(label=label, parent="cl.body", default_open=opened): dpg.add_checkbox(label="отметить всю категорию", tag=f"cat.chk.{int(cat)}", default_value=all(self.findings[i].selected for i in idxs), callback=self._on_cat_check, user_data=cat) with dpg.table(header_row=True, resizable=True, policy=dpg.mvTable_SizingStretchProp, borders_innerH=False, borders_outerH=False, borders_innerV=True, borders_outerV=False, row_background=True, scrollY=False): dpg.add_table_column(label="", width_fixed=True, init_width_or_weight=34) dpg.add_table_column(label="Объект", init_width_or_weight=0.34) dpg.add_table_column(label="Размер", width_fixed=True, init_width_or_weight=110) dpg.add_table_column(label="Риск", width_fixed=True, init_width_or_weight=140) dpg.add_table_column(label="Изменён", width_fixed=True, init_width_or_weight=100) dpg.add_table_column(label="Путь", init_width_or_weight=0.46) for i in idxs: f = self.findings[i] with dpg.table_row(): dpg.add_checkbox(tag=f"chk.{i}", default_value=f.selected, callback=self._on_check, user_data=i) dpg.add_selectable(label=_clip(f.title, 46), callback=self._on_row, user_data=i, tag=f"row.{i}") dpg.add_text(human(f.size)) dpg.add_text(f.risk.label, color=risk_color(f.risk, self.p)) dpg.add_text(_age(f)) dpg.add_text(_clip(f.path, 70)) with dpg.tooltip(f"row.{i}"): dpg.add_text(f.path, wrap=700) self._update_sel() def _on_check(self, sender, value, idx: int) -> None: self.findings[idx].selected = bool(value) self._sync_cat(self.findings[idx].category) self._update_sel() def _on_cat_check(self, sender, value, cat: Category) -> None: for i in self.cat_index.get(cat, []): self.findings[i].selected = bool(value) if dpg.does_item_exist(f"chk.{i}"): dpg.set_value(f"chk.{i}", bool(value)) self._update_sel() def _sync_cat(self, cat: Category) -> None: tag = f"cat.chk.{int(cat)}" idxs = self.cat_index.get(cat, []) if idxs and dpg.does_item_exist(tag): dpg.set_value(tag, all(self.findings[i].selected for i in idxs)) def _on_row(self, sender, value, idx: int) -> None: # add_selectable остаётся «нажатым» — снимаем, нам нужен только клик dpg.set_value(sender, False) f = self.findings[idx] mode = ("удаляется содержимое каталога" if f.delete_contents_only else "удаляется целиком") if f.members: mode = f"удаляются лишние копии ({len(f.members)} шт.), оригинал остаётся" busy = f.extra.get("busy") or [] warn = (f"\n\n{I.WARNING} Сейчас запущено: {', '.join(busy)} — " "часть файлов удалить не получится." if busy else "") dpg.set_value("cl.detail", f"{f.title}\n{f.path}\n\n" f"{human(f.size)} · {f.files} файлов · {f.risk.label} · {mode}\n\n" f"{f.detail}{warn}") def _bulk(self, mode: str) -> None: for i, f in enumerate(self.findings): if mode == "none": f.selected = False elif mode == "safe": f.selected = f.risk == Risk.SAFE and not f.extra.get("busy") elif mode == "low": f.selected = (f.risk <= Risk.LOW and f.category not in (Category.BIG_FILES, Category.DUPLICATES)) if dpg.does_item_exist(f"chk.{i}"): dpg.set_value(f"chk.{i}", f.selected) for cat in self.cat_index: self._sync_cat(cat) self._update_sel() def _update_sel(self) -> None: sel = [f for f in self.findings if f.selected] if not sel: dpg.set_value("st.sel", "") return risky = sum(1 for f in sel if f.risk >= Risk.MEDIUM) txt = f"{I.CHECK} выбрано {len(sel)} · {human(sum(f.size for f in sel))}" if risky: txt += f" · из них {risky} с повышенным риском" dpg.set_value("st.sel", txt) # ================================================================ обзор == def _refresh_disks(self) -> None: dpg.delete_item("ov.disks", children_only=True) disks = self.result.disks if self.result else [disk_usage(d) for d in drives()] for d in disks: if not d["total"]: continue pct = d["percent"] / 100.0 key = "danger" if pct > 0.9 else ("warn" if pct > 0.8 else "accent") letter = d["root"].rstrip("\\/").upper() with dpg.group(parent="ov.disks", horizontal=True): dpg.add_text(f"{I.HDD} {d['root']}", color=self.p["fg_dim"]) bar = dpg.add_progress_bar( default_value=pct, width=200, height=16, overlay=f"{d['percent']:.0f}%") dpg.bind_item_theme(bar, self.th_bar[key]) dpg.add_text(f"{human(d['free'])} своб.", color=self.p["fg_dim"]) dpg.add_button(label=I.btn(I.SEARCH, "только этот"), width=165, user_data=letter, callback=lambda s, a, u: self.scan_only(u)) if self.result is None and disks: dpg.set_value("ov.free", human(disks[0]["free"])) def _render_overview(self) -> None: res = self.result if res is None: return safe = sum(f.size for f in res.findings if f.risk == Risk.SAFE) dev = res.total({Category.DEV_CACHE, Category.DEV_PROJECT}) dpg.set_value("ov.total", human(res.total())) dpg.set_value("ov.safe", human(safe)) dpg.set_value("ov.dev", human(dev)) # если системный диск не сканировался — показываем первый из выбранных sysroot = next((d for d in res.disks if d["root"].upper().startswith("C")), None) shown = sysroot or (res.disks[0] if res.disks else None) dpg.set_value("ov.free", human(shown["free"]) if shown else "—") dpg.set_value("ov.cap.free", "Свободно на системном диске" if sysroot else (f"Свободно на {shown['root']}" if shown else "Свободно")) dpg.delete_item("ov.cats", children_only=True) cats = sorted(res.by_category().items(), key=lambda kv: -sum(f.size for f in kv[1])) top = max((sum(f.size for f in v) for _k, v in cats), default=1) or 1 for cat, items in cats: total = sum(f.size for f in items) with dpg.group(parent="ov.cats", horizontal=True): dpg.add_text(f"{I.cat(cat)}{cat.label[:20]:<20}", color=self.p["fg_dim"]) bar = dpg.add_progress_bar(default_value=total / top, width=180, height=14, overlay=human(total)) dpg.bind_item_theme(bar, self.th_bar["accent"]) dpg.add_text(f"({len(items)})", color=self.p["fg_dim"]) dpg.delete_item("ov.recs", children_only=True) self.rec_items.clear() if not res.recommendations: self.rec_items.append(dpg.add_text( "Система выглядит чистой — существенного мусора не найдено.", parent="ov.recs", color=self.p["fg_dim"], wrap=640)) for i, r in enumerate(res.recommendations, 1): icon = {0: I.INFO, 1: I.LIGHTBULB, 2: I.BOLT}[r.severity] color = {0: self.p["fg_dim"], 1: self.p["fg"], 2: self.p["warn"]}[r.severity] t = dpg.add_text(f"{icon} {i}. {r.title}", parent="ov.recs", color=color, wrap=640) self.fonts.apply(t, self.fonts.h2) self.rec_items.append(t) if r.gain: dpg.add_text(f" освободится {human(r.gain)}", parent="ov.recs", color=self.p["ok"]) self.rec_items.append(dpg.add_text( r.body, parent="ov.recs", color=self.p["fg_dim"], wrap=630)) dpg.add_separator(parent="ov.recs") self._on_resize() def _render_errors(self) -> None: if self.result is None: return errs = self.result.errors dpg.set_value("jr.errors", "\n".join(errs[:200]) if errs else "Ошибок не было.") # ============================================================= программы = def _render_programs(self) -> None: dpg.delete_item("pg.body", children_only=True) if not self.programs: dpg.add_text("Список программ не собран.", parent="pg.body", color=self.p["fg_dim"]) return needle = (dpg.get_value("pg.filter") or "").strip().lower() items = [p for p in self.programs if not needle or needle in p.name.lower() or needle in (p.publisher or "").lower()] mode = dpg.get_value("pg.sort") if mode == "по размеру": items.sort(key=lambda p: -p.best_size) elif mode == "по простою": items.sort(key=lambda p: -p.idle_days) elif mode == "по имени": items.sort(key=lambda p: p.name.lower()) else: items.sort(key=lambda p: ((p.publisher or "я").lower(), -p.best_size)) with dpg.table(parent="pg.body", header_row=True, resizable=True, policy=dpg.mvTable_SizingStretchProp, row_background=True, borders_innerV=True, scrollY=False): dpg.add_table_column(label="Программа", init_width_or_weight=0.34) dpg.add_table_column(label="Размер", width_fixed=True, init_width_or_weight=110) dpg.add_table_column(label="Простой", width_fixed=True, init_width_or_weight=110) dpg.add_table_column(label="Версия", width_fixed=True, init_width_or_weight=140) dpg.add_table_column(label="Издатель", init_width_or_weight=0.22) dpg.add_table_column(label="Расположение", init_width_or_weight=0.4) for n, p in enumerate(items): idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—" color = self.p["fg"] if p.best_size > 2 * 1024 ** 3: color = self.p["warn"] if p.idle_days > 365 and p.best_size > 500 * 1024 ** 2: color = self.p["danger"] with dpg.table_row(): dpg.add_selectable(label=_clip(p.name, 42), tag=f"prog.{n}", callback=self._on_prog, user_data=p) dpg.add_text(human(p.best_size), color=color) dpg.add_text(idle) dpg.add_text(_clip(p.version, 16)) dpg.add_text(_clip(p.publisher, 26)) dpg.add_text(_clip(p.install_location, 46)) self._selected_program = None def _on_prog(self, sender, value, prog: Program) -> None: dpg.set_value(sender, False) self._selected_program = prog dpg.set_value("st.msg", f"{prog.name} — {human(prog.best_size)} · " f"{prog.install_location or 'путь не указан в реестре'}") if prog.install_location: open_in_explorer(prog.install_location) def ask_uninstall(self) -> None: prog = getattr(self, "_selected_program", None) if prog is None: self._alert("Программа не выбрана", "Кликните по строке в списке, затем нажмите «Удалить программу».") return def go(): ok, err = progmod.uninstall(prog) if not ok: self._alert("Не получилось", f"Не удалось запустить удаление: {err}") self._confirm("Удаление программы", f"Запустить штатный деинсталлятор?\n\n{prog.name}\n" f"{human(prog.best_size)}\n{prog.install_location}\n\n" "Откроется окно самой программы — дальнейшие шаги за вами.", go, yes_label=I.btn(I.TRASH, "Запустить удаление"), danger=True) # =============================================================== очистка = def ask_clean(self) -> None: if self.busy: return sel = [f for f in self.findings if f.selected] if not sel: self._alert("Ничего не выбрано", "Сначала отметьте флажками то, что нужно удалить.") return dry = dpg.get_value("cl.dry") recycle = dpg.get_value("cl.recycle") mode = ("ПРОБНЫЙ ЗАПУСК — ничего не будет удалено." if dry else ("Файлы отправятся в Корзину — их можно вернуть." if recycle else "ВНИМАНИЕ: удаление БЕЗВОЗВРАТНОЕ, мимо Корзины.")) self._confirm("Подтверждение очистки", plan_summary(sel) + "\n\n" + mode, lambda: self._start_clean(sel, recycle, dry), yes_label=I.btn(I.TRASH, "Очистить"), danger=not dry) def _start_clean(self, sel: list[Finding], recycle: bool, dry: bool) -> None: opts = cleanmod.CleanOptions(to_recycle_bin=recycle, dry_run=dry) self.busy = True self.cancel.reset() dpg.configure_item("btn.clean", enabled=False) dpg.configure_item("btn.scan", enabled=False) dpg.configure_item("btn.stop", enabled=True) def work() -> None: try: res = cleanmod.clean(sel, opts, self.cancel, lambda m, f: self.q.put(("clean_progress", (m, f)))) self.q.put(("clean_done", res)) except Exception as e: # noqa: BLE001 self.q.put(("error", f"Сбой очистки: {e}")) threading.Thread(target=work, daemon=True).start() def _clean_done(self, res) -> None: self._finish_busy() dpg.set_value("st.pb", 1.0) dpg.set_value("st.msg", res.summary()) self._load_journals() self._refresh_disks() lines = [res.summary(), ""] fails = [i for i in res.items if not i.ok] if fails: lines.append(f"Не удалось полностью очистить {len(fails)}:") for i in fails[:14]: lines.append(f" · {i.finding.title} — " f"{i.message or 'файлы заняты другой программой'}") if len(fails) > 14: lines.append(f" … и ещё {len(fails) - 14}") lines.append("") lines.append("Обычная причина — файлы используются запущенной программой " "или нужны права администратора.") if res.journal_path: lines.append("") lines.append(f"Журнал: {res.journal_path}") # убираем из списка то, что удалилось целиком done = {id(i.finding) for i in res.items if i.ok and not i.finding.delete_contents_only} if done: self.findings = [f for f in self.findings if id(f) not in done] self._render_findings() self._update_sel() self._alert("Очистка завершена", "\n".join(lines)) # ============================================================== прочее === def _save_report(self, sender, app_data) -> None: if self.result is None: self._alert("Нет данных", "Сначала выполните сканирование.") return path = app_data.get("file_path_name") or "" if not path: return if not os.path.splitext(path)[1]: path += ".txt" try: with open(path, "w", encoding="utf-8") as fh: fh.write(report_text(self.result)) dpg.set_value("st.msg", f"Отчёт сохранён: {path}") open_in_explorer(path) except OSError as e: self._alert("Не сохранилось", f"Не удалось записать файл: {e}") def _add_root(self, sender, app_data) -> None: path = app_data.get("file_path_name") or app_data.get("current_path") or "" if not path: return path = os.path.normpath(path) if os.path.isdir(path) and path not in self.roots: self.roots.append(path) dpg.configure_item("set.roots", items=self.roots) def _del_root(self) -> None: cur = dpg.get_value("set.roots") if cur and cur in self.roots: self.roots.remove(cur) dpg.configure_item("set.roots", items=self.roots) def _reset_roots(self) -> None: self.roots = default_project_roots() dpg.configure_item("set.roots", items=self.roots) def _load_journals(self) -> None: dpg.delete_item("jr.list", children_only=True) rows = cleanmod.list_journals() if not rows: dpg.add_text("Очисток пока не было.", parent="jr.list", color=self.p["fg_dim"]) return with dpg.table(parent="jr.list", header_row=True, row_background=True, policy=dpg.mvTable_SizingStretchProp, borders_innerV=True): dpg.add_table_column(label="Дата", width_fixed=True, init_width_or_weight=170) dpg.add_table_column(label="Освобождено", width_fixed=True, init_width_or_weight=140) dpg.add_table_column(label="Файл журнала") for n, (path, when, freed) in enumerate(rows): with dpg.table_row(): dpg.add_selectable(label=when, callback=self._open_journal, user_data=path) dpg.add_text(human(freed), color=self.p["ok"]) dpg.add_text(path) def _open_journal(self, sender, value, path: str) -> None: dpg.set_value(sender, False) open_in_explorer(path) def _open_appwiz(self) -> None: try: if sys.platform == "win32": subprocess.Popen(["cmd", "/c", "start", "ms-settings:appsfeatures"]) except Exception: pass def toggle_theme(self) -> None: self.dark = not self.dark self.p = DARK if self.dark else LIGHT self._apply_theme() dpg.configure_item("btn.theme", label=I.btn(I.MOON if self.dark else I.SUN, "Тема")) # цвета текста заданы поштучно — перерисовываем то, что от них зависит self.th_card = build_card_theme(self.p) self.th_accent = build_accent_button_theme(self.p, "accent") self.th_danger = build_accent_button_theme(self.p, "danger") for key in ("accent", "ok", "warn", "danger"): self.th_bar[key] = build_bar_theme(self.p, key) for key in ("total", "safe", "dev", "free"): if dpg.does_item_exist(f"ov.card.{key}"): dpg.bind_item_theme(f"ov.card.{key}", self.th_card) dpg.bind_item_theme("btn.scan", self.th_accent) dpg.bind_item_theme("btn.clean", self.th_danger) self._refresh_disks() if self.result is not None: self._render_overview() self._render_findings() self._render_programs() self._load_journals() def _rect_w(tag: str, fallback: int) -> int: """Фактическая ширина элемента; до первой отрисовки вернёт запасное значение.""" try: size = dpg.get_item_rect_size(tag) return int(size[0]) if size and size[0] > 1 else fallback except Exception: return fallback def _clip(text: str, width: int) -> str: text = text or "" return text if len(text) <= width else "…" + text[-(width - 1):] def _age(f: Finding) -> str: if not f.mtime: return "—" d = int(f.age_days) if d < 1: return "сегодня" if d < 30: return f"{d} дн." if d < 365: return f"{d // 30} мес." return f"{d // 365} г." def main() -> None: App().run() if __name__ == "__main__": main()