Анализ Windows и очистка кешей, мусора и остатков разработки. Ядро: - каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов проектов — Windows, браузеры, кеши сборки, IDE, приложения, пакетные менеджеры; - рекурсивный поиск node_modules, target, build, __pycache__, .venv и т.п. с проверкой маркеров проекта, чтобы не ловить ложные срабатывания; - поиск крупных файлов и дубликатов (хеш по началу/середине/концу и полная сверка), инвентаризация установленных программ через реестр; - ограничение области сканирования по дискам; - рекомендации и текстовый отчёт. Безопасность: - четыре уровня риска, автоматически отмечается только «Безопасно»; - список защищённых путей проверяется и при выборе, и перед удалением; - для каталогов вроде %TEMP% удаляется содержимое, а не сам каталог; - удаление в Корзину по умолчанию, пробный запуск, журнал очисток. Интерфейс на Dear PyGui: - JetBrains Mono Nerd Font: кириллица и иконки в одном шрифте; - тёмная и светлая темы, адаптивная раскладка; - заголовок окна ставится через SetWindowTextW — Dear PyGui отдаёт его в ANSI-функцию как UTF-8 и портит кириллицу; - запуск всегда с правами администратора: манифест UAC в exe и самоперезапуск через ShellExecuteW при работе из исходников. Сборка в один exe через PyInstaller со вшитым шрифтом и значком. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1055 lines
52 KiB
Python
1055 lines
52 KiB
Python
"""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()
|