- Добавлена сохраняемая настройка размера текста индексов LED с мгновенным предпросмотром и сбросом. - Исправлено размещение быстрых кнопок в строке меню без перекрытия пунктов на узком окне. - Усилена проверка OTA-загрузок: права записи, конечный URL, авторизация, Content-Length и отдельная группа updater-процесса. - Добавлен единый загрузчик подложек с определением формата по содержимому, EXIF-ориентацией и преобразованием CMYK. - Расширен список форматов подложки и добавлена безопасная обработка крупных изображений. - Ошибки декодирования теперь показываются пользователю без замены текущей подложки. - Обновлены smoke-тесты, документация, PyInstaller-конфигурация и версия приложения до 3.4.0.
307 lines
13 KiB
Python
307 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtGui import QKeySequence
|
||
from PySide6.QtWidgets import (
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QDoubleSpinBox,
|
||
QFileDialog,
|
||
QFormLayout,
|
||
QHBoxLayout,
|
||
QKeySequenceEdit,
|
||
QLabel,
|
||
QLineEdit,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSpinBox,
|
||
QStackedWidget,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
try:
|
||
from .localization import localize_dialog_buttons, show_warning
|
||
from .settings import (
|
||
DEFAULT_GRID_STEP_MM,
|
||
DEFAULT_LED_INDEX_FONT_SIZE_PX,
|
||
DEFAULT_LED_POINT_DIAMETER_PX,
|
||
DEFAULT_SHORTCUTS,
|
||
MAX_GRID_STEP_MM,
|
||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||
MAX_LED_POINT_DIAMETER_PX,
|
||
MIN_GRID_STEP_MM,
|
||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||
MIN_LED_POINT_DIAMETER_PX,
|
||
SETTINGS_VERSION,
|
||
SHORTCUT_DEFINITIONS,
|
||
AppSettings,
|
||
)
|
||
except ImportError:
|
||
from localization import localize_dialog_buttons, show_warning
|
||
from settings import (
|
||
DEFAULT_GRID_STEP_MM,
|
||
DEFAULT_LED_INDEX_FONT_SIZE_PX,
|
||
DEFAULT_LED_POINT_DIAMETER_PX,
|
||
DEFAULT_SHORTCUTS,
|
||
MAX_GRID_STEP_MM,
|
||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||
MAX_LED_POINT_DIAMETER_PX,
|
||
MIN_GRID_STEP_MM,
|
||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||
MIN_LED_POINT_DIAMETER_PX,
|
||
SETTINGS_VERSION,
|
||
SHORTCUT_DEFINITIONS,
|
||
AppSettings,
|
||
)
|
||
|
||
|
||
class SettingsDialog(QDialog):
|
||
def __init__(self, settings: AppSettings, parent=None) -> None: # noqa: ANN001
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Параметры")
|
||
self.setMinimumSize(520, 360)
|
||
self._settings = settings
|
||
|
||
self.theme = QComboBox()
|
||
self.theme.addItem("Системная", "system")
|
||
self.theme.addItem("Светлая", "light")
|
||
self.theme.addItem("Темная", "dark")
|
||
self.theme.setCurrentIndex(max(0, self.theme.findData(settings.theme)))
|
||
self.show_grid = QCheckBox("Показывать сетку при запуске")
|
||
self.show_grid.setChecked(settings.show_grid_by_default)
|
||
self.show_arrows = QCheckBox("Показывать стрелки направления при запуске")
|
||
self.show_arrows.setChecked(settings.show_direction_arrows_by_default)
|
||
self.show_led_ids = QCheckBox("Показывать id светодиодов при выборе")
|
||
self.show_led_ids.setChecked(settings.show_led_ids_by_default)
|
||
self.grid_step = QDoubleSpinBox()
|
||
self.grid_step.setRange(MIN_GRID_STEP_MM, MAX_GRID_STEP_MM)
|
||
self.grid_step.setDecimals(2)
|
||
self.grid_step.setSingleStep(1.0)
|
||
self.grid_step.setSuffix(" мм")
|
||
self.grid_step.setValue(settings.grid_step_mm)
|
||
self.grid_step_reset = QPushButton("Сбросить")
|
||
self.grid_step_reset.clicked.connect(
|
||
lambda: self.grid_step.setValue(DEFAULT_GRID_STEP_MM)
|
||
)
|
||
self.led_point_diameter = QSpinBox()
|
||
self.led_point_diameter.setRange(
|
||
MIN_LED_POINT_DIAMETER_PX,
|
||
MAX_LED_POINT_DIAMETER_PX,
|
||
)
|
||
self.led_point_diameter.setSuffix(" px")
|
||
self.led_point_diameter.setValue(settings.led_point_diameter_px)
|
||
self.led_point_diameter_reset = QPushButton("Сбросить")
|
||
self.led_point_diameter_reset.clicked.connect(
|
||
lambda: self.led_point_diameter.setValue(DEFAULT_LED_POINT_DIAMETER_PX)
|
||
)
|
||
self.led_index_font_size = QSpinBox()
|
||
self.led_index_font_size.setRange(
|
||
MIN_LED_INDEX_FONT_SIZE_PX,
|
||
MAX_LED_INDEX_FONT_SIZE_PX,
|
||
)
|
||
self.led_index_font_size.setSuffix(" px")
|
||
self.led_index_font_size.setValue(settings.led_index_font_size_px)
|
||
self.led_index_font_size_reset = QPushButton("Сбросить")
|
||
self.led_index_font_size_reset.clicked.connect(
|
||
lambda: self.led_index_font_size.setValue(DEFAULT_LED_INDEX_FONT_SIZE_PX)
|
||
)
|
||
self.zoom_pull = QSpinBox()
|
||
self.zoom_pull.setRange(0, 100)
|
||
self.zoom_pull.setSuffix(" %")
|
||
self.zoom_pull.setValue(settings.zoom_focus_pull)
|
||
self.pan_mode = QComboBox()
|
||
self.pan_mode.addItem("СКМ", "middle")
|
||
self.pan_mode.addItem("Пробел", "space")
|
||
self.pan_mode.addItem("СКМ + Пробел", "middle_space")
|
||
self.pan_mode.setCurrentIndex(max(0, self.pan_mode.findData(settings.pan_mode)))
|
||
self.instant_space_pan = QCheckBox("Сразу перетаскивать на пробел")
|
||
self.instant_space_pan.setChecked(settings.instant_space_pan)
|
||
|
||
self.brush_radius = QSpinBox()
|
||
self.brush_radius.setRange(4, 80)
|
||
self.brush_radius.setSuffix(" px")
|
||
self.brush_radius.setValue(settings.brush_radius)
|
||
|
||
self.output_dir = QLineEdit(settings.default_output_dir)
|
||
self.output_dir.setPlaceholderText("Выбирать папку при генерации")
|
||
self.confirm_partial = QCheckBox("Предупреждать, если назначены не все LED")
|
||
self.confirm_partial.setChecked(settings.confirm_partial_generation)
|
||
self.shortcut_edits: dict[str, QKeySequenceEdit] = {}
|
||
|
||
self.section_list = QListWidget()
|
||
self.section_list.setFixedWidth(170)
|
||
self.section_stack = QStackedWidget()
|
||
self._add_section("Внешний вид", self._build_appearance_tab())
|
||
self._add_section("Поведение", self._build_behavior_tab())
|
||
self._add_section("Инструменты", self._build_tools_tab())
|
||
self._add_section("Файлы и генерация", self._build_files_tab())
|
||
self._add_section("Комбинации клавиш", self._build_shortcuts_tab())
|
||
self.section_list.setCurrentRow(0)
|
||
self.section_list.currentRowChanged.connect(self.section_stack.setCurrentIndex)
|
||
|
||
content = QWidget()
|
||
content_layout = QHBoxLayout(content)
|
||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||
content_layout.addWidget(self.section_list)
|
||
content_layout.addWidget(self.section_stack, 1)
|
||
|
||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
localize_dialog_buttons(buttons)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(content)
|
||
layout.addWidget(buttons)
|
||
|
||
def _add_section(self, title: str, widget: QWidget) -> None:
|
||
item = QListWidgetItem(title)
|
||
self.section_list.addItem(item)
|
||
self.section_stack.addWidget(widget)
|
||
|
||
def settings(self) -> AppSettings:
|
||
return replace(
|
||
self._settings,
|
||
version=SETTINGS_VERSION,
|
||
theme=str(self.theme.currentData()),
|
||
show_grid_by_default=self.show_grid.isChecked(),
|
||
show_direction_arrows_by_default=self.show_arrows.isChecked(),
|
||
show_led_ids_by_default=self.show_led_ids.isChecked(),
|
||
grid_step_mm=self.grid_step.value(),
|
||
led_point_diameter_px=self.led_point_diameter.value(),
|
||
led_index_font_size_px=self.led_index_font_size.value(),
|
||
zoom_focus_pull=self.zoom_pull.value(),
|
||
pan_mode=str(self.pan_mode.currentData()),
|
||
instant_space_pan=self.instant_space_pan.isChecked(),
|
||
brush_radius=self.brush_radius.value(),
|
||
default_output_dir=self.output_dir.text().strip(),
|
||
confirm_partial_generation=self.confirm_partial.isChecked(),
|
||
shortcuts=self._shortcuts(),
|
||
)
|
||
|
||
def _build_appearance_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
layout = QFormLayout(page)
|
||
layout.addRow("Тема приложения", self.theme)
|
||
layout.addRow(self.show_grid)
|
||
layout.addRow(self.show_arrows)
|
||
layout.addRow(self.show_led_ids)
|
||
layout.addRow("Шаг сетки", self._setting_with_reset(self.grid_step, self.grid_step_reset))
|
||
layout.addRow(
|
||
"Диаметр LED-точек",
|
||
self._setting_with_reset(self.led_point_diameter, self.led_point_diameter_reset),
|
||
)
|
||
layout.addRow(
|
||
"Размер текста индексов LED",
|
||
self._setting_with_reset(self.led_index_font_size, self.led_index_font_size_reset),
|
||
)
|
||
layout.addRow("Притяжение зума к курсору", self.zoom_pull)
|
||
layout.addRow(QLabel("0% оставляет центр спокойнее, 100% сильнее тянет схему к курсору."))
|
||
return page
|
||
|
||
@staticmethod
|
||
def _setting_with_reset(editor: QWidget, reset: QPushButton) -> QWidget:
|
||
row = QWidget()
|
||
row_layout = QHBoxLayout(row)
|
||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||
row_layout.addWidget(editor, 1)
|
||
row_layout.addWidget(reset)
|
||
return row
|
||
|
||
def _build_behavior_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
layout = QFormLayout(page)
|
||
layout.addRow("Перемещение", self.pan_mode)
|
||
layout.addRow(self.instant_space_pan)
|
||
layout.addRow(QLabel("Пробел означает: удерживайте Пробел и перетаскивайте схему ЛКМ."))
|
||
return page
|
||
|
||
def _build_tools_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
layout = QFormLayout(page)
|
||
layout.addRow("Радиус кисти", self.brush_radius)
|
||
layout.addRow(QLabel("Обычный мазок создает новый блок. Shift + ЛКМ добавляет точки к выбранному блоку."))
|
||
return page
|
||
|
||
def _build_files_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
form = QFormLayout(page)
|
||
row = QWidget()
|
||
row_layout = QHBoxLayout(row)
|
||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||
browse = QPushButton("Выбрать")
|
||
browse.clicked.connect(self._browse_output_dir)
|
||
row_layout.addWidget(self.output_dir, 1)
|
||
row_layout.addWidget(browse)
|
||
form.addRow("Папка вывода", row)
|
||
form.addRow(self.confirm_partial)
|
||
return page
|
||
|
||
def _build_shortcuts_tab(self) -> QWidget:
|
||
page = QWidget()
|
||
layout = QVBoxLayout(page)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
scroll_content = QWidget()
|
||
form = QFormLayout()
|
||
form.setContentsMargins(0, 0, 0, 0)
|
||
for key, title, _default_shortcut in SHORTCUT_DEFINITIONS:
|
||
edit = QKeySequenceEdit(QKeySequence(self._settings.shortcut_for(key)))
|
||
edit.setClearButtonEnabled(True)
|
||
edit.setMinimumWidth(190)
|
||
self.shortcut_edits[key] = edit
|
||
form.addRow(title, edit)
|
||
scroll_content.setLayout(form)
|
||
scroll_area = QScrollArea()
|
||
scroll_area.setWidgetResizable(True)
|
||
scroll_area.setFrameShape(QScrollArea.NoFrame)
|
||
scroll_area.setWidget(scroll_content)
|
||
reset = QPushButton("Сбросить по умолчанию")
|
||
reset.clicked.connect(self._reset_shortcuts)
|
||
layout.addWidget(scroll_area, 1)
|
||
layout.addWidget(reset)
|
||
layout.addWidget(QLabel("Сочетания работают по физическим клавишам и не зависят от раскладки клавиатуры."))
|
||
return page
|
||
|
||
def accept(self) -> None:
|
||
duplicates: dict[str, list[str]] = {}
|
||
for key, sequence in self._shortcuts().items():
|
||
if not sequence:
|
||
continue
|
||
duplicates.setdefault(sequence, []).append(key)
|
||
conflict = next((keys for keys in duplicates.values() if len(keys) > 1), None)
|
||
if conflict:
|
||
names = {key: title for key, title, _shortcut in SHORTCUT_DEFINITIONS}
|
||
show_warning(
|
||
self,
|
||
"Конфликт сочетаний",
|
||
"Одно сочетание назначено нескольким действиям:\n"
|
||
+ "\n".join(names.get(key, key) for key in conflict),
|
||
)
|
||
return
|
||
super().accept()
|
||
|
||
def _shortcuts(self) -> dict[str, str]:
|
||
shortcuts: dict[str, str] = {}
|
||
for key, edit in self.shortcut_edits.items():
|
||
shortcuts[key] = edit.keySequence().toString(QKeySequence.SequenceFormat.PortableText)
|
||
return shortcuts
|
||
|
||
def _reset_shortcuts(self) -> None:
|
||
for key, edit in self.shortcut_edits.items():
|
||
edit.setKeySequence(QKeySequence(DEFAULT_SHORTCUTS.get(key, "")))
|
||
|
||
def _browse_output_dir(self) -> None:
|
||
start = self.output_dir.text().strip()
|
||
if not start or not Path(start).exists():
|
||
start = str(Path.home())
|
||
selected = QFileDialog.getExistingDirectory(self, "Папка вывода", start)
|
||
if selected:
|
||
self.output_dir.setText(selected)
|