From 231954bc5d09da18807826be0f191093adb7a3f7 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Thu, 4 Jun 2026 22:06:40 -0400 Subject: [PATCH 01/17] add filename checkboxes adjust ui --- src/gui/importer/d4builds.py | 1 + src/gui/importer/gui_common.py | 48 +++++++++-- src/gui/importer/importer_config.py | 1 + src/gui/importer/maxroll.py | 1 + src/gui/importer/mobalytics.py | 1 + src/gui/importer_window.py | 124 +++++++++++++++++++--------- 6 files changed, 130 insertions(+), 46 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 910cd433..0961f4af 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -210,6 +210,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): season_number=season_number, build_header=build_header, variant_name=variant_name, + filename_components=config.filename_components, ) # Optionally embed Paragon data into the profile model before saving diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index 2835a09e..dfbe4915 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -135,21 +135,57 @@ def normalize_profile_file_name(file_name: str) -> str: def build_default_profile_file_name( - source_name: str, class_name: str = "", season_number: str = "", build_header: str = "", variant_name: str = "" + source_name: str, + class_name: str = "", + season_number: str = "", + build_header: str = "", + variant_name: str = "", + filename_components=None, ) -> str: + if filename_components is None: + # Default behavior (include all non-empty components) + filename_components = { + "include_source": True, + "include_season": True, + "include_class": True, + "include_header": True, + "include_subbuild": True, + } + normalized_source_name = _normalize_profile_name_part(source_name) or "imported" clean_title = _clean_build_header(normalized_source_name, build_header, season_number) normalized_class_name = _normalize_profile_name_part(class_name) or "unknown" normalized_variant_name = _normalize_profile_name_part(variant_name) + + # Normalize season number season_match = re.search(r"\d+", str(season_number)) - normalized_season_name = f"s{season_match.group(0)}" if season_match else "" - file_name_parts = [normalized_source_name, normalized_class_name] - if normalized_season_name: + normalized_season_name = ( + f"s{season_match.group(0)}" if season_match and filename_components["include_season"] else "" + ) + + file_name_parts = [] + + # Include components based on user preferences + if filename_components["include_source"]: + file_name_parts.append(normalized_source_name) + + if filename_components["include_class"] and normalized_class_name != "unknown": + file_name_parts.append(normalized_class_name) + + if season_match and filename_components["include_season"]: file_name_parts.append(normalized_season_name) - if clean_title: + + # Include build header only if clean_title is non-empty and user wants it included + if clean_title and filename_components["include_header"]: file_name_parts.append(clean_title) - if normalized_variant_name: + + if normalized_variant_name and filename_components["include_subbuild"]: file_name_parts.append(normalized_variant_name) + + # Default fallback: include at least the source + if not file_name_parts: + return normalize_profile_file_name(normalized_source_name + "_imported") + return normalize_profile_file_name("_".join(file_name_parts)) diff --git a/src/gui/importer/importer_config.py b/src/gui/importer/importer_config.py index 26411a63..f7fb2385 100644 --- a/src/gui/importer/importer_config.py +++ b/src/gui/importer/importer_config.py @@ -10,3 +10,4 @@ class ImportConfig: require_greater_affixes: bool export_paragon: bool = False custom_file_name: str | None = None + filename_components: dict = None diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 8fd5fe21..0f7c936c 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -184,6 +184,7 @@ def import_maxroll(config: ImportConfig): season_number=guide_season, build_header=build_header, variant_name=variant_name, + filename_components=config.filename_components, ) # Optionally embed Paragon data into the profile model before saving diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 6736ef26..037ffeba 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -243,6 +243,7 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): season_number=season_number, build_header=build_header, variant_name=variant_name, + filename_components=config.filename_components, ) # Optionally embed Paragon data into the profile model before saving if config.export_paragon: diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index d95214c2..391f5d0e 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -5,17 +5,7 @@ from PyQt6.QtCore import QObject, QPoint, QRunnable, QSettings, QSize, Qt, QThreadPool, pyqtSignal, pyqtSlot from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import ( - QGridLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QMainWindow, - QPushButton, - QTextEdit, - QVBoxLayout, - QWidget, -) +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QTextEdit, QVBoxLayout, QWidget from src.config.loader import IniConfigLoader from src.gui.importer.d4builds import import_d4builds @@ -72,18 +62,65 @@ def __init__(self, parent=None): url_hbox.addWidget(self.input_box) layout.addLayout(url_hbox) - # Filename input - filename_hbox = QHBoxLayout() - filename_label = QLabel("Custom file name:") - filename_hbox.addWidget(filename_label) + # Filename input with inline filename options row self.filename_input_box = QLineEdit() self.filename_input_box.setPlaceholderText("Leave blank for default filename") - filename_hbox.addWidget(self.filename_input_box) - layout.addLayout(filename_hbox) - # Checkboxes + self.filename_label = QLabel("Custom file name:") + self.filename_label_layout = QHBoxLayout() + self.filename_label_layout.addWidget(self.filename_label) + self.filename_label_layout.addWidget(self.filename_input_box) + + # Filename Options label and checkboxes in a single row + self.filename_options_label = QLabel("Filename Options:") + self.filename_options_label.setFixedWidth(120) + + self.include_source_checkbox = CheckmarkCheckBox("Source") + self.include_source_checkbox.setToolTip("Include the build source (e.g., maxroll, d4builds, mobalytics)") + self.include_source_checkbox.setChecked(True) + + self.include_season_checkbox = CheckmarkCheckBox("Season") + self.include_season_checkbox.setToolTip("Include the season number (e.g., s5)") + self.include_season_checkbox.setChecked(True) + + self.include_class_checkbox = CheckmarkCheckBox("Class") + self.include_class_checkbox.setToolTip("Include the character class (e.g., Barbarian, Druid)") + self.include_class_checkbox.setChecked(True) + + self.include_header_checkbox = CheckmarkCheckBox("Build Name") + self.include_header_checkbox.setToolTip("Include the main build name/guide title") + self.include_header_checkbox.setChecked(True) + + self.include_subbuild_checkbox = CheckmarkCheckBox("Sub Build") + self.include_subbuild_checkbox.setToolTip("Include the sub-build/variant name") + self.include_subbuild_checkbox.setChecked(True) + + self.filename_options_hbox = QHBoxLayout() + self.filename_options_hbox.addWidget(self.filename_options_label) + self.filename_options_hbox.addWidget(self.include_source_checkbox) + self.filename_options_hbox.addWidget(self.include_season_checkbox) + self.filename_options_hbox.addWidget(self.include_class_checkbox) + self.filename_options_hbox.addWidget(self.include_header_checkbox) + self.filename_options_hbox.addWidget(self.include_subbuild_checkbox) + self.filename_options_hbox.addStretch() + + layout.addLayout(self.filename_label_layout) + layout.addLayout(self.filename_options_hbox) + + # Generate button + button_hbox = QHBoxLayout() + self.generate_button = QPushButton("Generate") + self.generate_button.setEnabled(False) + self.generate_button.clicked.connect(self._generate_button_click) + button_hbox.addWidget(self.generate_button) + layout.addLayout(button_hbox) + + # Import Options label and checkboxes in a single row + self.import_options_label = QLabel("Import Options:") + self.import_options_label.setFixedWidth(120) + self.import_aspect_upgrades_checkbox = self._generate_checkbox( - "Import Aspect Upgrades", + "Aspect Upgrades", "import_aspect_upgrades", "If legendary aspects are in the build, do you want an aspect upgrades section generated for them?", ) @@ -105,7 +142,7 @@ def __init__(self, parent=None): ) self.export_paragon_checkbox = self._generate_checkbox( - "Import Paragon", + "Paragon", "export_paragon", "Import Paragon boards into your profile for the integrated Paragon overlay.", "false", @@ -130,27 +167,16 @@ def disable_require_if_import_disabled(): # Connect toggle logic self.import_gas_checkbox.stateChanged.connect(lambda: disable_require_if_import_disabled()) - # Use a grid layout to ensure checkboxes align vertically in columns - checkbox_grid = QGridLayout() - checkbox_grid.setContentsMargins(0, 10, 0, 10) - checkbox_grid.setSpacing(10) - - checkbox_grid.addWidget(self.import_aspect_upgrades_checkbox, 0, 0) - checkbox_grid.addWidget(self.import_gas_checkbox, 0, 1) - checkbox_grid.addWidget(self.require_all_gas_checkbox, 0, 2) - - checkbox_grid.addWidget(self.export_paragon_checkbox, 1, 0) - checkbox_grid.addWidget(self.add_to_profiles_checkbox, 1, 1) - - layout.addLayout(checkbox_grid) + self.import_options_hbox = QHBoxLayout() + self.import_options_hbox.addWidget(self.import_options_label) + self.import_options_hbox.addWidget(self.import_aspect_upgrades_checkbox) + self.import_options_hbox.addWidget(self.add_to_profiles_checkbox) + self.import_options_hbox.addWidget(self.import_gas_checkbox) + self.import_options_hbox.addWidget(self.require_all_gas_checkbox) + self.import_options_hbox.addWidget(self.export_paragon_checkbox) + self.import_options_hbox.addStretch() - # Generate button - button_hbox = QHBoxLayout() - self.generate_button = QPushButton("Generate") - self.generate_button.setEnabled(False) - self.generate_button.clicked.connect(self._generate_button_click) - button_hbox.addWidget(self.generate_button) - layout.addLayout(button_hbox) + layout.addLayout(self.import_options_hbox) # Log output log_label = QLabel("Log:") @@ -210,6 +236,23 @@ def save_setting_change(settings_value, value): def _handle_text_changed(self, text): """Enable/disable generate button based on input.""" self.generate_button.setEnabled(bool(text.strip())) + # Show/hide filename options based on whether a custom filename is entered + self.filename_options_label.setVisible(not bool(text.strip())) + self.include_source_checkbox.setVisible(not bool(text.strip())) + self.include_season_checkbox.setVisible(not bool(text.strip())) + self.include_class_checkbox.setVisible(not bool(text.strip())) + self.include_header_checkbox.setVisible(not bool(text.strip())) + self.include_subbuild_checkbox.setVisible(not bool(text.strip())) + + def _get_filename_components(self) -> dict: + """Build and return the filename_components dict from checkbox states.""" + return { + "include_source": self.include_source_checkbox.isChecked(), + "include_season": self.include_season_checkbox.isChecked(), + "include_class": self.include_class_checkbox.isChecked(), + "include_header": self.include_header_checkbox.isChecked(), + "include_subbuild": self.include_subbuild_checkbox.isChecked(), + } def _generate_button_click(self): self.log_output.clear() @@ -228,6 +271,7 @@ def _generate_button_click(self): self.require_all_gas_checkbox.isChecked(), self.export_paragon_checkbox.isChecked(), custom_filename, + self._get_filename_components() if not custom_filename else None, ) if "maxroll" in url: From 286fb76d21846c163e2eef4c17fab478981a060b Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 18:28:59 -0400 Subject: [PATCH 02/17] Profile Editor Improvements --- src/config/profile_models.py | 39 +- src/gui/importer/d4builds.py | 11 +- src/gui/importer/gui_common.py | 13 +- src/gui/importer/maxroll.py | 16 +- src/gui/importer/mobalytics.py | 32 +- src/gui/importer_window.py | 7 - src/gui/models/activity_log_widget.py | 158 +- src/gui/models/dialog.py | 56 +- src/gui/profile_editor/affixes_tab.py | 1464 ++++++++++++----- src/gui/profile_editor/aspect_upgrades_tab.py | 18 +- src/gui/profile_editor/global_uniques_tab.py | 460 +++++- src/gui/profile_editor/paper_doll.py | 440 +++++ src/gui/profile_editor/profile_editor.py | 296 +++- src/gui/profile_editor/sigils_tab.py | 5 +- src/gui/profile_editor/tributes_tab.py | 2 +- src/gui/profile_editor_window.py | 21 +- src/gui/profile_tab.py | 47 +- src/gui/themes.py | 80 + 18 files changed, 2503 insertions(+), 662 deletions(-) create mode 100644 src/gui/profile_editor/paper_doll.py diff --git a/src/config/profile_models.py b/src/config/profile_models.py index f75b3f31..e44936f5 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -48,6 +48,7 @@ def parse_data(cls, data: str | list[str] | list[str | float] | dict[str, str | class AffixFilterModel(AffixAspectFilterModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) want_greater: bool = False + required: bool = False min_percent_of_affix: int = Field(default=0, alias="minPercentOfAffix") @field_validator("name") @@ -97,9 +98,6 @@ def model_validator(self) -> AffixFilterCountModel: if self.min_count > self.max_count: msg = "minCount must be smaller than maxCount" raise ValueError(msg) - if not self.count: - msg = "count must not be empty" - raise ValueError(msg) return self @@ -140,6 +138,10 @@ class GlobalUniqueModel(BaseModel): min_greater_affix_count: int = Field(default=0, alias="minGreaterAffixCount") min_percent_of_aspect: int = Field(default=0, alias="minPercentOfAspect") min_power: int = Field(default=0, alias="minPower") + item_type: list[ItemType] = Field(default=[], alias="itemType") + affix_pool: list[AffixFilterCountModel] = Field(default=[], alias="affixPool") + inherent_pool: list[AffixFilterCountModel] = Field(default=[], alias="inherentPool") + unique_aspect: list[AspectUniqueFilterModel] = Field(default=[], alias="uniqueAspect") @field_validator("min_power") @classmethod @@ -159,6 +161,31 @@ def count_validator(cls, v: int) -> int: def percent_validator(cls, v: int) -> int: return validate_percent(v) + @field_validator("item_type", mode="before") + @classmethod + def parse_item_type(cls, data: str | list[str]) -> list[str]: + return _parse_item_type_or_rarities(data) + + @field_validator("unique_aspect", mode="before") + @classmethod + def parse_unique_aspect(cls, data: dict | list[dict] | None) -> list[dict]: + if not data: + return [] + if isinstance(data, dict): + return [data] + return data + + @model_validator(mode="after") + def unique_aspect_names_must_be_unique(self) -> GlobalUniqueModel: + if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect): + msg = "uniqueAspect names must be unique" + raise ValueError(msg) + if not self.affix_pool: + self.affix_pool = [AffixFilterCountModel(count=[], min_count=1)] + if not self.inherent_pool: + self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] + return self + class ItemFilterModel(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) @@ -201,6 +228,10 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: if len({aspect.name for aspect in self.unique_aspect}) != len(self.unique_aspect): msg = "uniqueAspect names must be unique" raise ValueError(msg) + if not self.affix_pool: + self.affix_pool = [AffixFilterCountModel(count=[], min_count=3)] + if not self.inherent_pool: + self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self @@ -321,11 +352,13 @@ class ProfileModel(BaseModel): aspect_upgrades: list[str] = Field(default=[], alias="AspectUpgrades") global_uniques: list[GlobalUniqueModel] = Field(default=[], alias="GlobalUniques") name: str + class_name: str = Field(default="unknown", alias="ClassName") sigils: SigilFilterModel = Field( default=SigilFilterModel(blacklist=[], whitelist=[], priority=SigilPriority.blacklist), alias="Sigils" ) tributes: list[TributeFilterModel] = Field(default=[], alias="Tributes") paragon: dict[str, object] | list[dict[str, object]] | None = Field(default=None, alias="Paragon") + source_url: str = Field(default="", alias="SourceUrl") @model_validator(mode="before") def aspects_must_exist(self) -> ProfileModel: diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index 0961f4af..68df398c 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -18,7 +18,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, fix_offhand_type, @@ -95,7 +94,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): raise D4BuildsError(msg) slot_to_unique_name_map = _get_item_slots(data=data) finished_filters = [] - mythic_names = [] aspect_upgrade_filters = _get_legendary_aspects(data=data) for item in items[0]: item_filter = ItemFilterModel() @@ -115,9 +113,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if slot_to_unique_name_map[slot]: unique_name, rarity = slot_to_unique_name_map[slot] - if rarity == ItemRarity.Mythic: - mythic_names.append(unique_name) - continue try: item_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_name)] except Exception: @@ -198,9 +193,9 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): filter_name = f"{filter_name_template}{i}" i += 1 finished_filters.append({filter_name: item_filter}) - # Place all mythics in a single filter - add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", affixes=sort_profile_filters(finished_filters), class_name=class_name, source_url=url + ) if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters diff --git a/src/gui/importer/gui_common.py b/src/gui/importer/gui_common.py index dfbe4915..5a61d56d 100644 --- a/src/gui/importer/gui_common.py +++ b/src/gui/importer/gui_common.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import functools import logging @@ -18,7 +20,6 @@ from src import __version__ from src.config.loader import IniConfigLoader -from src.config.profile_models import AspectUniqueFilterModel, ItemFilterModel, ProfileModel from src.config.settings_models import BrowserType from src.item.data.item_type import ItemType @@ -27,6 +28,8 @@ from selenium.webdriver.chromium.webdriver import ChromiumDriver + from src.config.profile_models import ItemFilterModel, ProfileModel + LOGGER = logging.getLogger(__name__) D = TypeVar("D", bound=WebDriver | WebElement) @@ -224,14 +227,6 @@ def update_mingreateraffixcount(item_filter: ItemFilterModel, require_gas: bool) item_filter.min_greater_affix_count = 0 -def add_mythics_to_filters(mythic_names, finished_filters): - if mythic_names: - mythic_filter = ItemFilterModel() - for mythic_name in mythic_names: - mythic_filter.unique_aspect.append(AspectUniqueFilterModel(name=mythic_name)) - finished_filters.append({"Mythics": mythic_filter}) - - def sort_profile_filters(filters: list[dict[str, ItemFilterModel]]) -> list[dict[str, ItemFilterModel]]: return sorted(filters, key=_profile_filter_sort_key) diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 0f7c936c..1522b7d3 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -14,7 +14,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, fix_offhand_type, @@ -91,7 +90,6 @@ def import_maxroll(config: ImportConfig): build_name += f"_{variant_name}" finished_filters = [] aspect_upgrade_filters = [] - mythic_names = [] for item_id in active_profile["items"].values(): resolved_item = items[str(item_id)] resolved_item_id = resolved_item["id"] @@ -135,10 +133,6 @@ def import_maxroll(config: ImportConfig): unique_name = mapping_data["items"][resolved_item_id]["name"] try: unique_name = _unique_name_special_handling(unique_name) - # We handle mythics at the end - if rarity == ItemRarity.Mythic: - mythic_names.append(unique_name) - continue item_filter.unique_aspect = [AspectUniqueFilterModel(name=unique_name)] except Exception: LOGGER.exception(f"Unexpected error adding unique aspect for {unique_name}, please report a bug.") @@ -170,9 +164,13 @@ def import_maxroll(config: ImportConfig): finished_filters.append({filter_name: item_filter}) - # Place all mythics in a single filter - add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", + affixes=sort_profile_filters(finished_filters), + class_name=all_data["class"], + source_url=url, + ) + if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 037ffeba..26511165 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -21,7 +21,6 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import ( - add_mythics_to_filters, add_to_profiles, build_default_profile_file_name, fix_offhand_type, @@ -128,13 +127,10 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): LOGGER.error(msg := "No items found") raise MobalyticsError(msg) finished_filters = [] - mythic_names = [] aspect_upgrade_filters = [] for item in items: item_filter = ItemFilterModel() entity_type = jsonpath.findall(".gameEntity.type", item)[0] - mythic_result = jsonpath.findall(".gameEntity.entity.mythic", item) - is_mythic = mythic_result[0] if mythic_result else False if entity_type not in ["aspects", "uniqueItems"]: continue if not (item_name := str(jsonpath.findall(".gameEntity.entity.title", item)[0])): @@ -152,10 +148,6 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): is_unique = entity_type == "uniqueItems" if is_unique: try: - # We handle mythics at the end - if is_mythic: - mythic_names.append(item_name) - continue item_filter.unique_aspect = [AspectUniqueFilterModel(name=item_name)] except Exception: LOGGER.exception(f"Unexpected error adding unique aspect for {item_name}, please report a bug.") @@ -210,16 +202,15 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): affixes = _convert_raw_to_affixes(raw_affixes, config.import_greater_affixes) inherents = _convert_raw_to_affixes(raw_inherents) - if not is_mythic: - item_filter.affix_pool = [ - AffixFilterCountModel( - count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes], - min_count=1 if is_unique else 3, - ) - ] - update_mingreateraffixcount(item_filter, config.require_greater_affixes) + item_filter.affix_pool = [ + AffixFilterCountModel( + count=[AffixFilterModel(name=x.name, want_greater=x.type == AffixType.greater) for x in affixes], + min_count=1 if is_unique else 3, + ) + ] + update_mingreateraffixcount(item_filter, config.require_greater_affixes) item_filter.min_power = 100 - if inherents and not is_mythic: + if inherents: item_filter.inherent_pool = [ AffixFilterCountModel(count=[AffixFilterModel(name=x.name) for x in inherents]) ] @@ -231,9 +222,10 @@ def import_mobalytics(config: ImportConfig, driver: ChromiumDriver = None): i += 1 finished_filters.append({filter_name: item_filter}) - # Place all mythics in a single filter - add_mythics_to_filters(mythic_names, finished_filters) - profile = ProfileModel(name="imported profile", Affixes=sort_profile_filters(finished_filters)) + profile = ProfileModel( + name="imported profile", affixes=sort_profile_filters(finished_filters), class_name=class_name, source_url=url + ) + if config.import_aspect_upgrades and aspect_upgrade_filters: profile.aspect_upgrades = aspect_upgrade_filters diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index 391f5d0e..b01bc13b 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -236,13 +236,6 @@ def save_setting_change(settings_value, value): def _handle_text_changed(self, text): """Enable/disable generate button based on input.""" self.generate_button.setEnabled(bool(text.strip())) - # Show/hide filename options based on whether a custom filename is entered - self.filename_options_label.setVisible(not bool(text.strip())) - self.include_source_checkbox.setVisible(not bool(text.strip())) - self.include_season_checkbox.setVisible(not bool(text.strip())) - self.include_class_checkbox.setVisible(not bool(text.strip())) - self.include_header_checkbox.setVisible(not bool(text.strip())) - self.include_subbuild_checkbox.setVisible(not bool(text.strip())) def _get_filename_components(self) -> dict: """Build and return the filename_components dict from checkbox states.""" diff --git a/src/gui/models/activity_log_widget.py b/src/gui/models/activity_log_widget.py index c745cc50..551c3606 100644 --- a/src/gui/models/activity_log_widget.py +++ b/src/gui/models/activity_log_widget.py @@ -1,13 +1,15 @@ from __future__ import annotations import datetime +import functools import logging from typing import TYPE_CHECKING import yaml -from PyQt6.QtCore import QMimeData, Qt +from PyQt6.QtCore import QMimeData, QSettings, Qt from PyQt6.QtGui import QDrag from PyQt6.QtWidgets import ( + QDialog, QFrame, QGraphicsOpacityEffect, QGridLayout, @@ -24,9 +26,17 @@ ) from src.config.loader import IniConfigLoader -from src.config.profile_models import ProfileModel +from src.config.profile_models import DynamicItemFilterModel, ItemFilterModel, ProfileModel from src.config.settings_models import IS_HOTKEY_KEY +from src.gui.importer.d4builds import import_d4builds +from src.gui.importer.gui_common import add_to_profiles, save_as_profile +from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.maxroll import import_maxroll +from src.gui.importer.mobalytics import import_mobalytics +from src.gui.importer_window import THREADPOOL, _Worker from src.gui.models.checkmark_checkbox import CheckmarkCheckBox +from src.gui.models.dialog import CreateProfileDialog +from src.gui.profile_editor.paper_doll import BASE_GEAR_SLOTS, get_weapon_slots from src.item.filter import _UniqueKeyLoader if TYPE_CHECKING: @@ -155,12 +165,14 @@ def __init__(self, parent=None): action_layout = QHBoxLayout() self.import_btn = QPushButton("Import Profile") self.import_btn.setObjectName("primary") + + self.create_profile_btn = QPushButton("Create Profile") self.settings_btn = QPushButton("Settings") self.minimize_to_tray_cb = CheckmarkCheckBox("Minimize to Tray") self.minimize_to_tray_cb.setObjectName("switch") - for btn in [self.import_btn, self.settings_btn]: + for btn in [self.import_btn, self.create_profile_btn, self.settings_btn]: btn.setFixedHeight(34) btn.setFixedWidth(130) action_layout.addWidget(btn) @@ -264,6 +276,11 @@ def refresh_profiles(self): edit_btn.clicked.connect(lambda _, n=name: self._edit_profile(n)) header_hbox.addWidget(edit_btn) + refresh_btn = self._create_row_btn("Refresh") + refresh_btn.setToolTip("Refresh build from source URL") + refresh_btn.clicked.connect(lambda _, n=name: self._refresh_profile(n)) + header_hbox.addWidget(refresh_btn) + delete_btn = self._create_row_btn("Delete") delete_btn.setObjectName("delete-profile-btn") delete_btn.setToolTip("Delete Profile") @@ -337,6 +354,106 @@ def _delete_profile(self, name: str): except Exception: LOGGER.exception(f"Failed to delete profile {name}") + def _refresh_profile(self, name: str): + """Re-import the build from its source URL while merging manual rules.""" + profiles_dir = self._config.user_dir / "profiles" + p_path = None + for ext in [".yaml", ".yml"]: + test_path = profiles_dir / f"{name}{ext}" + if test_path.exists(): + p_path = test_path + break + + if not p_path: + return + + try: + with p_path.open(encoding="utf-8") as f: + config_data = yaml.load(stream=f, Loader=_UniqueKeyLoader) + old_model = ProfileModel(name=name, **config_data) + except Exception: + LOGGER.exception(f"Failed to load profile {name} for refresh") + return + + url = old_model.source_url + if not url: + # Fallback for older profiles: try to extract from the top-level comment + with p_path.open(encoding="utf-8") as f: + first_line = f.readline() + if first_line.startswith("# http"): + url = first_line[2:].strip() + + if not url: + QMessageBox.warning(self, "Refresh Profile", "No source URL found in this profile. It cannot be refreshed.") + return + + msg = ( + f"Are you sure you want to refresh '{name}' from its source URL?\n\n" + f"URL: {url}\n\n" + "Warning: This will update all items and aspects from the planner. " + "Your manual Sigil, Tribute, and Global Unique rules will be preserved." + ) + if ( + QMessageBox.question( + self, "Refresh Profile", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + != QMessageBox.StandardButton.Yes + ): + return + + # Imports needed for background worker + + import_settings = QSettings("d4lf", "ImporterWindow") + importer_config = ImportConfig( + url=url, + import_aspect_upgrades=import_settings.value("import_aspect_upgrades", "true") == "true", + add_to_profiles=False, + import_greater_affixes=import_settings.value("import_gas", "true") == "true", + require_greater_affixes=import_settings.value("require_all_gas", "false") == "true", + export_paragon=import_settings.value("export_paragon", "false") == "true", + custom_file_name=name, + filename_components=None, + ) + + fn = import_mobalytics + if "maxroll" in url: + fn = import_maxroll + elif "d4builds" in url: + fn = import_d4builds + + worker = _Worker(name=f"refresh-{name}", fn=fn, config=importer_config) + finish_callback = functools.partial(self._on_refresh_finished, name, old_model) + worker.signals.finished.connect(finish_callback) + + THREADPOOL.start(worker) + QMessageBox.information(self, "Refresh Profile", f"Refreshing '{name}' in the background...") + + def _on_refresh_finished(self, name: str, old_model: ProfileModel): + """Merge user-protected sections back into the refreshed profile.""" + profiles_dir = self._config.user_dir / "profiles" + p_path = next( + (profiles_dir / f"{name}{ext}" for ext in [".yaml", ".yml"] if (profiles_dir / f"{name}{ext}").exists()), + None, + ) + + if p_path: + try: + with p_path.open(encoding="utf-8") as f: + new_config = yaml.load(stream=f, Loader=_UniqueKeyLoader) + new_model = ProfileModel(name=name, **new_config) + + # Restore sections importers don't touch + new_model.sigils = old_model.sigils + new_model.tributes = old_model.tributes + new_model.global_uniques = old_model.global_uniques + + save_as_profile(file_name=name, profile=new_model, url=new_model.source_url, exclude={"name"}) + LOGGER.info(f"Refreshed profile '{name}' and restored manual rules.") + except Exception: + LOGGER.exception(f"Failed to merge manual rules into refreshed profile '{name}'") + + self.refresh_profiles() + def _get_profile_summary(self, path: Path) -> str: """Peeks into the YAML using ProfileModel to build a summary tooltip.""" try: @@ -351,6 +468,12 @@ def _get_profile_summary(self, path: Path) -> str: model = ProfileModel(name=path.stem, **config) summary = [f"Last Modified: {mtime}"] + if model.class_name and model.class_name != "unknown": + summary.append(f"👤 Class: {model.class_name.title()}") + + if model.source_url: + summary.append(f"🔗 Source: {model.source_url}") + if model.affixes: types = set() for filter_dict in model.affixes: @@ -496,8 +619,37 @@ def _connect_signals(self): self.show_log_btn.clicked.connect(self._on_show_log_clicked) if self._main_window: self.import_btn.clicked.connect(self._main_window.open_import_dialog) + self.create_profile_btn.clicked.connect(self._create_profile) self.settings_btn.clicked.connect(self._main_window.open_settings_dialog) + def _create_profile(self): + """Create a new empty profile and save it to disk.""" + existing_profile_names = self._config.general.profiles + dialog = CreateProfileDialog(existing_profile_names, self) + if dialog.exec() == QDialog.DialogCode.Accepted: + profile_name, class_name = dialog.get_value() + + # Create base items for each slot matching the current naming and class + all_slots = BASE_GEAR_SLOTS + get_weapon_slots(class_name) + initial_affixes = [] + for slot_name, item_types, _ in all_slots: + # Initialize with minPower 100 as a standard baseline + item_filter = ItemFilterModel(item_type=item_types, min_power=100) + initial_affixes.append(DynamicItemFilterModel({slot_name: item_filter})) + + new_profile_model = ProfileModel(name=profile_name, class_name=class_name, affixes=initial_affixes) + saved_file_name = save_as_profile( + file_name=profile_name, + profile=new_profile_model, + url="manually_created", + exclude={"name"}, + backup_file=False, + ) + add_to_profiles(saved_file_name) + self.refresh_profiles() + if self._main_window: + self._main_window.open_profile_editor(profile_name=saved_file_name) + def _on_config_changed(self, changed_keys: AbstractSet[str]): """Refresh the hotkey grid if any relevant settings changed.""" if any(k.startswith("advanced_options") for k in changed_keys): diff --git a/src/gui/models/dialog.py b/src/gui/models/dialog.py index 0b1c4678..4ab9ee56 100644 --- a/src/gui/models/dialog.py +++ b/src/gui/models/dialog.py @@ -27,7 +27,7 @@ TributeFilterModel, ) from src.dataloader import Dataloader -from src.gui.importer.gui_common import MAX_POWER +from src.gui.importer.gui_common import MAX_POWER, PLAYER_CLASSES, normalize_profile_file_name from src.gui.settings_tab import IgnoreScrollWheelComboBox from src.item.data.item_type import ItemType @@ -185,6 +185,60 @@ def get_value(self): return DynamicItemFilterModel(**{item_name: item}) +class CreateProfileDialog(QDialog): + def __init__(self, existing_profile_names: list[str], parent=None): + super().__init__(parent) + self.setWindowTitle("Create New Profile") + self.setFixedSize(300, 150) + self.existing_profile_names = existing_profile_names + + self.main_layout = QVBoxLayout() + self.form_layout = QFormLayout() + + # Profile Name + self.name_label = QLabel("Profile Name:") + self.name_input = QLineEdit() + self.form_layout.addRow(self.name_label, self.name_input) + + self.buttonLayout = QHBoxLayout() + self.okButton = QPushButton("OK") + self.okButton.clicked.connect(self.accept) + + # Class Selection + self.class_label = QLabel("Class:") + self.class_input = QComboBox() + self.class_input.addItems(sorted([c.title() for c in PLAYER_CLASSES])) + self.form_layout.addRow(self.class_label, self.class_input) + + self.cancelButton = QPushButton("Cancel") + self.cancelButton.clicked.connect(self.reject) + + self.buttonLayout.addWidget(self.okButton) + self.buttonLayout.addWidget(self.cancelButton) + + self.main_layout.addLayout(self.form_layout) + self.main_layout.addLayout(self.buttonLayout) + + self.setLayout(self.main_layout) + + def accept(self): + profile_name = self.name_input.text().strip() + if not profile_name: + QMessageBox.warning(self, "Warning", "Profile name cannot be empty.") + return + + normalized_name = normalize_profile_file_name(profile_name) + + if normalized_name in [normalize_profile_file_name(n) for n in self.existing_profile_names]: + QMessageBox.warning(self, "Warning", f"A profile with the name '{profile_name}' already exists.") + return + + super().accept() + + def get_value(self) -> tuple[str, str]: + return self.name_input.text().strip(), self.class_input.currentText().lower() + + class DeleteItem(QDialog): def __init__(self, item_names, parent=None): super().__init__(parent) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 4f6bdcfd..0f1bb9ca 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -1,6 +1,7 @@ import logging +from typing import override -from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer +from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal from PyQt6.QtGui import QDoubleValidator, QIntValidator from PyQt6.QtWidgets import ( QCheckBox, @@ -15,14 +16,12 @@ QLabel, QLineEdit, QListWidget, - QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QSizePolicy, - QSpinBox, + QTabBar, QTabWidget, - QToolBar, QVBoxLayout, QWidget, ) @@ -32,10 +31,10 @@ AffixFilterModel, AspectUniqueFilterModel, DynamicItemFilterModel, + ItemFilterModel, ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER -from src.gui.models.collapsible_widget import Container from src.gui.models.dialog import ( CreateItem, DeleteAffixPool, @@ -46,7 +45,7 @@ MinPercentDialog, MinPowerDialog, ) -from src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon +from src.item.data.item_type import ItemType, is_weapon LOGGER = logging.getLogger(__name__) @@ -54,6 +53,109 @@ AFFIX_VALUE_MODE = "Value" AFFIX_PERCENT_MODE = "Min %" UNIQUE_ASPECTS_TITLE = "Unique Aspects" +MAX_DROPDOWN_TEXT_LENGTH = 50 + + +class TruncatingComboBox(IgnoreScrollWheelComboBox): + def __init__(self, max_length=MAX_DROPDOWN_TEXT_LENGTH, parent=None): + # Initialize QComboBox (grandparent) with parent + QComboBox.__init__(self, parent) + # Then call the immediate parent's __init__ (IgnoreScrollWheelComboBox) without parent + IgnoreScrollWheelComboBox.__init__(self) + self.max_length = max_length + + @override + def addItems(self, texts: list[str]): + display_texts = [self._get_display_text(t) for t in texts] + super().addItems(display_texts) + for i, text in enumerate(texts): + if len(text) > self.max_length: + self.setItemData(i, text, Qt.ItemDataRole.ToolTipRole) + + @override + def setCurrentText(self, text: str): + super().setCurrentText(self._get_display_text(text)) + self.setToolTip(text if len(text) > self.max_length else "") + + def _get_display_text(self, text: str) -> str: + if len(text) > self.max_length: + return text[: self.max_length - 3] + "..." + return text + + +class CharacterSpinBox(QWidget): + value_changed = pyqtSignal(int) + + def __init__(self, value=0, min_val=0, max_val=100, step=1, parent=None): + super().__init__(parent) + self._value = value + self.min_val = min_val + self.max_val = max_val + self.step = step + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.down_btn = QPushButton("−") + self.down_btn.setObjectName("left") + self.down_btn.setFixedSize(26, 26) + self.down_btn.clicked.connect(self._decrement) + self.down_btn.setStyleSheet( + "QPushButton { border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none; font-size: 16px; padding: 0; }" + ) + + self.edit = QLineEdit(str(self._value)) + self.edit.setReadOnly(True) + self.edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.edit.setFixedHeight(26) + self.edit.setStyleSheet("QLineEdit { border-radius: 0; }") + + self.up_btn = QPushButton("+") + self.up_btn.setObjectName("right") + self.up_btn.setFixedSize(26, 26) + self.up_btn.clicked.connect(self._increment) + self.up_btn.setStyleSheet( + "QPushButton { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: none; font-size: 16px; padding: 0; }" + ) + + layout.addWidget(self.down_btn) + layout.addWidget(self.edit) + layout.addWidget(self.up_btn) + + def _increment(self): + if self._value + self.step <= self.max_val: + self._value += self.step + self.edit.setText(str(self._value)) + self.value_changed.emit(self._value) + + def _decrement(self): + if self._value - self.step >= self.min_val: + self._value -= self.step + self.edit.setText(str(self._value)) + self.value_changed.emit(self._value) + + def value(self) -> int: + return self._value + + def set_value(self, val: int): + self._value = val + self.edit.setText(str(self._value)) + + def set_range(self, min_val: int, max_val: int): + self.min_val = min_val + self.max_val = max_val + + def set_minimum(self, val: int): + self.min_val = val + + def set_maximum(self, val: int): + self.max_val = val + + @override + def setFixedWidth(self, w: int): + super().setFixedWidth(w) + self.edit.setFixedWidth(max(10, w - 52)) def _item_type_summary(item_types: list[ItemType]) -> str: @@ -125,10 +227,325 @@ def get_selected_item_types(self) -> list[ItemType]: return [item_type for item_type, checkbox in self.checkboxes.items() if checkbox.isChecked()] +class SelectionDialog(QDialog): + def __init__(self, parent: QWidget, title: str, items: list[str]): + super().__init__(parent) + self.setWindowTitle(title) + self.resize(400, 500) + layout = QVBoxLayout(self) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Filter items...") + layout.addWidget(self.search_input) + + self.list_widget = QListWidget() + self.list_widget.addItems(items) + layout.addWidget(self.list_widget) + + self.search_input.textChanged.connect(self._filter_list) + self.list_widget.itemDoubleClicked.connect(self.accept) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _filter_list(self, text: str): + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + item.setHidden(text.lower() not in item.text().lower()) + + def get_value(self) -> str | None: + selected = self.list_widget.selectedItems() + return selected[0].text() if selected else None + + +def _create_delete_btn() -> QPushButton: + btn = QPushButton("−") + btn.setFixedWidth(30) + btn.setToolTip("Remove this entry") + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(""" + QPushButton { + color: #ef4444; + font-weight: bold; + font-size: 16px; + border: 1px solid #450a0a; + background-color: #1a0a0a; + } + QPushButton:hover { background-color: #450a0a; color: white; } + """) + return btn + + +def _affix_summary(pool: AffixFilterCountModel) -> str: + names = [] + for a in pool.count: + name = Dataloader().affix_dict.get(a.name, a.name) + if getattr(a, "required", False): + name = f"[REQ] {name}" + if a.want_greater: + name += " (GA)" + names.append(name) + return "\n".join(names) + + +def _affix_card_summary(model: AffixFilterModel) -> str: + name = Dataloader().affix_dict.get(model.name, model.name) + if getattr(model, "required", False): + name = f"[REQ] {name}" + if model.want_greater: + name += " (GA)" + return name + + +def _create_summary_card_style() -> str: + return """ + #SummaryCard { + border: 1px solid #3c3c3c; + border-radius: 6px; + background-color: #1a1a1a; + margin-bottom: 4px; + } + #SummaryCard:hover { + background-color: #242424; + border-color: #5c5c5c; + } + """ + + +def _create_column_header(title: str, add_callback: callable) -> QWidget: + header = QWidget() + layout = QHBoxLayout(header) + layout.setContentsMargins(5, 5, 5, 5) + + spacer = QWidget() + spacer.setFixedWidth(30) + layout.addWidget(spacer) + layout.addStretch() + + lbl = QLabel(title) + lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #94a3b8; text-transform: uppercase;") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(lbl) + layout.addStretch() + + btn = QPushButton("+") + btn.setFixedWidth(30) + btn.setToolTip(f"Add to {title}") + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(add_callback) + layout.addWidget(btn) + + return header + + +def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) -> QWidget: + footer = QWidget() + layout = QHBoxLayout(footer) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(10) + + layout.addStretch() + + min_lbl = QLabel("Min:") + min_lbl.setStyleSheet("color: #94a3b8; font-size: 11px;") + layout.addWidget(min_lbl) + + min_spin = CharacterSpinBox() + min_spin.set_range(0, 10) + min_spin.setFixedWidth(100) + min_spin.set_value(model.min_count) + min_spin.value_changed.connect(lambda v: (setattr(model, "min_count", v), on_change_cb())) + layout.addWidget(min_spin) + + max_lbl = QLabel("Max:") + max_lbl.setStyleSheet("color: #94a3b8; font-size: 11px;") + layout.addWidget(max_lbl) + + max_spin = CharacterSpinBox() + max_spin.set_range(0, 100) + max_spin.setFixedWidth(100) + max_spin.set_value(min(model.max_count, 100)) + max_spin.value_changed.connect( + lambda v: (setattr(model, "max_count", v if v < 100 else 2147483647), on_change_cb()) + ) + layout.addWidget(max_spin) + + layout.addStretch() + + # Store references to update if model changes externally + footer.setProperty("min_spin", min_spin) + footer.setProperty("max_spin", max_spin) + + return footer + + +class UniqueAspectDialog(QDialog): + def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): + super().__init__(parent) + self.setWindowTitle("Configure Unique Aspect") + self.setMinimumWidth(500) + self.model = model + + layout = QVBoxLayout(self) + form = QFormLayout() + + self.name_combo = TruncatingComboBox() + self.name_combo.setEditable(True) + self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.name_combo.addItems(sorted(Dataloader().aspect_unique_dict.keys())) + self.name_combo.setCurrentText(model.name) + form.addRow("Aspect:", self.name_combo) + + self.mode_combo = IgnoreScrollWheelComboBox() + self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) + self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE if model.min_percent_of_aspect else AFFIX_VALUE_MODE) + form.addRow("Mode:", self.mode_combo) + + self.value_edit = QLineEdit() + if model.min_percent_of_aspect: + self.value_edit.setText(str(model.min_percent_of_aspect)) + elif model.value is not None: + self.value_edit.setText(str(model.value)) + form.addRow("Threshold:", self.value_edit) + + layout.addLayout(form) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.save_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def save_and_accept(self): + name = self.name_combo.currentText() + if name in Dataloader().aspect_unique_dict: + self.model.name = name + + mode = self.mode_combo.currentText() + val_str = self.value_edit.text() + + if mode == AFFIX_PERCENT_MODE: + try: + self.model.min_percent_of_aspect = int(val_str) if val_str else 0 + except ValueError: + self.model.min_percent_of_aspect = 0 + self.model.value = None + else: + try: + self.model.value = float(val_str) if val_str else None + except ValueError: + self.model.value = None + self.model.min_percent_of_aspect = 0 + self.accept() + + +class AffixEditDialog(QDialog): + def __init__(self, parent: QWidget, model: AffixFilterModel): + super().__init__(parent) + self.setWindowTitle("Configure Affix") + self.setMinimumWidth(500) + self.model = model + + layout = QVBoxLayout(self) + self.editor = AffixWidget(model) + layout.addWidget(self.editor) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.save_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def save_and_accept(self): + # AffixWidget updates model live on changes, but we ensure final strings are set + self.editor.update_name() + self.editor.update_required() + self.editor.update_value(self.editor.value_edit.text()) + self.accept() + + +class AffixPoolDialog(QDialog): + def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumSize(600, 500) + self.pool = pool + + layout = QVBoxLayout(self) + + config_layout = QHBoxLayout() + self.min_count = CharacterSpinBox() + self.min_count.set_value(pool.min_count) + self.min_count.setFixedWidth(100) + + self.max_count = CharacterSpinBox() + self.max_count.set_value(min(pool.max_count, 2147483647)) + self.max_count.setFixedWidth(100) + + config_layout.addWidget(QLabel("Min:")) + config_layout.addWidget(self.min_count) + config_layout.addSpacing(20) + config_layout.addWidget(QLabel("Max:")) + config_layout.addWidget(self.max_count) + config_layout.addStretch() + layout.addLayout(config_layout) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + self.rows_container = QWidget() + self.rows_layout = QVBoxLayout(self.rows_container) + self.rows_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + for affix in pool.count: + self.add_affix_row(affix) + + scroll.setWidget(self.rows_container) + layout.addWidget(scroll) + + add_btn = QPushButton("+ Add Affix to Pool") + add_btn.clicked.connect(self.add_affix) + layout.addWidget(add_btn) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.save_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def add_affix_row(self, model: AffixFilterModel): + widget = AffixWidget(model) + widget.delete_requested.connect(lambda: self.remove_affix_widget(widget)) + self.rows_layout.addWidget(widget) + + def add_affix(self): + items = sorted(Dataloader().affix_dict.values()) + dialog = SelectionDialog(self, "Select Affix", items) + if dialog.exec() == QDialog.DialogCode.Accepted: + val = dialog.get_value() + if val: + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + affix_id = reverse_dict.get(val) + new_model = AffixFilterModel(name=affix_id, value=None) + self.pool.count.append(new_model) + self.add_affix_row(new_model) + + def remove_affix_widget(self, widget: AffixWidget): + if widget.affix in self.pool.count: + self.pool.count.remove(widget.affix) + widget.setParent(None) + widget.deleteLater() + + def save_and_accept(self): + self.pool.min_count = self.min_count.value() + self.pool.max_count = self.max_count.value() + self.accept() + + class AffixGroupEditor(QWidget): def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): super().__init__(parent) self.settings = QSettings("d4lf", "profile_editor") + self.dynamic_filter = dynamic_filter for item_name, config in dynamic_filter.root.items(): self.item_name = item_name self.config = config @@ -137,33 +554,17 @@ def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): self.setup_ui() def setup_ui(self): - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - - content_widget = QWidget() - self.content_layout = QVBoxLayout(content_widget) + self.content_layout = QVBoxLayout(self) self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) general_form = QFormLayout() - self.item_types = [ - item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item) - ] - self.item_type_line_edit = QLineEdit() - self.item_type_line_edit.setReadOnly(True) - self.item_type_line_edit.setMinimumWidth(360) - self.item_type_line_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.refresh_item_type_summary() - - item_type_layout = QHBoxLayout() - item_type_layout.addWidget(self.item_type_line_edit) - edit_item_types_btn = QPushButton("...") - edit_item_types_btn.setMaximumWidth(40) - edit_item_types_btn.clicked.connect(self.edit_item_types) - item_type_layout.addWidget(edit_item_types_btn) - item_type_layout.addStretch() - general_form.addRow("Item Types:", item_type_layout) + # Item Alias/Name + self.alias_edit = QLineEdit() + self.alias_edit.setText(self.item_name) + self.alias_edit.setMaximumWidth(300) + self.alias_edit.textChanged.connect(self.update_item_alias) + general_form.addRow("Item Name / Alias:", self.alias_edit) self.min_power = IgnoreScrollWheelSpinBox() self.min_power.setMaximum(MAX_POWER) @@ -174,17 +575,17 @@ def setup_ui(self): min_greater_layout = QHBoxLayout() - self.min_greater = QSpinBox() - self.min_greater.setValue(self.config.min_greater_affix_count) - self.min_greater.setMaximum(4) - self.min_greater.setMinimum(0) - self.min_greater.setMaximumWidth(80) + self.min_greater = CharacterSpinBox() + self.min_greater.set_value(self.config.min_greater_affix_count) + self.min_greater.set_maximum(4) + self.min_greater.set_minimum(0) + self.min_greater.setFixedWidth(100) self.min_greater.setToolTip( "Minimum number of checked affixes that must be Greater Affixes.\n" "0 = Accept items even without GAs (for leveling)\n" "1-4 = At least this many checked affixes must be GA" ) - self.min_greater.valueChanged.connect(self.update_min_greater_affix) + self.min_greater.value_changed.connect(self.update_min_greater_affix) self.auto_sync_checkbox = QCheckBox("Auto Sync") self.auto_sync_checkbox.setToolTip( @@ -213,192 +614,171 @@ def setup_ui(self): self._refresh_widget_style(self.min_greater) general_form.addRow("Min Greater Affixes:", min_greater_layout) - self.content_layout.addLayout(general_form) - self.create_unique_aspect_container() - - pool_btn_layout = QHBoxLayout() - add_affix_pool_btn = QPushButton("Add Affix Pool") - add_affix_pool_btn.clicked.connect(self.add_affix_pool) - add_inherent_pool_btn = QPushButton("Add Inherent Pool") - add_inherent_pool_btn.clicked.connect(self.add_inherent_pool) - remove_affix_pool_btn = QPushButton("Remove Affix Pool") - remove_affix_pool_btn.clicked.connect(lambda: self.remove_selected(self.affix_pool_layout)) - remove_inherent_pool_btn = QPushButton("Remove Inherent Pool") - remove_inherent_pool_btn.clicked.connect(lambda: self.remove_selected(self.inherent_pool_layout, inherent=True)) - - pool_btn_layout.addWidget(add_affix_pool_btn) - pool_btn_layout.addWidget(add_inherent_pool_btn) - pool_btn_layout.addWidget(remove_affix_pool_btn) - pool_btn_layout.addWidget(remove_inherent_pool_btn) - - self.affix_pool_container = Container("Affix Pool") - self.affix_pool_layout = QVBoxLayout(self.affix_pool_container.content_widget) - self.affix_pool_container.first_expansion.connect(self.init_affix_pool) - - self.inherent_pool_container = Container("Inherent Pool") - self.inherent_pool_layout = QVBoxLayout(self.inherent_pool_container.content_widget) - self.inherent_pool_container.first_expansion.connect(self.init_inherent_pool) - - self.content_layout.addWidget(self.affix_pool_container) - self.content_layout.addWidget(self.inherent_pool_container) - self.content_layout.addLayout(pool_btn_layout) - - scroll_area.setWidget(content_widget) - - main_layout = QVBoxLayout(self) - main_layout.addWidget(scroll_area) - self.setLayout(main_layout) - - QTimer.singleShot(100, self.affix_pool_container.expand) - QTimer.singleShot(100, self.inherent_pool_container.expand) - - def create_unique_aspect_container(self): - self.unique_aspect_container = Container(self._unique_aspects_title()) - self.unique_aspect_layout = QVBoxLayout(self.unique_aspect_container.content_widget) - self.unique_aspect_container.first_expansion.connect(self.init_unique_aspects) - - layout = QVBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - title_layout = QHBoxLayout() - title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - - aspect_label = QLabel("Aspect") - aspect_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(aspect_label) - - mode_label = QLabel("Mode") - mode_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(mode_label) - value_label = QLabel("Threshold") - value_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(value_label) - - title_layout.addSpacing(25) - title_layout.addWidget(aspect_label) - title_layout.addSpacing(440) - title_layout.addWidget(mode_label) - title_layout.addSpacing(85) - title_layout.addWidget(value_label) - - self.unique_aspect_list = QListWidget() - self.unique_aspect_list.setFixedHeight(180) - self.unique_aspect_list.setAlternatingRowColors(True) - self.unique_aspect_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.unique_aspect_list.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - - unique_aspect_btn_layout = QHBoxLayout() - add_unique_aspect_btn = QPushButton("Add Unique Aspect") - add_unique_aspect_btn.clicked.connect(self.add_unique_aspect) - unique_aspect_btn_layout.addWidget(add_unique_aspect_btn) + # 3-Column Layout + columns_layout = QHBoxLayout() + columns_layout.setSpacing(15) + + def create_col(title, add_cb, pool_model=None): + col_widget = QWidget() + col_layout = QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(0) + + header = _create_column_header(title, add_cb) + col_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(inner) + col_layout.addWidget(scroll) + + footer = None + if pool_model is not None: + footer = _create_column_footer(pool_model, self.update_greater_count_label) + footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + col_layout.addWidget(footer) + + return col_widget, inner_layout, footer + + # Init columns + self.aspect_col, self.aspect_rows_layout, _ = create_col("Unique Aspects", self.add_unique_aspect) + self.affix_col, self.affix_pool_layout, self.affix_footer = create_col( + "Affix Pool", self.add_affix_pool, self.config.affix_pool[0] + ) + self.inherent_col, self.inherent_pool_layout, self.inherent_footer = create_col( + "Inherent Pool", self.add_inherent_pool, self.config.inherent_pool[0] + ) - remove_unique_aspect_btn = QPushButton("Remove Unique Aspect") - remove_unique_aspect_btn.clicked.connect(self.remove_selected_unique_aspects) - unique_aspect_btn_layout.addWidget(remove_unique_aspect_btn) + columns_layout.addWidget(self.aspect_col) + columns_layout.addWidget(self.affix_col) + columns_layout.addWidget(self.inherent_col) - layout.addLayout(unique_aspect_btn_layout) - layout.addLayout(title_layout) - layout.addWidget(self.unique_aspect_list) + self.content_layout.addLayout(columns_layout) - self.unique_aspect_layout.addLayout(layout) - self.content_layout.addWidget(self.unique_aspect_container) + # Initialize content + self.init_unique_aspects() + self.init_affix_pool() + self.init_inherent_pool() - def _unique_aspects_title(self): - aspect_names = ", ".join(unique_aspect.name for unique_aspect in self.config.unique_aspect) or "None" - return f"{UNIQUE_ASPECTS_TITLE} - {aspect_names}" + def init_unique_aspects(self): + for aspect in self.config.unique_aspect: + self.add_unique_aspect_item(aspect) - def refresh_unique_aspects_title(self): - self.unique_aspect_container.header.set_name(self._unique_aspects_title()) + def init_affix_pool(self): + for affix in self.config.affix_pool[0].count: + self.add_affix_item(affix, inherent=False) - def init_unique_aspects(self): - for unique_aspect in self.config.unique_aspect: - self.add_unique_aspect_item(unique_aspect) + def init_inherent_pool(self): + for affix in self.config.inherent_pool[0].count: + self.add_affix_item(affix, inherent=True) def _refresh_widget_style(self, widget): widget.style().unpolish(widget) widget.style().polish(widget) - def add_unique_aspect_item(self, unique_aspect: AspectUniqueFilterModel): - item = QListWidgetItem() - widget = UniqueAspectWidget(unique_aspect) - item_size = widget.sizeHint() - item_size.setWidth(850) - item.setSizeHint(item_size) - self.unique_aspect_list.addItem(item) - self.unique_aspect_list.setItemWidget(item, widget) - - def add_unique_aspect(self): - existing_names = {unique_aspect.name for unique_aspect in self.config.unique_aspect} - for aspect_name in Dataloader().aspect_unique_dict: - if aspect_name in existing_names: - continue - new_unique_aspect = AspectUniqueFilterModel(name=aspect_name, value=None) - self.config.unique_aspect.append(new_unique_aspect) - self.add_unique_aspect_item(new_unique_aspect) - self.refresh_unique_aspects_title() + def update_item_alias(self, text: str): + new_name = text.strip() + if not new_name or new_name == self.item_name: return - QMessageBox.information(self, "Info", "All unique aspects have already been added.") - def remove_selected_unique_aspects(self): - selected_rows = sorted( - (self.unique_aspect_list.row(item) for item in self.unique_aspect_list.selectedItems()), reverse=True - ) - for row in selected_rows: - self.unique_aspect_list.takeItem(row) - del self.config.unique_aspect[row] - self.refresh_unique_aspects_title() - - def init_affix_pool(self): - """Initialize affix pool content on first expansion.""" - for pool in self.config.affix_pool: - self.add_affix_pool_item(pool) - QTimer.singleShot(50, self.update_greater_count_label) + # Update root dictionary key + if self.item_name in self.dynamic_filter.root: + self.dynamic_filter.root[new_name] = self.dynamic_filter.root.pop(self.item_name) + self.item_name = new_name + + # Signal parent to refresh tab text + p = self.parent() + while p: + if isinstance(p, AffixesTab): + # Find index of this item in the parent's map + if self.item_name in p.item_data_map: + idx = p.item_names.index( + next(k for k, v in p.item_data_map.items() if v == self.dynamic_filter) + ) + p.item_names[idx] = self.item_name + p.tab_widget.setTabText(idx, self.item_name) + break + p = p.parent() + + def add_unique_aspect_item(self, model: AspectUniqueFilterModel): + widget = UniqueAspectWidget(model) + widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget)) + self.aspect_rows_layout.addWidget(widget) + return widget - def init_inherent_pool(self): - """Initialize inherent pool content on first expansion.""" - for pool in self.config.inherent_pool: - self.add_affix_pool_item(pool, inherent=True) - QTimer.singleShot(50, self.update_greater_count_label) - - def add_affix_pool_item(self, pool: AffixFilterCountModel, inherent: bool = False): - if inherent: - nb_count = self.inherent_pool_layout.count() - container = Container(f"Count {nb_count}", color_background=True) - container_layout = QVBoxLayout(container.content_widget) - widget = AffixPoolWidget(pool) - container_layout.addWidget(widget) - self.inherent_pool_layout.addWidget(container) - QTimer.singleShot(50, container.expand) - else: - nb_count = self.affix_pool_layout.count() - container = Container(f"Count {nb_count}", color_background=True) - container_layout = QVBoxLayout(container.content_widget) - widget = AffixPoolWidget(pool) - container_layout.addWidget(widget) - self.affix_pool_layout.addWidget(container) - QTimer.singleShot(50, container.expand) + def add_unique_aspect(self): + items = sorted(Dataloader().aspect_unique_dict.keys()) + dialog = SelectionDialog(self, "Select Unique Aspect", items) + if dialog.exec() == QDialog.DialogCode.Accepted: + val = dialog.get_value() + if val: + new_model = AspectUniqueFilterModel(name=val, value=None) + self.config.unique_aspect.append(new_model) + self.add_unique_aspect_item(new_model).open_config_dialog() + + def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): + if widget.unique_aspect in self.config.unique_aspect: + self.config.unique_aspect.remove(widget.unique_aspect) + widget.setParent(None) + widget.deleteLater() + + def add_affix_item(self, model: AffixFilterModel, inherent: bool = False): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + + widget = AffixSummaryWidget(model) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent)) + widget.config_changed.connect(self.update_greater_count_label) + layout.addWidget(widget) + return widget + + def remove_affix_item_widget(self, widget, inherent: bool): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + pool = self.config.inherent_pool[0] if inherent else self.config.affix_pool[0] + + idx = layout.indexOf(widget) + if idx != -1: + pool.count.pop(idx) + widget.setParent(None) + widget.deleteLater() + self.update_greater_count_label() def add_affix_pool(self): - default_affix = AffixFilterModel( - name=next(iter(Dataloader().affix_dict.keys())), # First valid affix name - value=None, - ) + common_affixes = ["Energy", "Strength", "Dexterity", "Vitality", "Intelligence"] + default_name = None + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + for affix in common_affixes: + if affix in reverse_dict: + default_name = reverse_dict[affix] + break + if default_name is None: + default_name = next(iter(Dataloader().affix_dict.keys())) - new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3) - self.config.affix_pool.append(new_pool) - self.add_affix_pool_item(new_pool) + default_affix = AffixFilterModel(name=default_name, value=None) + self.config.affix_pool[0].count.append(default_affix) + self.add_affix_item(default_affix).open_config_dialog() def add_inherent_pool(self): - default_affix = AffixFilterModel( - name=next(iter(Dataloader().affix_dict.keys())), # First valid affix name - value=None, - ) + common_affixes = ["Strength", "Dexterity", "Vitality", "Intelligence"] + default_name = None + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + for affix in common_affixes: + if affix in reverse_dict: + default_name = reverse_dict[affix] + break + if default_name is None: + default_name = next(iter(Dataloader().affix_dict.keys())) - new_pool = AffixFilterCountModel(count=[default_affix], min_count=1, max_count=3) - self.config.inherent_pool.append(new_pool) - self.add_affix_pool_item(new_pool, inherent=True) + default_affix = AffixFilterModel(name=default_name, value=None) + self.config.inherent_pool[0].count.append(default_affix) + self.add_affix_item(default_affix, inherent=True).open_config_dialog() def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): nb_pool = layout_widget.count() @@ -422,17 +802,7 @@ def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): def reorganize_pool(self, layout_widget: QVBoxLayout): for i in range(layout_widget.count()): item = layout_widget.itemAt(i) - if item and item.widget() is not None: - item.widget().header.set_name(f"Count {i}") - - def refresh_item_type_summary(self): - self.item_type_line_edit.setText(_item_type_summary(self.config.item_type)) - - def edit_item_types(self): - item_type_picker = ItemTypePicker(self, self.item_types, self.config.item_type) - if item_type_picker.exec() == QDialog.DialogCode.Accepted: - self.config.item_type = item_type_picker.get_selected_item_types() - self.refresh_item_type_summary() + item and item.widget() is not None def update_min_power(self): self.config.min_power = self.min_power.value() @@ -452,12 +822,8 @@ def toggle_auto_sync(self): if is_auto_sync: self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003 self._refresh_widget_style(self.min_greater) - - self.affix_pool_container.expand() - self.inherent_pool_container.expand() - count = self.count_want_greater_affixes() - self.min_greater.setValue(count) + self.min_greater.set_value(count) self.update_greater_count_label() else: self.min_greater.setProperty("autoSyncSpin", False) # noqa: FBT003 @@ -465,51 +831,37 @@ def toggle_auto_sync(self): def _update_auto_sync_count(self): count = self.count_want_greater_affixes() - self.min_greater.setValue(count) + self.min_greater.set_value(count) self.update_greater_count_label() def sync_min_greater_from_checkboxes(self): if self.auto_sync_checkbox.isChecked(): count = self.count_want_greater_affixes() - self.min_greater.setValue(count) - - def _ensure_pool_widgets_initialized(self): - for container in (self.affix_pool_container, self.inherent_pool_container): - was_visible = container.content_widget.isVisible() - if container.header.first_expansion: - container.expand() - if not was_visible: - container.collapse() + self.min_greater.set_value(count) def iter_affix_widgets(self): - self._ensure_pool_widgets_initialized() - - # Inherents do not participate in Greater Affix auto-sync or bulk Min % updates. - for i in range(self.affix_pool_layout.count()): - container = self.affix_pool_layout.itemAt(i).widget() - if container is None or not hasattr(container, "content_widget"): - continue - pool_item = container.content_widget.layout().itemAt(0) - if pool_item is None: - continue - pool_widget = pool_item.widget() - if not isinstance(pool_widget, AffixPoolWidget): - continue - for j in range(pool_widget.affix_list.count()): - list_item = pool_widget.affix_list.item(j) - affix_widget = pool_widget.affix_list.itemWidget(list_item) - if isinstance(affix_widget, AffixWidget): - yield affix_widget + # NOTE: Since AffixWidgets are now inside dialogs, we can't yield UI widgets for bulk updates. + # Bulk operations in this view must be handled via direct model updates or a different pattern. + return [] + + def refresh_all_summaries(self): + for layout in (self.affix_pool_layout, self.inherent_pool_layout): + for i in range(layout.count()): + w = layout.itemAt(i).widget() + if isinstance(w, AffixPoolWidget): + w.refresh_display() + for i in range(self.aspect_rows_layout.count()): + w = self.aspect_rows_layout.itemAt(i).widget() + if isinstance(w, UniqueAspectWidget): + w.refresh_display() def count_want_greater_affixes(self): want_greater_count = 0 - if not hasattr(self, "affix_pool_layout") or not hasattr(self, "inherent_pool_layout"): - return 0 - - for affix_widget in self.iter_affix_widgets(): - if affix_widget.greater_checkbox.isChecked(): - want_greater_count += 1 + for pool in self.config.affix_pool: + for affix in pool.count: + if affix.want_greater: + want_greater_count += 1 return want_greater_count @@ -528,54 +880,56 @@ def convert_all_to_min_percent_of_affix(self, percent: int): class UniqueAspectWidget(QWidget): + delete_requested = pyqtSignal() + config_changed = pyqtSignal() + def __init__(self, unique_aspect: AspectUniqueFilterModel, parent=None): super().__init__(parent) self.unique_aspect = unique_aspect + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setCursor(Qt.CursorShape.PointingHandCursor) self.setup_ui() def setup_ui(self): - layout = QHBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - layout.setSpacing(50) + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) - self.create_aspect_name_combobox() - self.create_mode_combobox() - self.create_value_input() - self.mode_combo.currentTextChanged.connect(self.update_mode) - self.update_mode(self.mode_combo.currentText()) + self.summary_label = QLabel() + self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") + self.main_layout.addWidget(self.summary_label, 1) - layout.addWidget(self.name_combo) - layout.addWidget(self.mode_combo) - layout.addWidget(self.value_edit) + self.threshold_label = QLabel() + self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.main_layout.addWidget(self.threshold_label) - self.setMinimumWidth(850) - self.setLayout(layout) + self.delete_btn = _create_delete_btn() + self.delete_btn.clicked.connect(self.delete_requested.emit) + self.main_layout.addWidget(self.delete_btn) - def create_aspect_name_combobox(self): - self.name_combo = IgnoreScrollWheelComboBox() - self.name_combo.setEditable(True) - self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.name_combo.addItems(sorted(Dataloader().aspect_unique_dict.keys())) - self.name_combo.setMaximumWidth(600) - if self.unique_aspect.name in Dataloader().aspect_unique_dict: - self.name_combo.setCurrentText(self.unique_aspect.name) - self.name_combo.currentTextChanged.connect(self.update_name) + self.refresh_display() + + @override + def mousePressEvent(self, event): + if event is None or event.button() == Qt.MouseButton.LeftButton: + self.open_config_dialog() + + def open_config_dialog(self): + dialog = UniqueAspectDialog(self, self.unique_aspect) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.refresh_display() + self.config_changed.emit() + + def refresh_display(self): + name = Dataloader().aspect_unique_dict.get(self.unique_aspect.name, {}).get("name", self.unique_aspect.name) + self.summary_label.setText(name.replace("_", " ").title()) - def create_mode_combobox(self): - self.mode_combo = IgnoreScrollWheelComboBox() - self.mode_combo.setFixedSize(100, self.mode_combo.sizeHint().height()) - self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) if self.unique_aspect.min_percent_of_aspect: - self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE) + self.threshold_label.setText(f"{self.unique_aspect.min_percent_of_aspect}%") + elif self.unique_aspect.value is not None: + self.threshold_label.setText(str(self.unique_aspect.value)) else: - self.mode_combo.setCurrentText(AFFIX_VALUE_MODE) - - def create_value_input(self): - self.value_edit = QLineEdit() - self.value_edit.setFixedSize(100, self.value_edit.sizeHint().height()) - self.value_edit.textChanged.connect(self.update_value) + self.threshold_label.setText("No Threshold") def update_name(self, current_text=None): aspect_name = current_text or self.name_combo.currentText() @@ -637,125 +991,129 @@ def update_value(self, value): self.unique_aspect.min_percent_of_aspect = 0 +class AffixSummaryWidget(QWidget): + delete_requested = pyqtSignal() + config_changed = pyqtSignal() + + def __init__(self, model: AffixFilterModel, parent=None): + super().__init__(parent) + self.model = model + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setup_ui() + + def setup_ui(self): + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) + + self.summary_label = QLabel() + self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") + self.main_layout.addWidget(self.summary_label, 1) + + self.threshold_label = QLabel() + self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.main_layout.addWidget(self.threshold_label) + + self.delete_btn = _create_delete_btn() + self.delete_btn.clicked.connect(self.delete_requested.emit) + self.main_layout.addWidget(self.delete_btn) + + self.refresh_display() + + @override + def mousePressEvent(self, event): + if event is None or event.button() == Qt.MouseButton.LeftButton: + self.open_config_dialog() + + def open_config_dialog(self): + dialog = AffixEditDialog(self, self.model) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.refresh_display() + self.config_changed.emit() + + def refresh_display(self): + name = Dataloader().affix_dict.get(self.model.name, self.model.name) + prefix = "[REQ] " if getattr(self.model, "required", False) else "" + if self.model.want_greater: + name += " (GA)" + self.summary_label.setText(f"{prefix}{name}") + + if self.model.min_percent_of_affix: + self.threshold_label.setText(f"{self.model.min_percent_of_affix}%") + elif self.model.value is not None: + self.threshold_label.setText(str(self.model.value)) + else: + self.threshold_label.setText("No Threshold") + + class AffixPoolWidget(QWidget): + pool_delete_requested = pyqtSignal() + config_changed = pyqtSignal() + def __init__(self, pool: AffixFilterCountModel, parent=None): super().__init__(parent) self.pool = pool + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setCursor(Qt.CursorShape.PointingHandCursor) self.setup_ui() def setup_ui(self): - layout = QVBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) + self.main_layout.setSpacing(10) - config_layout = QHBoxLayout() - config_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - - min_count_label = QLabel("Min Count:") - min_count_label.setMaximumWidth(100) - min_count_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(min_count_label) - config_layout.addWidget(min_count_label) - - self.min_count = IgnoreScrollWheelSpinBox() - self.min_count.setValue(self.pool.min_count) - self.min_count.setMaximumWidth(100) - self.min_count.valueChanged.connect(self.update_min_count) - config_layout.addWidget(self.min_count) - config_layout.addSpacing(150) - - max_count_label = QLabel("Max Count:") - max_count_label.setMaximumWidth(100) - max_count_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(max_count_label) - config_layout.addWidget(max_count_label) - - self.max_count = IgnoreScrollWheelSpinBox() - self.max_count.setValue(min(self.pool.max_count, 2147483647)) - self.max_count.setMaximumWidth(100) - self.max_count.valueChanged.connect(self.update_max_count) - config_layout.addWidget(self.max_count) + # Container for labels on the left + text_layout = QVBoxLayout() + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(2) - layout.addLayout(config_layout) + self.affix_summary = QLabel() + self.affix_summary.setWordWrap(True) + self.affix_summary.setStyleSheet("color: #cbd5e1; font-size: 11px;") + text_layout.addWidget(self.affix_summary) - title_layout = QHBoxLayout() - title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - - affix_label = QLabel("Affixes") - affix_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(affix_label) - - greater_label = QLabel("Greater") - greater_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(greater_label) - - mode_label = QLabel("Mode") - mode_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(mode_label) - - value_label = QLabel("Threshold") - value_label.setProperty("affixHeaderLabel", True) # noqa: FBT003 - self._refresh_widget_style(value_label) - - title_layout.addSpacing(250) - title_layout.addWidget(affix_label) - title_layout.addSpacing(400) - title_layout.addWidget(greater_label) - title_layout.addSpacing(70) - title_layout.addWidget(mode_label) - title_layout.addSpacing(85) - title_layout.addWidget(value_label) - - self.affix_list = QListWidget() - self.affix_list.setMinimumHeight(200) - self.affix_list.setAlternatingRowColors(True) - for affix in self.pool.count: - self.add_affix_item(affix) - - affix_btn_layout = QHBoxLayout() - add_affix_btn = QPushButton("Add Affix") - add_affix_btn.clicked.connect(self.add_affix) - affix_btn_layout.addWidget(add_affix_btn) - - remove_affix_btn = QPushButton("Remove Affix") - remove_affix_btn.clicked.connect(lambda: self.remove_selected(self.affix_list)) - affix_btn_layout.addWidget(remove_affix_btn) - - layout.addLayout(affix_btn_layout) - layout.addLayout(title_layout) - layout.addWidget(self.affix_list) + self.count_label = QLabel() + self.count_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + text_layout.addWidget(self.count_label) - self.setLayout(layout) + self.main_layout.addLayout(text_layout, 1) - def _refresh_widget_style(self, widget): - widget.style().unpolish(widget) - widget.style().polish(widget) + # Hidden label used for internal state and dialog titles + self.pool_name_label = QLabel() + self.pool_name_label.setVisible(False) - def add_affix_item(self, affix: AffixFilterModel): - item = QListWidgetItem() - widget = AffixWidget(affix) - item.setSizeHint(widget.sizeHint()) - self.affix_list.addItem(item) - self.affix_list.setItemWidget(item, widget) + self.del_pool_btn = _create_delete_btn() + self.del_pool_btn.setToolTip("Delete entire pool") + self.del_pool_btn.clicked.connect(self.pool_delete_requested.emit) + self.main_layout.addWidget(self.del_pool_btn, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - def add_affix(self): - new_affix = AffixFilterModel(name=next(iter(Dataloader().affix_dict.keys())), value=None) - self.pool.count.append(new_affix) - self.add_affix_item(new_affix) + self.refresh_display() - def remove_selected(self, list_widget: QListWidget): - for item in list_widget.selectedItems(): - row = list_widget.row(item) - list_widget.takeItem(row) - del self.pool.count[row] + @override + def mousePressEvent(self, event): + if event is None or event.button() == Qt.MouseButton.LeftButton: + self.open_config_dialog() - def update_min_count(self): - self.pool.min_count = self.min_count.value() + def open_config_dialog(self): + dialog = AffixPoolDialog(self, self.pool, self.pool_name_label.text()) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.refresh_display() + self.config_changed.emit() - def update_max_count(self): - self.pool.max_count = self.max_count.value() + def set_pool_name(self, name: str): + self.pool_name_label.setText(name.upper()) + + def refresh_display(self): + max_val = "∞" if self.pool.max_count > 1000 else str(self.pool.max_count) + self.count_label.setText(f"Min: {self.pool.min_count} / Max: {max_val}") + self.affix_summary.setText(_affix_summary(self.pool)) class AffixWidget(QWidget): + delete_requested = pyqtSignal() + def __init__(self, affix: AffixFilterModel, parent=None): super().__init__(parent) self.affix = affix @@ -763,38 +1121,54 @@ def __init__(self, affix: AffixFilterModel, parent=None): def setup_ui(self): layout = QHBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - layout.setSpacing(50) + layout.setContentsMargins(0, 2, 0, 2) + layout.setSpacing(10) self.create_affix_name_combobox() self.create_greater_checkbox() + self.create_required_checkbox() self.create_mode_combobox() self.create_value_input() + self.mode_combo.currentTextChanged.connect(self.update_mode) self.update_mode(self.mode_combo.currentText()) layout.addWidget(self.name_combo) + layout.addWidget(self.required_checkbox) layout.addWidget(self.greater_checkbox) - layout.addWidget(self.mode_combo) - layout.addWidget(self.value_edit) + layout.addWidget(self.mode_combo, stretch=0) + layout.addWidget(self.value_edit, stretch=0) self.setLayout(layout) def create_affix_name_combobox(self): - self.name_combo = IgnoreScrollWheelComboBox() + # The previous line `self.name_combo = IgnoreScrollWheelComboBox()` was redundant and overwritten. + # The TruncatingComboBox needs to be initialized correctly. + self.name_combo = TruncatingComboBox(parent=self) self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) self.name_combo.addItems(sorted(Dataloader().affix_dict.values())) - self.name_combo.setMaximumWidth(600) + self.name_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) if self.affix.name in Dataloader().affix_dict: self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name]) - # currentIndexChanged misses some editable-combobox keyboard flows. + self.name_combo.setCurrentText( + Dataloader().affix_dict[self.affix.name] + ) # TruncatingComboBox handles truncation self.name_combo.currentTextChanged.connect(self.update_name) + def create_required_checkbox(self): + self.required_checkbox = QCheckBox("Required") + self.required_checkbox.setChecked(getattr(self.affix, "required", False)) + self.required_checkbox.setFixedWidth(85) + self.required_checkbox.stateChanged.connect(self.update_required) + + def update_required(self): + self.affix.required = self.required_checkbox.isChecked() + def create_greater_checkbox(self): - self.greater_checkbox = QCheckBox("Greater") + self.greater_checkbox = QCheckBox("GA") self.greater_checkbox.setChecked(getattr(self.affix, "want_greater", False)) self.greater_checkbox.setFixedWidth(80) self.greater_checkbox.setProperty("greaterCheckbox", True) # noqa: FBT003 @@ -817,7 +1191,7 @@ def update_parent_count_label(self): def create_mode_combobox(self): self.mode_combo = IgnoreScrollWheelComboBox() - self.mode_combo.setFixedSize(100, self.mode_combo.sizeHint().height()) + self.mode_combo.setFixedWidth(80) self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) if self.affix.min_percent_of_affix: self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE) @@ -826,7 +1200,7 @@ def create_mode_combobox(self): def create_value_input(self): self.value_edit = QLineEdit() - self.value_edit.setFixedSize(100, self.value_edit.sizeHint().height()) + self.value_edit.setFixedWidth(80) self.value_edit.textChanged.connect(self.update_value) def update_name(self, current_text=None): @@ -893,7 +1267,13 @@ class AffixesTab(QWidget): def __init__(self, affixes_model: list[DynamicItemFilterModel], parent=None): super().__init__(parent) self.affixes_model = affixes_model + self._current_slot_name = "" + self._current_slot_item_types = [] self.loaded = False + self.settings = QSettings("d4lf", "profile_editor") + self.item_names = [] + self.item_data_map: dict[str, DynamicItemFilterModel] = {} + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): if not self.loaded: @@ -903,71 +1283,122 @@ def load(self): def setup_ui(self): """Populate the grid layout with existing groups.""" self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 20, 0, 20) + self.main_layout.setContentsMargins(0, 5, 0, 5) self.tab_widget = QTabWidget(self) - self.tab_widget.setTabsClosable(True) - self.tab_widget.tabCloseRequested.connect(self.close_tab) - - self.toolbar = QToolBar("MyToolBar", self) - self.toolbar.setMinimumHeight(50) - self.toolbar.setContentsMargins(10, 10, 10, 10) - self.toolbar.setMovable(False) - - self.item_names = [] - for affix_group in self.affixes_model: - for item_name in affix_group.root: - if item_name in self.item_names: - QMessageBox.warning( - self, "Warning", f"Item name already exist please rename {item_name} in the profile file." - ) - continue - group = AffixGroupEditor(affix_group) - self.item_names.append(item_name) - self.tab_widget.addTab(group, item_name) - - add_item_button = QPushButton() - add_item_button.setText("Create Item") - add_item_button.clicked.connect(self.add_item_type) - - remove_item_button = QPushButton() - remove_item_button.setText("Remove Item") - remove_item_button.clicked.connect(self.remove_item_type) - - set_all_min_greater_affix_button = QPushButton("Set All Min GAs (Excludes Auto Synced Items)") - convert_all_to_min_percent_button = QPushButton("Convert All To Min %") - set_all_min_power_button = QPushButton("Set all minPower") - set_all_min_greater_affix_button.clicked.connect(self.set_all_min_greater_affix) - convert_all_to_min_percent_button.clicked.connect(self.convert_all_to_min_percent_of_affix) - set_all_min_power_button.clicked.connect(self.set_all_min_power) - - self.toolbar.addWidget(add_item_button) - self.toolbar.addWidget(remove_item_button) - self.toolbar.addWidget(set_all_min_greater_affix_button) - self.toolbar.addWidget(convert_all_to_min_percent_button) - self.toolbar.addWidget(set_all_min_power_button) - - self.main_layout.addWidget(self.toolbar) + with QSignalBlocker(self.tab_widget): + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self.close_tab) + self.tab_widget.currentChanged.connect(self._on_tab_changed) + self.tab_widget.tabBar().tabBarClicked.connect(self._on_tab_bar_clicked) + + # Add a persistent "+" tab at the end + self.tab_widget.addTab(QWidget(), "+") + + self.item_names = [] + self.item_data_map.clear() + for affix_group in self.affixes_model: + for item_name in affix_group.root: + if item_name in self.item_names: + QMessageBox.warning( + self, "Warning", f"Item name already exist please rename {item_name} in the profile file." + ) + continue + self.item_names.append(item_name) + self.item_data_map[item_name] = affix_group + # Insert before the "+" tab + self.tab_widget.insertTab(self.tab_widget.count() - 1, QWidget(), item_name) + + self._update_plus_tab_button() self.main_layout.addWidget(self.tab_widget) def show_message(self, text): QMessageBox.information(self, "Info", text) + def _on_tab_changed(self, index): + if index >= 0 and self.tab_widget.tabText(index) == "+": + self.add_item_type() + + def _on_tab_bar_clicked(self, index): + # This handles clicking the "+" tab when it's already selected + if index >= 0 and self.tab_widget.tabText(index) == "+" and self.tab_widget.currentIndex() == index: + self.add_item_type() + + def _update_plus_tab_button(self): + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + self.tab_widget.tabBar().setTabButton(i, QTabBar.ButtonPosition.RightSide, None) + self.tab_widget.setTabToolTip(i, "Create Item") + + def _ensure_tab_instantiated(self, index: int): + if index < 0 or index >= self.tab_widget.count(): + return + if not isinstance(self.tab_widget.widget(index), AffixGroupEditor): + item_name = self.item_names[index] + affix_group = self.item_data_map[item_name] + is_current = self.tab_widget.currentIndex() == index + with QSignalBlocker(self.tab_widget): + editor = AffixGroupEditor(affix_group) + self.tab_widget.removeTab(index) + self.tab_widget.insertTab(index, editor, item_name) + if is_current: + self.tab_widget.setCurrentIndex(index) + def add_item_type(self): + plus_idx = -1 + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + plus_idx = i + break + + # Switch to previous tab if we were triggered by clicking the "+" tab + if self.tab_widget.currentIndex() == plus_idx and plus_idx > 0: + self.tab_widget.setCurrentIndex(plus_idx - 1) + + if self._current_slot_name: + base_name = self._current_slot_name.replace(" ", "") + new_name = base_name + if new_name in self.item_names: + i = 2 + while f"{base_name}{i}" in self.item_names: + i += 1 + new_name = f"{base_name}{i}" + + item_model = ItemFilterModel(item_type=self._current_slot_item_types or []) + dynamic_filter = DynamicItemFilterModel({new_name: item_model}) + + self.item_names.append(new_name) + self.item_data_map[new_name] = dynamic_filter + group = AffixGroupEditor(dynamic_filter) + self.tab_widget.insertTab(plus_idx, group, new_name) + self.affixes_model.append(dynamic_filter) + self.tab_widget.setCurrentIndex(plus_idx) + self._update_plus_tab_button() + return + + # Fallback for manual creation outside of doll context dialog = CreateItem(self.item_names, self) if dialog.exec() == QDialog.DialogCode.Accepted: item = dialog.get_value() for item_name in item.root: group = AffixGroupEditor(item) self.item_names.append(item_name) - self.tab_widget.addTab(group, item_name) + self.item_data_map[item_name] = item + self.tab_widget.insertTab(plus_idx, group, item_name) self.affixes_model.append(item) - return + self.tab_widget.setCurrentIndex(plus_idx) + self._update_plus_tab_button() def close_tab(self, index): - self.item_names.pop(index) - self.tab_widget.removeTab(index) - self.affixes_model.pop(index) + if self.tab_widget.tabText(index) == "+": + return + + with QSignalBlocker(self.tab_widget): + name = self.item_names.pop(index) + self.item_data_map.pop(name, None) + self.tab_widget.removeTab(index) + self.affixes_model.pop(index) + self._update_plus_tab_button() def remove_item_type(self): dialog = DeleteItem(self.item_names, self) @@ -976,8 +1407,10 @@ def remove_item_type(self): for item_name in item_names_to_delete: index = self.item_names.index(item_name) self.item_names.remove(item_name) + self.item_data_map.pop(item_name, None) self.tab_widget.removeTab(index) self.affixes_model.pop(index) + self._update_plus_tab_button() return def set_all_min_greater_affix(self): @@ -985,24 +1418,149 @@ def set_all_min_greater_affix(self): if dialog.exec() == QDialog.DialogCode.Accepted: min_greater_affix = dialog.get_value() for i in range(self.tab_widget.count()): - tab: AffixGroupEditor = self.tab_widget.widget(i) - if tab.auto_sync_checkbox.isChecked(): + if self.tab_widget.tabText(i) == "+": continue - tab.min_greater.setValue(min_greater_affix) - tab.update_min_greater_affix() + + tab = self.tab_widget.widget(i) + item_name = self.item_names[i] + + if isinstance(tab, AffixGroupEditor): + if tab.auto_sync_checkbox.isChecked(): + continue + tab.min_greater.setValue(min_greater_affix) + tab.update_min_greater_affix() + else: + # Placeholder: check settings for auto-sync status + if self.settings.value(f"auto_sync_ga_{item_name}", defaultValue=False, type=bool): + continue + self.item_data_map[item_name].root[item_name].min_greater_affix_count = min_greater_affix def convert_all_to_min_percent_of_affix(self): - current_tab = self.tab_widget.currentWidget() - if isinstance(current_tab, AffixGroupEditor): - dialog = MinPercentDialog(self) - if dialog.exec() == QDialog.DialogCode.Accepted: - current_tab.convert_all_to_min_percent_of_affix(dialog.get_value()) + dialog = MinPercentDialog(self) + if dialog.exec() == QDialog.DialogCode.Accepted: + percent = dialog.get_value() + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + continue + + tab = self.tab_widget.widget(i) + item_name = self.item_names[i] + + if isinstance(tab, AffixGroupEditor): + tab.convert_all_to_min_percent_of_affix(percent) + else: + # Placeholder: update the data model directly + config = self.item_data_map[item_name].root[item_name] + for pool in config.affix_pool: + for affix in pool.count: + affix.min_percent_of_affix = percent + affix.value = None def set_all_min_power(self): dialog = MinPowerDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: min_power = dialog.get_value() for i in range(self.tab_widget.count()): - tab: AffixGroupEditor = self.tab_widget.widget(i) - tab.min_power.setValue(min_power) - tab.update_min_power() + if self.tab_widget.tabText(i) == "+": + continue + + tab = self.tab_widget.widget(i) + item_name = self.item_names[i] + + if isinstance(tab, AffixGroupEditor): + tab.min_power.setValue(min_power) + tab.update_min_power() + else: + # Placeholder: Update the model directly + self.item_data_map[item_name].root[item_name].min_power = min_power + + def filter_by_item_types(self, item_types: list[ItemType] | None, slot_name: str | None = None): + """Show only tabs that match the provided item types.""" + if not hasattr(self, "tab_widget"): + return + self._current_slot_name = slot_name + self._current_slot_item_types = item_types + + # Normalize slot name for comparison (e.g., "Dual-Wield 1" -> "dualwield1") + slot_match_name = slot_name.lower().replace(" ", "").replace("-", "") if slot_name else None + is_rings = slot_match_name == "rings" + is_dw_all = slot_match_name == "dualwields" + is_ring_2 = slot_match_name == "ring2" + is_ring_1 = slot_match_name == "ring1" + is_dw_1 = slot_match_name == "dualwield1" + is_dw_2 = slot_match_name == "dualwield2" + is_dw_ranged = slot_match_name == "rangedweapon" + is_bludgeoning = slot_match_name == "bludgeoning" + is_slashing = slot_match_name == "slashing" + is_main_hand = slot_match_name == "mainhand" + type_names = [t.value.lower().replace(" ", "").replace("-", "") for t in item_types] if item_types else [] + + # Determine if we have any tabs that specifically match this slot's name. + # This allows us to separate slots like "Ring 1" and "Ring 2" if the user has rules for both. + has_exact_match = False + if slot_match_name: + for i in range(self.tab_widget.count()): + tab_text = self.tab_widget.tabText(i).lower().replace(" ", "").replace("-", "") + if ( + tab_text == slot_match_name + or (slot_match_name and slot_match_name in tab_text) + or (tab_text and tab_text in slot_match_name) + or (is_rings and "ring" in tab_text) + or (is_dw_all and "dualwield" in tab_text) + or (is_ring_1 and tab_text == "ring") + or (is_dw_1 and tab_text == "dualwield") + or (is_dw_2 and tab_text == "dualwield") + or (is_dw_ranged and tab_text == "ranged") + or ( + tab_text in type_names + and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged) + ) + or (is_main_hand and tab_text == "weapon") + ): + has_exact_match = True + break + + with QSignalBlocker(self.tab_widget): + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + self.tab_widget.setTabVisible(i, True) # noqa: FBT003 + continue + + item_name = self.item_names[i] + affix_group = self.item_data_map[item_name] + config = affix_group.root[item_name] + tab_text = self.tab_widget.tabText(i).lower().replace(" ", "").replace("-", "") + + type_match = not item_types or not config.item_type or any(t in config.item_type for t in item_types) + + if has_exact_match: + visible = type_match and ( + tab_text == slot_match_name + or (slot_match_name and slot_match_name in tab_text) + or (tab_text and tab_text in slot_match_name) + or (is_rings and "ring" in tab_text) + or (is_dw_all and "dualwield" in tab_text) + or (is_ring_1 and tab_text == "ring") + or (is_dw_1 and tab_text == "dualwield") + or (is_dw_2 and tab_text == "dualwield") + or (is_dw_ranged and tab_text == "ranged") + or ( + tab_text in type_names + and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged) + ) + or (is_main_hand and tab_text == "weapon") + ) + else: + visible = type_match + + if visible and not isinstance(self.tab_widget.widget(i), AffixGroupEditor): + self._ensure_tab_instantiated(i) + self.tab_widget.setTabVisible(i, visible) + + # Ensure a valid content tab is focused instead of the '+' tab + curr = self.tab_widget.currentIndex() + if curr == -1 or not self.tab_widget.isTabVisible(curr) or self.tab_widget.tabText(curr) == "+": + for i in range(self.tab_widget.count()): + if self.tab_widget.isTabVisible(i) and self.tab_widget.tabText(i) != "+": + self.tab_widget.setCurrentIndex(i) + break diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index 29d68db0..7bd0ec41 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -25,6 +25,7 @@ def setup_ui(self): label = QLabel( "Add any legendary aspects you'd like to have favorited if an upgrade is found. See the readme on AspectUpgrades for more information." ) + label.setWordWrap(True) main_layout.addWidget(label) button_layout = self.create_button_layout() main_layout.addLayout(button_layout) @@ -36,14 +37,17 @@ def setup_ui(self): def create_button_layout(self) -> QHBoxLayout: btn_layout = QHBoxLayout() - add_tribute_btn = QPushButton("Add Aspect") - add_tribute_btn.clicked.connect(self.add_aspect) + add_aspect_btn = QPushButton("Add Aspect") + add_aspect_btn.setFixedWidth(140) + add_aspect_btn.clicked.connect(self.add_aspect) - remove_tribute_btn = QPushButton("Remove Aspect") - remove_tribute_btn.clicked.connect(self.remove_aspect) + remove_aspect_btn = QPushButton("Remove Aspect") + remove_aspect_btn.setFixedWidth(140) + remove_aspect_btn.clicked.connect(self.remove_aspect) - btn_layout.addWidget(add_tribute_btn) - btn_layout.addWidget(remove_tribute_btn) + btn_layout.addWidget(add_aspect_btn) + btn_layout.addWidget(remove_aspect_btn) + btn_layout.addStretch() return btn_layout def add_aspect(self): @@ -54,6 +58,8 @@ def add_aspect(self): self.upgrade_list_widget.addItem(aspect_upgrade) def remove_aspect(self): + if not self.upgrade_list_widget.currentRow() >= 0: + return current_aspect = self.upgrade_list_widget.currentItem().text() self.aspect_upgrades.remove(current_aspect) self.upgrade_list_widget.takeItem(self.upgrade_list_widget.currentRow()) diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 6fbb4cc6..129ae6fe 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -1,60 +1,165 @@ -from PyQt6.QtCore import Qt +from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer from PyQt6.QtWidgets import ( + QCheckBox, QDialog, QFormLayout, QFrame, QGroupBox, + QHBoxLayout, + QLabel, QLineEdit, QPushButton, QScrollArea, + QSizePolicy, + QTabBar, QTabWidget, - QToolBar, - QToolButton, QVBoxLayout, QWidget, ) -from src.config.profile_models import GlobalUniqueModel +from src.config.profile_models import AffixFilterModel, AspectUniqueFilterModel, GlobalUniqueModel +from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER from src.gui.models.dialog import DeleteItem, IgnoreScrollWheelSpinBox +from src.gui.profile_editor.affixes_tab import ( + AffixSummaryWidget, + CharacterSpinBox, + ItemTypePicker, + UniqueAspectWidget, + _create_column_footer, + _create_column_header, + _create_summary_card_style, + _item_type_summary, +) +from src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon -UNIQUES_TABNAME = "GlobalUniques" +UNIQUES_TABNAME = "GlobalRules" class UniqueWidget(QWidget): def __init__(self, unique_model: GlobalUniqueModel, parent=None): super().__init__(parent) + self.settings = QSettings("d4lf", "profile_editor") self.unique_model = unique_model - + self.item_types = [ + item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item) + ] self.setup_ui() def setup_ui(self): - scroll_area = QScrollArea(self) - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - - content_widget = QWidget() - self.content_layout = QVBoxLayout(content_widget) + self.content_layout = QVBoxLayout(self) self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.setStyleSheet(_create_summary_card_style()) self.create_general_groupbox() - scroll_area.setWidget(content_widget) - self.main_layout = QVBoxLayout() - self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.main_layout.addWidget(scroll_area) - self.setLayout(self.main_layout) + # Rule Content + columns_layout = QHBoxLayout() + columns_layout.setSpacing(15) + + def create_col(title, add_cb, pool_model=None): + col_widget = QWidget() + col_layout = QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(0) + + header = _create_column_header(title, add_cb) + col_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; border-bottom: none; }" + ) + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(inner) + col_layout.addWidget(scroll) + + footer = None + if pool_model is not None: + footer = _create_column_footer(pool_model, self.update_greater_count_label) + footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + col_layout.addWidget(footer) + + return col_widget, inner_layout, footer + + # Init columns + col1_w, self.aspect_rows_layout, _ = create_col("Unique Aspects", self.add_unique_aspect) + col2_w, self.affix_pool_layout, self.affix_footer = create_col( + "Affix Pool", self.add_affix_pool, self.unique_model.affix_pool[0] + ) + col3_w, self.inherent_pool_layout, self.inherent_footer = create_col( + "Inherent Pool", self.add_inherent_pool, self.unique_model.inherent_pool[0] + ) + + columns_layout.addWidget(col1_w) + columns_layout.addWidget(col2_w) + columns_layout.addWidget(col3_w) + + self.content_layout.addLayout(columns_layout) + + # Initialize content + self.init_aspects() + self.init_affix_pool() + self.init_inherent_pool() + + def init_aspects(self): + for aspect in self.unique_model.unique_aspect: + self.add_unique_aspect_item(aspect) + + def init_affix_pool(self): + for affix in self.unique_model.affix_pool[0].count: + self.add_affix_item(affix, inherent=False) + + def init_inherent_pool(self): + for affix in self.unique_model.inherent_pool[0].count: + self.add_affix_item(affix, inherent=True) + + def add_affix_item(self, model: AffixFilterModel, inherent: bool = False): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + widget = AffixSummaryWidget(model) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent)) + widget.config_changed.connect(self.update_greater_count_label) + layout.addWidget(widget) + return widget + + def remove_affix_item_widget(self, widget, inherent: bool): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + pool = self.unique_model.inherent_pool[0] if inherent else self.unique_model.affix_pool[0] + idx = layout.indexOf(widget) + if idx != -1: + pool.count.pop(idx) + widget.setParent(None) + widget.deleteLater() + self.update_greater_count_label() def create_general_groupbox(self): self.general_groupbox = QGroupBox() self.general_groupbox.setTitle("Global Unique Rule") self.general_form = QFormLayout() + # Profile Alias / Name self.profile_alias = QLineEdit() self.profile_alias.setMaximumWidth(300) self.profile_alias.setText(self.unique_model.profile_alias) self.profile_alias.textChanged.connect(self.update_profile_alias) - self.general_form.addRow("Profile Alias:", self.profile_alias) + self.general_form.addRow("Rule Alias:", self.profile_alias) + + # Item Types (Slots) + self.item_type_line_edit = QLineEdit() + self.item_type_line_edit.setReadOnly(True) + self.refresh_item_type_summary() + item_type_layout = QHBoxLayout() + item_type_layout.addWidget(self.item_type_line_edit) + edit_item_types_btn = QPushButton("Select Slots") + edit_item_types_btn.setMaximumWidth(100) + edit_item_types_btn.clicked.connect(self.edit_item_types) + item_type_layout.addWidget(edit_item_types_btn) + self.general_form.addRow("Target Slots:", item_type_layout) self.min_power = IgnoreScrollWheelSpinBox() self.min_power.setRange(0, MAX_POWER) @@ -63,25 +168,107 @@ def create_general_groupbox(self): self.min_power.valueChanged.connect(self.update_min_power) self.general_form.addRow("Minimum Power:", self.min_power) - self.min_greater = IgnoreScrollWheelSpinBox() - self.min_greater.setRange(0, 4) - self.min_greater.setValue(self.unique_model.min_greater_affix_count) - self.min_greater.setMaximumWidth(150) - self.min_greater.valueChanged.connect(self.update_min_greater_affix) - self.general_form.addRow("Min Greater Affixes:", self.min_greater) + # Min Greater Affixes with Auto Sync + min_greater_layout = QHBoxLayout() + self.min_greater = CharacterSpinBox() + self.min_greater.set_range(0, 4) + self.min_greater.set_value(self.unique_model.min_greater_affix_count) + self.min_greater.setFixedWidth(100) + self.min_greater.value_changed.connect(self.update_min_greater_affix) - self.min_percent = IgnoreScrollWheelSpinBox() - self.min_percent.setRange(0, 100) - self.min_percent.setValue(self.unique_model.min_percent_of_aspect) - self.min_percent.setMaximumWidth(150) - self.min_percent.valueChanged.connect(self.update_min_percent) - self.general_form.addRow("Min Percent of Aspect:", self.min_percent) + self.auto_sync_checkbox = QCheckBox("Auto Sync") + self.auto_sync_checkbox.setChecked( + self.settings.value(f"auto_sync_ga_global_{self.unique_model.profile_alias}", defaultValue=False, type=bool) + ) + self.auto_sync_checkbox.stateChanged.connect(self.toggle_auto_sync) + self.greater_count_label = QLabel() + self.greater_count_label.setStyleSheet("color: gray; font-style: italic;") + + min_greater_layout.addWidget(self.min_greater) + min_greater_layout.addWidget(self.auto_sync_checkbox) + min_greater_layout.addWidget(self.greater_count_label) + min_greater_layout.addStretch() + + self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked()) + self.general_form.addRow("Min Greater Affixes:", self.min_greater) self.general_groupbox.setLayout(self.general_form) self.content_layout.addWidget(self.general_groupbox) + QTimer.singleShot(100, self.update_greater_count_label) + + def add_unique_aspect_item(self, model: AspectUniqueFilterModel) -> UniqueAspectWidget: + widget = UniqueAspectWidget(model) + widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget)) + self.aspect_rows_layout.addWidget(widget) + return widget + + def add_unique_aspect(self): + aspect_name = next(iter(Dataloader().aspect_unique_dict.keys())) + new_aspect = AspectUniqueFilterModel(name=aspect_name) + self.unique_model.unique_aspect.append(new_aspect) + self.add_unique_aspect_item(new_aspect).open_config_dialog() + + def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): + if widget.unique_aspect in self.unique_model.unique_aspect: + self.unique_model.unique_aspect.remove(widget.unique_aspect) + widget.setParent(None) + widget.deleteLater() + + def add_affix_pool(self): + affix_name = next(iter(Dataloader().affix_dict.keys())) + new_affix = AffixFilterModel(name=affix_name) + self.unique_model.affix_pool[0].count.append(new_affix) + self.add_affix_item(new_affix).open_config_dialog() + + def add_inherent_pool(self): + affix_name = next(iter(Dataloader().affix_dict.keys())) + new_affix = AffixFilterModel(name=affix_name) + self.unique_model.inherent_pool[0].count.append(new_affix) + self.add_affix_item(new_affix, inherent=True).open_config_dialog() + + def toggle_auto_sync(self): + is_auto = self.auto_sync_checkbox.isChecked() + self.settings.setValue(f"auto_sync_ga_global_{self.unique_model.profile_alias}", is_auto) + self.min_greater.setEnabled(not is_auto) + if is_auto: + self.update_greater_count_label() + + def update_greater_count_label(self): + count = 0 + # Count in affix pools + for pool in self.unique_model.affix_pool: + for affix in pool.count: + if getattr(affix, "want_greater", False): + count += 1 + + if count == 0: + self.greater_count_label.setText("(no greater affixes marked)") + else: + self.greater_count_label.setText(f"({count} GAs required)") + if self.auto_sync_checkbox.isChecked(): + with QSignalBlocker(self.min_greater): + self.min_greater.set_value(count) + + def refresh_item_type_summary(self): + self.item_type_line_edit.setText(_item_type_summary(self.unique_model.item_type)) + + def edit_item_types(self): + picker = ItemTypePicker(self, self.item_types, self.unique_model.item_type) + if picker.exec() == QDialog.DialogCode.Accepted: + self.unique_model.item_type = picker.get_selected_item_types() + self.refresh_item_type_summary() def update_profile_alias(self, value: str): self.unique_model.profile_alias = value.strip() + self.update_parent_tab_text() + + def update_parent_tab_text(self): + p = self.parent() + while p: + if type(p).__name__ == "UniquesTab": + p.rename_tabs() + break + p = p.parent() def update_min_power(self): self.unique_model.min_power = self.min_power.value() @@ -97,7 +284,10 @@ class UniquesTab(QWidget): def __init__(self, unique_model_list: list[GlobalUniqueModel], parent=None): super().__init__(parent) self.unique_model_list = unique_model_list + self._current_slot_name = "" + self._current_slot_item_types = [] self.loaded = False + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): if not self.loaded: @@ -106,37 +296,171 @@ def load(self): def setup_ui(self): self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 20, 0, 20) + self.main_layout.setContentsMargins(0, 5, 0, 5) self.tab_widget = QTabWidget(self) - self.tab_widget.setTabsClosable(True) - self.tab_widget.tabCloseRequested.connect(self.close_tab) - - self.add_button = QToolButton() - self.add_button.setText("+") - self.add_button.clicked.connect(self.add_item_type) - - self.tab_widget.setCornerWidget(self.add_button) - self.toolbar = QToolBar("MyToolBar", self) - self.toolbar.setMinimumHeight(50) - self.toolbar.setContentsMargins(10, 10, 10, 10) - self.toolbar.setMovable(False) - for i, unique_model in enumerate(self.unique_model_list): - group = UniqueWidget(unique_model) - self.tab_widget.addTab(group, f"Unique Rule {i}") - - add_item_button = QPushButton("Create Rule") - remove_item_button = QPushButton("Remove Rule") - add_item_button.clicked.connect(self.add_item_type) - remove_item_button.clicked.connect(self.remove_item_type) - self.toolbar.addWidget(add_item_button) - self.toolbar.addWidget(remove_item_button) - self.main_layout.addWidget(self.toolbar) + with QSignalBlocker(self.tab_widget): + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self.close_tab) + self.tab_widget.currentChanged.connect(self._on_tab_changed) + self.tab_widget.tabBar().tabBarClicked.connect(self._on_tab_bar_clicked) + + # Add a persistent "+" tab at the end + self.tab_widget.addTab(QWidget(), "+") + + for i, unique_model in enumerate(self.unique_model_list): + self.tab_widget.insertTab( + self.tab_widget.count() - 1, QWidget(), unique_model.profile_alias or f"Rule {i}" + ) + + self._update_plus_tab_button() + self.main_layout.addWidget(self.tab_widget) + def _on_tab_changed(self, index): + if index >= 0 and self.tab_widget.tabText(index) == "+": + self.add_item_type() + + def _on_tab_bar_clicked(self, index): + # This handles clicking the "+" tab when it's already selected + if index >= 0 and self.tab_widget.tabText(index) == "+" and self.tab_widget.currentIndex() == index: + self.add_item_type() + + def _update_plus_tab_button(self): + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + self.tab_widget.tabBar().setTabButton(i, QTabBar.ButtonPosition.RightSide, None) + self.tab_widget.setTabToolTip(i, "Create Rule") + def close_tab(self, index): - self.tab_widget.removeTab(index) - self.unique_model_list.pop(index) + if self.tab_widget.tabText(index) == "+": + return + with QSignalBlocker(self.tab_widget): + self.tab_widget.removeTab(index) + self.unique_model_list.pop(index) self.rename_tabs() + self._update_plus_tab_button() + + def filter_by_item_types(self, item_types: list[ItemType] | None, slot_name: str | None = None): + """Show only tabs that match the provided item types.""" + if not hasattr(self, "tab_widget"): + return + self._current_slot_name = slot_name + self._current_slot_item_types = item_types + + with QSignalBlocker(self.tab_widget): + if slot_name is None: # Global Rules view + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + self.tab_widget.setTabVisible(i, True) # noqa: FBT003 + continue + self._ensure_tab_instantiated(i) + self.tab_widget.setTabVisible(i, True) # noqa: FBT003 + return + + slot_match_name = slot_name.lower().replace(" ", "").replace("-", "") if slot_name else None + is_rings = slot_match_name == "rings" + is_dw_all = slot_match_name == "dualwields" + is_ring_2 = slot_match_name == "ring2" + is_ring_1 = slot_match_name == "ring1" + is_dw_1 = slot_match_name == "dualwield1" + is_dw_2 = slot_match_name == "dualwield2" + is_dw_ranged = slot_match_name == "rangedweapon" + is_bludgeoning = slot_match_name == "bludgeoning" + is_slashing = slot_match_name == "slashing" + is_main_hand = slot_match_name == "mainhand" + type_names = [t.value.lower().replace(" ", "").replace("-", "") for t in item_types] if item_types else [] + + # Check for exact matches in rule aliases/names + has_exact_match = False + if slot_match_name: + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + continue + model = self.unique_model_list[i] + alias = model.profile_alias.lower().replace(" ", "").replace("-", "") + if ( + alias == slot_match_name + or (slot_match_name and slot_match_name in alias) + or (alias and alias in slot_match_name) + or (is_rings and "ring" in alias) + or (is_dw_all and "dualwield" in alias) + or (is_ring_1 and alias == "ring") + or (is_dw_1 and alias == "dualwield") + or (is_dw_2 and alias == "dualwield") + or (is_dw_ranged and alias == "ranged") + or ( + alias in type_names + and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged) + ) + or (is_main_hand and alias == "weapon") + ): + has_exact_match = True + break + + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + self.tab_widget.setTabVisible(i, True) # noqa: FBT003 + continue + + model = self.unique_model_list[i] + alias = model.profile_alias.lower().replace(" ", "").replace("-", "") + rule_types = getattr(model, "item_type", []) + type_match = not item_types or not rule_types or any(t in rule_types for t in item_types) + + if has_exact_match: + visible = type_match and ( + alias == slot_match_name + or (slot_match_name and slot_match_name in alias) + or (alias and alias in slot_match_name) + or (is_rings and "ring" in alias) + or (is_dw_all and "dualwield" in alias) + or (is_ring_1 and alias == "ring") + or (is_dw_1 and alias == "dualwield") + or (is_dw_2 and alias == "dualwield") + or (is_dw_ranged and alias == "ranged") + or ( + alias in type_names + and not (is_ring_2 or is_dw_2 or is_dw_1 or is_bludgeoning or is_slashing or is_dw_ranged) + ) + or (is_main_hand and alias == "weapon") + ) + else: + visible = type_match + + if visible: + self._ensure_tab_instantiated(i) + self.tab_widget.setTabVisible(i, visible) + + # Ensure a valid content tab is focused instead of the '+' tab + curr = self.tab_widget.currentIndex() + if curr == -1 or not self.tab_widget.isTabVisible(curr) or self.tab_widget.tabText(curr) == "+": + for i in range(self.tab_widget.count()): + if self.tab_widget.isTabVisible(i) and self.tab_widget.tabText(i) != "+": + self.tab_widget.setCurrentIndex(i) + break + + def _ensure_tab_instantiated(self, index: int): + if index < 0 or index >= self.tab_widget.count(): + return + if not isinstance(self.tab_widget.widget(index), UniqueWidget): + # Find the correct model by counting non-plus tabs before this one + model_idx = 0 + for i in range(index): + if self.tab_widget.tabText(i) != "+": + model_idx += 1 + + if model_idx >= len(self.unique_model_list): + return + + model = self.unique_model_list[model_idx] + widget = UniqueWidget(model) + name = self.tab_widget.tabText(index) + is_current = self.tab_widget.currentIndex() == index + with QSignalBlocker(self.tab_widget): + self.tab_widget.removeTab(index) + self.tab_widget.insertTab(index, widget, name) + if is_current: + self.tab_widget.setCurrentIndex(index) def remove_item_type(self): dialog = DeleteItem([self.tab_widget.tabText(i) for i in range(self.tab_widget.count())], self) @@ -150,14 +474,32 @@ def remove_item_type(self): self.tab_widget.removeTab(index) self.unique_model_list.pop(index) self.rename_tabs() + self._update_plus_tab_button() return def rename_tabs(self): for i in range(self.tab_widget.count()): - self.tab_widget.setTabText(i, f"Unique Rule {i}") + if self.tab_widget.tabText(i) == "+": + continue + model = self.unique_model_list[i] + self.tab_widget.setTabText(i, model.profile_alias or f"Rule {i}") def add_item_type(self): - unique_model = GlobalUniqueModel() + item_types = self._current_slot_item_types or [] + plus_idx = -1 + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + plus_idx = i + break + + # Switch to previous tab if we were triggered by clicking the "+" tab + if self.tab_widget.currentIndex() == plus_idx and plus_idx > 0: + self.tab_widget.setCurrentIndex(plus_idx - 1) + + alias = f"New Rule {self.tab_widget.count()}" + unique_model = GlobalUniqueModel(item_type=item_types, profileAlias=alias) group = UniqueWidget(unique_model) - self.tab_widget.addTab(group, f"Unique Rule {self.tab_widget.count()}") + self.tab_widget.insertTab(plus_idx, group, alias) self.unique_model_list.append(unique_model) + self.tab_widget.setCurrentIndex(plus_idx) + self._update_plus_tab_button() diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py new file mode 100644 index 00000000..b1a6b480 --- /dev/null +++ b/src/gui/profile_editor/paper_doll.py @@ -0,0 +1,440 @@ +"""Paper doll equipment layout for the profile editor.""" + +from typing import override + +from PyQt6.QtCore import QRect, QSize, Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QPainter, QPen +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +from src.item.data.item_type import ItemType + +# Icon mapping for slots using common unicode symbols +SLOT_ICONS = { + "Helm": "🪖", + "Chest Armor": "👕", + "Gloves": "🧤", + "Pants": "👖", + "Boots": "👢", + "Amulet": "📿", + "Rings": "💍", + "Main Hand": "⚔️", + "Off Hand": "🛡️", + "Bludgeoning": "🔨", + "Slashing": "🪓", + "Dual-Wield 1": "🗡️", + "Dual-Wield 2": "⚔️", + "Dual Wields": "⚔️", + "Ranged Weapon": "🏹", + "Aspect Upgrades": "✨", + "Sigils": "📜", + "Tributes": "🏆", + "Global Rules": "💎", +} + +# Base gear slots common to all classes +BASE_GEAR_SLOTS = [ + # Left Column (Gear) + ("Helm", [ItemType.Helm], QRect(70, 10, 145, 60)), + ("Chest Armor", [ItemType.ChestArmor], QRect(70, 80, 145, 60)), + ("Gloves", [ItemType.Gloves], QRect(70, 150, 145, 60)), + ("Pants", [ItemType.Legs], QRect(70, 220, 145, 60)), + ("Boots", [ItemType.Boots], QRect(70, 290, 145, 60)), + # Right Column (Jewelry) + ("Amulet", [ItemType.Amulet], QRect(525, 10, 145, 60)), + ("Rings", [ItemType.Ring], QRect(525, 80, 145, 60)), +] + + +def get_weapon_slots(class_name: str | None = None) -> list[tuple[str, list[ItemType], QRect]]: + """Return weapon slot definitions based on character class.""" + class_name = (class_name or "").lower() + + # 1H Weapon types for dual wielding + one_hand_types = [ItemType.Axe, ItemType.Mace, ItemType.Sword, ItemType.Dagger, ItemType.Flail] + + if "barbarian" in class_name: + return [ + ("Bludgeoning", [ItemType.Mace2H], QRect(525, 150, 145, 60)), + ("Slashing", [ItemType.Axe2H, ItemType.Sword2H, ItemType.Polearm], QRect(525, 220, 145, 60)), + ("Dual Wields", one_hand_types, QRect(525, 290, 145, 60)), + ] + + if "rogue" in class_name or "rog" in class_name: + return [ + ("Dual Wields", [ItemType.Dagger, ItemType.Sword], QRect(525, 290, 145, 60)), + ("Ranged Weapon", [ItemType.Bow, ItemType.Crossbow2H], QRect(525, 220, 145, 60)), + ] + + if "necromancer" in class_name or "necro" in class_name: + return [ + ( + "Main Hand", + [ + ItemType.Scythe, + ItemType.Scythe2H, + ItemType.Sword, + ItemType.Sword2H, + ItemType.Dagger, + ItemType.Wand, + ItemType.Mace, + ], + QRect(525, 220, 145, 60), + ), + ("Off Hand", [ItemType.Shield, ItemType.Focus], QRect(525, 290, 145, 60)), + ] + + if "druid" in class_name or "dru" in class_name: + return [ + ( + "Main Hand", + [ItemType.Mace, ItemType.Mace2H, ItemType.Axe, ItemType.Axe2H, ItemType.Staff, ItemType.Polearm], + QRect(525, 220, 145, 60), + ), + ("Off Hand", [ItemType.OffHandTotem], QRect(525, 290, 145, 60)), + ] + + if any(c in class_name for c in ["sorcerer", "sorc", "warlock"]): + return [ + ("Main Hand", [ItemType.Wand, ItemType.Dagger, ItemType.Staff], QRect(525, 220, 145, 60)), + ("Off Hand", [ItemType.Focus], QRect(525, 290, 145, 60)), + ] + + if "spiritborn" in class_name or "spirit" in class_name: + return [ + ("Main Hand", [ItemType.Glaive, ItemType.Quarterstaff, ItemType.Polearm], QRect(525, 220, 145, 60)), + # Spiritborn typically uses 2H or Dual 1H (handled in Main/Off logic if needed) + ("Off Hand", [], QRect(525, 290, 145, 60)), + ] + + # Default Fallback + return [ + ( + "Main Hand", + [ + ItemType.Axe, + ItemType.Axe2H, + ItemType.Bow, + ItemType.Crossbow2H, + ItemType.Dagger, + ItemType.Flail, + ItemType.Glaive, + ItemType.Mace, + ItemType.Mace2H, + ItemType.Polearm, + ItemType.Quarterstaff, + ItemType.Scythe, + ItemType.Scythe2H, + ItemType.Staff, + ItemType.Sword, + ItemType.Sword2H, + ItemType.Wand, + ], + QRect(525, 220, 145, 60), + ), + ("Off Hand", [ItemType.Shield, ItemType.Focus, ItemType.OffHandTotem, ItemType.Tome], QRect(525, 290, 145, 60)), + ] + + +# Compatibility export for logic that expects a single list +EQUIPMENT_SLOTS = BASE_GEAR_SLOTS + get_weapon_slots() + +SPECIAL_TABS = ["Aspect Upgrades", "Sigils", "Tributes", "Global Rules"] + + +class EquipmentSlotButton(QFrame): + """A clickable equipment slot button for the paper doll.""" + + clicked = pyqtSignal() + + def __init__(self, slot_name: str, item_types: list[ItemType], rect: QRect, parent: QWidget | None = None): + super().__init__(parent) + self.slot_name = slot_name + self.item_types = item_types + self._slot_rect = rect # Use _slot_rect to avoid shadowing QWidget.rect property + self._has_config = False + self._is_active = False + self._icon = SLOT_ICONS.get(slot_name, "") + + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setStyleSheet( + "EquipmentSlotButton {" + " border: 2px solid #4a5568;" + " border-radius: 8px;" + " background-color: #1e293b;" + " color: #94a3b8;" + " font-size: 11px;" + " font-weight: bold;" + " padding: 4px;" + " text-align: center;" + "}" + "EquipmentSlotButton:hover {" + " border-color: #3b82f6;" + " background-color: #2d3748;" + "}" + "EquipmentSlotButton.active {" + " border: 2px solid #3b82f6;" + " background-color: #1e3a5f;" + " color: #e2e8f0;" + "}" + ) + + def set_active(self, active: bool) -> None: + self._is_active = active + self.update() + + def has_config(self, has: bool) -> None: + self._has_config = has + self.update() + + @override + def paintEvent(self, event): # type: ignore[override] + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Use local rect (0,0,W,H) instead of geometry (X,Y,W,H) relative to parent + r = self.rect() + + # Background + bg_color = QColor(30, 58, 95) if self._is_active else QColor(30, 41, 59) + + painter.fillRect(r, bg_color) + + # Border + pen_width = max(1, int(2 * (self.width() / 85.0))) if self.width() < 120 else 2 + pen = QPen(QColor(74, 85, 104) if not self._is_active else QColor(59, 130, 246), pen_width) + painter.setPen(pen) + painter.drawRoundedRect(r.adjusted(1, 1, -1, -1), 6, 6) + + h = self.height() + w = self.width() + # Relative scaling for fonts (reference width is 145px) + s = w / 145.0 + base_icon, base_text = 18, 10 + + # Slot Icon + painter.setPen(QColor(148, 163, 184) if not self._is_active else QColor(226, 232, 240)) + icon_font = QFont("Segoe UI Emoji", max(6, int(base_icon * s))) + painter.setFont(icon_font) + painter.drawText(r.adjusted(0, int(h * 0.05), 0, -int(h * 0.50)), Qt.AlignmentFlag.AlignCenter, self._icon) + + # Slot name + painter.setPen(QColor(148, 163, 184) if not self._is_active else QColor(226, 232, 240)) + font = QFont("Segoe UI", max(6, int(base_text * s)), QFont.Weight.Medium) + painter.setFont(font) + painter.drawText( + r.adjusted(2, int(h * 0.50), -2, -int(h * 0.05)), + Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, + self.slot_name, + ) + + # Config indicator (dot in corner if has config) + if self._has_config: + painter.setPen(QColor(34, 197, 94)) + painter.setBrush(QColor(34, 197, 94)) + painter.drawEllipse(self.width() - 12, 4, 8, 8) + + painter.end() + + @override + def mousePressEvent(self, event): # type: ignore[override] + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + @override + def sizeHint(self) -> QSize: + return QSize(self._slot_rect.width(), self._slot_rect.height()) + + +class CharacterCanvas(QFrame): + """Canvas for drawing the character silhouette and slot buttons.""" + + resized = pyqtSignal() + REF_WIDTH = 740 + REF_HEIGHT = 510 + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setMinimumSize(370, 255) + self.setStyleSheet("QFrame { background-color: #0f172a; border: none;}") + + @override + def resizeEvent(self, event): + super().resizeEvent(event) + self.resized.emit() + + @override + def paintEvent(self, event): # type: ignore[override] + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Draw a simple character silhouette outline + center_x = self.width() / 2 + sx = self.width() / self.REF_WIDTH + sy = self.height() / self.REF_HEIGHT + + # Use a slightly lighter, more muted color for the silhouette + # and thicker lines for better visibility without being too stark. + pen = QPen(QColor(100, 116, 139), max(1, int(2 * min(sx, sy)))) + painter.setPen(pen) + + # Head circle + painter.drawEllipse(int(center_x - 25 * sx), int(10 * sy), int(50 * sx), int(50 * sy)) + + # Torso (more of a rectangle now) + painter.drawRect(int(center_x - 20 * sx), int(60 * sy), int(40 * sx), int(100 * sy)) + + # Pelvis/Hips (a wider, shorter rectangle) + painter.drawRect(int(center_x - 30 * sx), int(160 * sy), int(60 * sx), int(20 * sy)) + + # Arms + painter.drawLine(int(center_x - 20 * sx), int(80 * sy), int(center_x - 80 * sx), int(150 * sy)) + painter.drawLine(int(center_x + 20 * sx), int(80 * sy), int(center_x + 80 * sx), int(150 * sy)) + + # Legs + painter.drawLine(int(center_x - 20 * sx), int(180 * sy), int(center_x - 40 * sx), int(370 * sy)) + painter.drawLine(int(center_x + 20 * sx), int(180 * sy), int(center_x + 40 * sx), int(370 * sy)) + + painter.end() + + +class PaperDollWidget(QWidget): + """A paper doll character layout with clickable equipment slots.""" + + slot_clicked = pyqtSignal(str) # Emits slot_name when clicked + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._active_slot: str | None = None + self._slot_buttons: dict[str, EquipmentSlotButton] = {} + self._has_config_map: dict[str, bool] = {} + + self.setup_ui() + + def setup_ui(self): + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Character silhouette panel (left side) - fill remaining space + self.character_panel = QFrame() + self.character_panel.setStyleSheet("QFrame { background-color: #0f172a; border-right: 1px solid #1e293b;}") + self.character_panel.setMaximumWidth(800) + char_layout = QVBoxLayout(self.character_panel) + char_layout.setContentsMargins(20, 10, 20, 20) + char_layout.setSpacing(5) + char_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Special Navigation Tabs at the Top + self.special_nav_layout = QHBoxLayout() + self.special_nav_layout.setSpacing(15) + for name in SPECIAL_TABS: + btn = EquipmentSlotButton(name, [], QRect(0, 0, 145, 60)) + btn.clicked.connect(lambda n=name: self._on_slot_clicked(n)) + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self._slot_buttons[name] = btn + self.special_nav_layout.addWidget(btn) + char_layout.addLayout(self.special_nav_layout) + + title_label = QLabel("Equipment") + title_label.setProperty("titleLabel", True) # noqa: FBT003 + title_label.setStyleSheet("QLabel { color: #e2e8f0; font-size: 18px; font-weight: bold; padding: 8px;}") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + char_layout.addWidget(title_label) + + # Character silhouette canvas + self.character_canvas = CharacterCanvas() + self.character_canvas.resized.connect(self.position_slots) + char_layout.addWidget(self.character_canvas) + + main_layout.addWidget(self.character_panel, stretch=0) + + # Side panel (right side) - initially shows placeholder + self.side_panel = QFrame() + self.side_panel.setStyleSheet("QFrame { background-color: #1e293b; border-left: 1px solid #334155;}") + side_layout = QVBoxLayout(self.side_panel) + side_layout.setContentsMargins(20, 10, 20, 20) + + self.show_message("Select an equipment slot to configure") + + main_layout.addWidget(self.side_panel, stretch=1) + self.side_panel.hide() + + def show_message(self, text: str) -> None: + """Clear side panel and show a message label.""" + self._clear_layout() + + placeholder = QLabel(text) + placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + placeholder.setWordWrap(True) + placeholder.setStyleSheet("QLabel { color: #64748b; font-size: 14px; padding: 40px;}") + self.side_panel.layout().addWidget(placeholder) + self.side_panel.layout().addStretch() + + def set_active_slot(self, slot_name: str | None) -> None: + """Set the currently active equipment slot.""" + self._active_slot = slot_name + for name, button in self._slot_buttons.items(): + button.set_active(name == slot_name) + if slot_name: + self.slot_clicked.emit(slot_name) + + def update_config_status(self, slot_name: str, has_config: bool) -> None: + """Update the config indicator for a slot.""" + self._has_config_map[slot_name] = has_config + if slot_name in self._slot_buttons: + self._slot_buttons[slot_name].has_config(has_config) + + def add_slot(self, slot_name: str, item_types: list[ItemType], rect: QRect) -> None: + """Add an equipment slot button.""" + button = EquipmentSlotButton(slot_name, item_types, rect, self.character_canvas) + button.clicked.connect(lambda: self._on_slot_clicked(slot_name)) # type: ignore[misc] + self._slot_buttons[slot_name] = button + + def position_slots(self) -> None: + """Position all slot buttons on the character canvas based on their defined rects and current canvas size.""" + if not self.character_canvas.width() or not self.character_canvas.height(): + return + + sx = self.character_canvas.width() / 740.0 + sy = self.character_canvas.height() / 510.0 + + for button in self._slot_buttons.values(): + if button.parent() == self.character_canvas: + # Use the rect stored internally during add_slot + rect = button._slot_rect + button.setGeometry( + int(rect.x() * sx), int(rect.y() * sy), int(rect.width() * sx), int(rect.height() * sy) + ) + button.show() + + def _on_slot_clicked(self, slot_name: str): + if self._active_slot == slot_name: + self.set_active_slot(None) + self.slot_clicked.emit(None) + else: + self.set_active_slot(slot_name) + self.slot_clicked.emit(slot_name) + + def _clear_layout(self) -> None: + """Remove items from the side panel layout without necessarily deleting widgets.""" + while self.side_panel.layout().count() > 0: + item = self.side_panel.layout().takeAt(0) + if item and item.widget(): + item.widget().hide() + + def clear_side_panel(self) -> None: + """Reset side panel to default placeholder.""" + self.side_panel.hide() + + def restore_side_panel(self, widget: QWidget) -> None: + """Restore a previously hidden widget to the side panel.""" + self._clear_layout() + self.side_panel.layout().addWidget(widget) + widget.show() + self.side_panel.show() + + +# Re-export for convenience +__all__ = ["EQUIPMENT_SLOTS", "CharacterCanvas", "EquipmentSlotButton", "PaperDollWidget"] diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 67d51fd8..febd72c5 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -1,100 +1,256 @@ +"""Profile editor with paper doll layout.""" + import logging -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QMessageBox, QTabWidget +from PyQt6.QtCore import QTimer, pyqtSignal +from PyQt6.QtWidgets import ( + QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) from src.config.profile_models import ProfileModel from src.gui.importer.gui_common import save_as_profile -from src.gui.profile_editor.affixes_tab import AFFIXES_TABNAME, AffixesTab -from src.gui.profile_editor.aspect_upgrades_tab import ASPECT_UPGRADES_TABNAME, AspectUpgradesTab -from src.gui.profile_editor.global_uniques_tab import UNIQUES_TABNAME, UniquesTab -from src.gui.profile_editor.sigils_tab import SIGILS_TABNAME, SigilsTab -from src.gui.profile_editor.tributes_tab import TRIBUTES_TABNAME, TributesTab +from src.gui.profile_editor.affixes_tab import AffixesTab +from src.gui.profile_editor.aspect_upgrades_tab import AspectUpgradesTab +from src.gui.profile_editor.global_uniques_tab import UniquesTab +from src.gui.profile_editor.paper_doll import BASE_GEAR_SLOTS, PaperDollWidget, get_weapon_slots +from src.gui.profile_editor.sigils_tab import SigilsTab +from src.gui.profile_editor.tributes_tab import TributesTab LOGGER = logging.getLogger(__name__) -class ProfileEditor(QTabWidget): +class ProfileEditor(QWidget): + """Profile editor with paper doll layout and side panel for editing.""" + # Signal emitted when profile is saved (passes profile name) profile_saved = pyqtSignal(str) - def __init__(self, profile_model: ProfileModel, parent=None): + def __init__(self, profile_model: ProfileModel, parent: QWidget | None = None): super().__init__(parent) - self.profile_model = profile_model - # Create main tabs - self.affixes_tab = AffixesTab(self.profile_model.affixes) - self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades) - self.sigils_tab = SigilsTab(self.profile_model.sigils) - self.tributes_tab = TributesTab(self.profile_model.tributes) - self.uniques_tab = UniquesTab(self.profile_model.global_uniques) - - self.currentChanged.connect(self.tab_changed) - # Add tabs with icons - self.addTab(self.affixes_tab, AFFIXES_TABNAME) - self.addTab(self.aspect_upgrades_tab, ASPECT_UPGRADES_TABNAME) - self.addTab(self.sigils_tab, SIGILS_TABNAME) - self.addTab(self.tributes_tab, TRIBUTES_TABNAME) - self.addTab(self.uniques_tab, UNIQUES_TABNAME) - - # Configure tab widget properties - self.setDocumentMode(True) - self.setMovable(False) - self.setTabPosition(QTabWidget.TabPosition.North) - self.setElideMode(Qt.TextElideMode.ElideRight) - - def tab_changed(self, index): - if self.tabText(index) == AFFIXES_TABNAME: + + # Create all tab widgets upfront (lazy-loaded internally) + self.affixes_tab = AffixesTab(self.profile_model.affixes, self) + self.aspect_upgrades_tab = AspectUpgradesTab(self.profile_model.aspect_upgrades, self) + self.sigils_tab = SigilsTab(self.profile_model.sigils, self) + self.tributes_tab = TributesTab(self.profile_model.tributes, self) + self.uniques_tab = UniquesTab(self.profile_model.global_uniques, self) + + # Side panel content widget (swaps based on slot selection) + self.side_content_widget: QWidget | None = None + + self.current_class = self._detect_class() + + # Build the UI + self.setup_ui() + + # Reset window expansion state on load to prevent accumulation when switching profiles + QTimer.singleShot(50, lambda: self._adjust_window_size(expanding=False)) + + def _detect_class(self) -> str: + """Return the character class defined in the profile model.""" + return self.profile_model.class_name.lower() + + def setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Paper doll widget (left side with clickable slots) + self.paper_doll = PaperDollWidget() + + # Add base gear slots + for slot_name, item_types, rect in BASE_GEAR_SLOTS: + self.paper_doll.add_slot(slot_name, item_types, rect) + + # Add dynamic weapon slots based on class + self.weapon_slots = get_weapon_slots(self.current_class) + for slot_name, item_types, rect in self.weapon_slots: + self.paper_doll.add_slot(slot_name, item_types, rect) + + # Position all slot buttons on the canvas + self.paper_doll.position_slots() + + # Add Bulk Actions at bottom of armory + actions_group = QGroupBox("Profile-Wide Actions") + actions_layout = QHBoxLayout(actions_group) + actions_layout.setContentsMargins(10, 15, 10, 10) + + btn_min_ga = QPushButton("Set Min GAs") + btn_min_ga.setToolTip("Set the Minimum Greater Affix requirement for every legendary filter in this profile.") + btn_min_ga.clicked.connect(self.affixes_tab.set_all_min_greater_affix) + + btn_min_power = QPushButton("Set minPower") + btn_min_power.setToolTip( + "Set the Minimum Power threshold (e.g. 900) for every legendary filter in this profile." + ) + btn_min_power.clicked.connect(self.affixes_tab.set_all_min_power) + + btn_to_percent = QPushButton("Convert to Min %") + btn_to_percent.setToolTip( + "Convert every legendary filter in this profile to use 'Min %' mode instead of fixed values." + ) + btn_to_percent.clicked.connect(self.affixes_tab.convert_all_to_min_percent_of_affix) + + for btn in [btn_min_ga, btn_min_power, btn_to_percent]: + btn.setFixedHeight(32) + actions_layout.addWidget(btn) + + # Insert into the paper doll panel's vertical layout (after the canvas) + self.paper_doll.character_panel.layout().addWidget(actions_group) + + # Pre-create integrated gear view components to avoid heavy construction on every click + self.gear_view_scroll = QScrollArea() + self.gear_view_scroll.setWidgetResizable(True) + self.gear_view_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.gear_view_container = QWidget() + self.gear_view_layout = QVBoxLayout(self.gear_view_container) + self.gear_view_layout.setContentsMargins(0, 0, 10, 0) + + self.gear_view_header = QLabel() + self.gear_view_header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px;") + self.gear_view_layout.addWidget(self.gear_view_header) + self.gear_view_layout.addWidget(self.affixes_tab) + self.gear_view_layout.addWidget(self.uniques_tab) + self.gear_view_scroll.setWidget(self.gear_view_container) + + # Connect slot click signal + self.paper_doll.slot_clicked.connect(self.on_slot_clicked) + + main_layout.addWidget(self.paper_doll) + + def _update_equilibrium_config_status(self): + """Update the config status indicators on equipment slots.""" + # TODO: Implement logic to check which items in self.profile_model.affixes match slot types + has_affix_config = len(self.profile_model.affixes) > 0 + self.paper_doll.update_config_status("Equipment", has_affix_config) + + def on_slot_clicked(self, slot_name: str): + """Handle equipment slot click - show relevant tab in side panel.""" + self.side_content_widget = None + item_types = None + + if not slot_name: + # Hide all content widgets and show placeholder + for widget in [ + self.affixes_tab, + self.aspect_upgrades_tab, + self.sigils_tab, + self.tributes_tab, + self.uniques_tab, + ]: + widget.hide() + self.paper_doll.clear_side_panel() + self._adjust_window_size(expanding=False) + return + + # Find item types for the clicked slot + all_equipment = BASE_GEAR_SLOTS + self.weapon_slots + slot_info = next((s for s in all_equipment if s[0] == slot_name), None) + item_types = slot_info[1] if slot_info else None + + # Determine if it's a gear/weapon slot (Affixes + Unique Rules) + is_gear_slot = any(s[0] == slot_name for s in all_equipment) + + if is_gear_slot: + # Ensure children are loaded before filtering self.affixes_tab.load() - elif self.tabText(index) == ASPECT_UPGRADES_TABNAME: + # Ensure gear view components are visible (they might have been hidden by Global Rules) + self.affixes_tab.show() + self.uniques_tab.hide() + self.gear_view_header.show() + + # Filter both tabs for this specific slot + self.affixes_tab.filter_by_item_types(item_types, slot_name) + + self.gear_view_header.setText(f"Slot: {slot_name}") + self.side_content_widget = self.gear_view_scroll + elif slot_name == "Aspect Upgrades": self.aspect_upgrades_tab.load() - elif self.tabText(index) == SIGILS_TABNAME: + self.aspect_upgrades_tab.show() + self.side_content_widget = self.aspect_upgrades_tab + elif slot_name == "Sigils": self.sigils_tab.load() - elif self.tabText(index) == TRIBUTES_TABNAME: + self.sigils_tab.show() + self.side_content_widget = self.sigils_tab + elif slot_name == "Tributes": self.tributes_tab.load() - elif self.tabText(index) == UNIQUES_TABNAME: + self.tributes_tab.show() + self.side_content_widget = self.tributes_tab + elif slot_name == "Global Rules": self.uniques_tab.load() + # When clicking global tab, show all rules via the integrated view + # to avoid widget reparenting issues that break the layout. + self.uniques_tab.filter_by_item_types(None) + self.uniques_tab.show() - @staticmethod - def show_warning(): - msg = QMessageBox() - msg.setIcon(QMessageBox.Icon.Warning) - msg.setWindowTitle("Warning") + # Hide the gear-specific parts + self.affixes_tab.hide() + self.gear_view_header.hide() - # Newline in message text - msg.setText("The profile model might not be valid. Do you still want to save your changes ?") + self.side_content_widget = self.gear_view_scroll + else: + self.side_content_widget = None - msg.setStandardButtons(QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard) + if self.side_content_widget is not None: + self.paper_doll.restore_side_panel(self.side_content_widget) + self._adjust_window_size(expanding=True) + else: + self.paper_doll.show_message(f"Configuration for '{slot_name}' coming soon") - response = msg.exec() - return response == QMessageBox.StandardButton.Save + def _adjust_window_size(self, expanding: bool): + """Resize the top-level window to accommodate the side panel.""" + win = self.window() + if not win or win.isMaximized(): + return + + # Use dynamic properties on the main window to track expansion state globally across instances. + # This prevents the window from getting wider and wider when switching profiles. + is_already_expanded = win.property("profile_editor_expanded") is True + + if expanding and not is_already_expanded: + # Store the current width before expanding so we can return to it exactly + win.setProperty("profile_editor_pre_expansion_width", win.width()) + current_size = win.size() + win.resize(current_size.width() + 850, current_size.height()) + win.setProperty("profile_editor_expanded", True) # noqa: FBT003 + elif not expanding and is_already_expanded: + # Restore the window to the exact width it had before the expansion + pre_width = win.property("profile_editor_pre_expansion_width") + current_size = win.size() + if pre_width is not None: + win.resize(pre_width, current_size.height()) + else: + # Fallback if the property is missing + new_width = max(800, current_size.width() - 850) + win.resize(new_width, current_size.height()) + win.setProperty("profile_editor_expanded", False) # noqa: FBT003 def save_all(self): """Save all tabs' configurations.""" try: - # Validate - model = ProfileModel.model_validate(self.profile_model) - if model != self.profile_model: - if self.show_warning(): - save_as_profile( - self.profile_model.name, self.profile_model, "custom", exclude={"name"}, backup_file=True - ) - # Emit signal for hot reload - self.profile_saved.emit(self.profile_model.name) - QMessageBox.information( - self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}" - ) - else: - QMessageBox.information(self, "Info", "Profile not saved.") - else: - save_as_profile( - self.profile_model.name, self.profile_model, "custom", exclude={"name"}, backup_file=True - ) - # Emit signal for hot reload - self.profile_saved.emit(self.profile_model.name) - QMessageBox.information( - self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}" - ) + # Re-validate to catch schema issues + ProfileModel.model_validate(self.profile_model) + + save_as_profile( + file_name=self.profile_model.name, + profile=self.profile_model, + url="custom", + exclude={"name"}, + backup_file=True, + ) + + # Emit signal for hot reload + self.profile_saved.emit(self.profile_model.name) + QMessageBox.information(self, "Info", f"Profile saved successfully to {self.profile_model.name + '.yaml'}") except Exception as e: LOGGER.exception("Failed to save profile") QMessageBox.critical(self, "Error", f"Failed to save profile: {e}") diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index 7c09a456..dbac883c 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -18,6 +18,7 @@ from src.dataloader import Dataloader from src.gui.models.collapsible_widget import Container from src.gui.models.dialog import CreateSigil, IgnoreScrollWheelComboBox, RemoveSigil +from src.gui.profile_editor.affixes_tab import TruncatingComboBox SIGILS_TABNAME = "Sigils" @@ -30,7 +31,7 @@ def __init__(self, condition: str, parent=None): self.condition = condition widget_layout = QHBoxLayout() widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.name_combo = IgnoreScrollWheelComboBox() + self.name_combo = TruncatingComboBox() self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) @@ -162,7 +163,7 @@ def load(self): def setup_ui(self): """Populate the grid layout with existing groups.""" self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 20, 0, 20) + self.main_layout.setContentsMargins(0, 5, 0, 5) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.create_button_layout() self.create_form() diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index 592ec81e..6fa85dec 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -32,7 +32,7 @@ def load(self): def setup_ui(self): main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(0, 20, 0, 20) + main_layout.setContentsMargins(0, 5, 0, 5) main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) label = QLabel( "Add tribute names and tribute rarities you want to keep. These rules are evaluated independently." diff --git a/src/gui/profile_editor_window.py b/src/gui/profile_editor_window.py index a66476ff..760c6054 100644 --- a/src/gui/profile_editor_window.py +++ b/src/gui/profile_editor_window.py @@ -29,7 +29,7 @@ def __init__(self, parent=None, profile_name: str | None = None): self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, on=True) self.setWindowTitle("Profile Editor") - self.resize(self.settings.value("size", QSize(650, 800))) + self.resize(self.settings.value("size", QSize(800, 800))) self.move(self.settings.value("pos", QPoint(0, 0))) if self.settings.value("maximized", "true") == "true": @@ -48,11 +48,22 @@ def _finish_construction(self): def closeEvent(self, event): # noqa: N802 """Save window size/position and check if profile needs saving.""" if not self.isMaximized(): - self.settings.setValue("size", self.size()) + save_size = self.size() + # If we are currently expanded, we want to save the width we had BEFORE expansion + # so that next time the editor opens, it opens to just the paper doll view. + if self.property("profile_editor_expanded") is True: + pre_width = self.property("profile_editor_pre_expansion_width") + if pre_width is not None: + save_size.setWidth(int(pre_width)) + + self.settings.setValue("size", save_size) self.settings.setValue("pos", self.pos()) self.settings.setValue("maximized", self.isMaximized()) - if self.profile_tab.check_close_save(): - event.accept() + if hasattr(self, "profile_tab"): + if self.profile_tab.check_close_save(): + event.accept() + else: + event.ignore() else: - event.ignore() + event.accept() diff --git a/src/gui/profile_tab.py b/src/gui/profile_tab.py index a2815e25..456d7eea 100644 --- a/src/gui/profile_tab.py +++ b/src/gui/profile_tab.py @@ -50,13 +50,13 @@ def __init__(self): scroll_area.setWidgetResizable(True) info_layout = QHBoxLayout() - info_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + info_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) tools_groupbox = QGroupBox("Profile") tools_groupbox_layout = QHBoxLayout() self.profile_combo = QComboBox() - self.save_button = QPushButton("Save") - self.refresh_button = QPushButton("Undo Changes") + self.save_button = QPushButton("Save Profile") + self.refresh_button = QPushButton("Revert to Saved") self.profile_combo.currentIndexChanged.connect(self.profile_selection_changed) self.save_button.clicked.connect(self.save_yaml) self.refresh_button.clicked.connect(self.refresh) @@ -79,7 +79,7 @@ def __init__(self): instructions_text = QTextBrowser() instructions_text.append( - "Select a profile from the dropdown. Click 'Save' to save your changes. Click 'Undo Changes' to revert your changes." + "Select a profile from the dropdown. Click 'Save Profile' to persist your changes. Click 'Revert to Saved' to discard unsaved edits." ) instructions_text.setFixedHeight(50) @@ -87,6 +87,12 @@ def __init__(self): self.setLayout(self.main_layout) self.populate_profile_dropdown() + def has_unsaved_changes(self) -> bool: + """Return True if the current profile has unsaved changes.""" + if not self.root or not self.original_root: + return False + return self.root != self.original_root + def confirm_discard_changes(self): reply = QMessageBox.warning( self, @@ -97,7 +103,26 @@ def confirm_discard_changes(self): if reply == QMessageBox.StandardButton.Yes: self.save_yaml() return True - return reply == QMessageBox.StandardButton.No + if reply == QMessageBox.StandardButton.No: + self._has_unsaved_changes = False + return True + return False + + def confirm_discard_profile_switch(self) -> bool: + """Prompt user to save changes before switching profiles. Returns True if safe to proceed.""" + reply = QMessageBox.warning( + self, + "Unsaved Changes", + "You have unsaved changes. What would you like to do before switching profiles?", + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, + ) + if reply == QMessageBox.StandardButton.Save: + self.save_yaml() + return not self.has_unsaved_changes() # True if save cleared dirty state + if reply == QMessageBox.StandardButton.Discard: + self._has_unsaved_changes = False + return True + return False # Cancel def create_alert(self, msg: str): reply = QMessageBox.warning(self, "Alert", msg, QMessageBox.StandardButton.Ok) @@ -111,6 +136,9 @@ def show_tab(self): def profile_selection_changed(self, index): selected_profile = self.profile_combo.itemData(index, Qt.ItemDataRole.UserRole) if selected_profile and selected_profile != self.current_profile_name: + # Check for unsaved changes before switching + if self.has_unsaved_changes() and not self.confirm_discard_profile_switch(): + return # User cancelled self.load_selected_profile(selected_profile) def load_selected_profile(self, profile_name): @@ -266,8 +294,11 @@ def load_yaml(self): return True def save_yaml(self): - self.original_root = copy.deepcopy(self.root) + if not self.root or not self.model_editor: + return self.model_editor.save_all() + self.original_root = copy.deepcopy(self.root) + # Mark as saved by comparing after save def check_close_save(self): if self.root and self.original_root != self.root: @@ -281,3 +312,7 @@ def refresh(self): self.model_editor = ProfileEditor(self.root) self.scrollable_layout.addWidget(self.model_editor) LOGGER.info(f"Profile {self.root.name} refreshed.") + + def set_unsaved_changes(self, has_changes: bool): + """Called by ProfileEditor when edits are made.""" + self._has_unsaved_changes = has_changes diff --git a/src/gui/themes.py b/src/gui/themes.py index 8687d23f..7d4bd247 100644 --- a/src/gui/themes.py +++ b/src/gui/themes.py @@ -130,6 +130,46 @@ background-color: #444; } +/* SpinBox Styling - Fixes hard to read arrows and click issues */ +QSpinBox, QDoubleSpinBox { + background-color: #1e1e1e; + color: #e0e0e0; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding-right: 24px; + min-height: 26px; +} +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 24px; + background-color: #252525; + border-left: 1px solid #3c3c3c; + border-bottom: 1px solid #3c3c3c; + border-top-right-radius: 4px; +} +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 24px; + background-color: #252525; + border-left: 1px solid #3c3c3c; + border-bottom-right-radius: 4px; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #3c3c3c; +} +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: none; + width: 8px; + height: 8px; +} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: none; + width: 8px; + height: 8px; +} + /* Disabled checkbox styling */ QCheckBox:disabled { color: gray; @@ -428,6 +468,46 @@ background-color: #d3d3d3; } +/* SpinBox Styling */ +QSpinBox, QDoubleSpinBox { + background-color: #ffffff; + color: #1f1f1f; + border: 1px solid #c3c3c3; + border-radius: 4px; + padding-right: 24px; + min-height: 26px; +} +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 24px; + background-color: #e0e0e0; + border-left: 1px solid #c3c3c3; + border-bottom: 1px solid #c3c3c3; + border-top-right-radius: 4px; +} +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 24px; + background-color: #e0e0e0; + border-left: 1px solid #c3c3c3; + border-bottom-right-radius: 4px; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #d3d3d3; +} +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: none; + width: 8px; + height: 8px; +} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: none; + width: 8px; + height: 8px; +} + /* Disabled checkbox styling */ QCheckBox:disabled { color: gray; From aaa5bbe700b0e9265a69ec97557ec9af69503a68 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 18:45:23 -0400 Subject: [PATCH 03/17] import error --- src/gui/profile_tab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/profile_tab.py b/src/gui/profile_tab.py index 456d7eea..deb9a0f6 100644 --- a/src/gui/profile_tab.py +++ b/src/gui/profile_tab.py @@ -19,8 +19,8 @@ ) from src.config.loader import IniConfigLoader +from src.config.profile_models import ProfileModel from src.dataloader import Dataloader -from src.gui.importer.gui_common import ProfileModel from src.gui.profile_editor.profile_editor import ProfileEditor from src.item.filter import _UniqueKeyLoader From 55ca3a7f4a5e1c57fa87a4fff02d02fe39ded96e Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 19:12:44 -0400 Subject: [PATCH 04/17] Add REQ coloring and logic for affix min has to at least be REQ affix count --- src/config/profile_models.py | 4 +++ src/gui/profile_editor/affixes_tab.py | 31 +++++++++++++++----- src/gui/profile_editor/global_uniques_tab.py | 14 +++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index e44936f5..f1c506ac 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -95,6 +95,10 @@ def model_validator(self) -> AffixFilterCountModel: self.max_count = len(self.count) self.model_fields_set.remove("min_count") self.model_fields_set.remove("max_count") + + req_count = sum(1 for a in self.count if a.required) + self.min_count = max(self.min_count, req_count) + if self.min_count > self.max_count: msg = "minCount must be smaller than maxCount" raise ValueError(msg) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 0f1bb9ca..47ea211f 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -283,11 +283,11 @@ def _affix_summary(pool: AffixFilterCountModel) -> str: for a in pool.count: name = Dataloader().affix_dict.get(a.name, a.name) if getattr(a, "required", False): - name = f"[REQ] {name}" + name = f'[REQ] {name}' if a.want_greater: name += " (GA)" names.append(name) - return "\n".join(names) + return "
".join(names) def _affix_card_summary(model: AffixFilterModel) -> str: @@ -800,9 +800,7 @@ def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): self.reorganize_pool(layout_widget) def reorganize_pool(self, layout_widget: QVBoxLayout): - for i in range(layout_widget.count()): - item = layout_widget.itemAt(i) - item and item.widget() is not None + pass def update_min_power(self): self.config.min_power = self.min_power.value() @@ -829,6 +827,8 @@ def toggle_auto_sync(self): self.min_greater.setProperty("autoSyncSpin", False) # noqa: FBT003 self._refresh_widget_style(self.min_greater) + self.update_greater_count_label() + def _update_auto_sync_count(self): count = self.count_want_greater_affixes() self.min_greater.set_value(count) @@ -874,6 +874,20 @@ def update_greater_count_label(self): else: self.greater_count_label.setText(f"({count} greater affixes marked)") + # Update pool footers with new Min Count constraints + for footer, model in [ + (self.affix_footer, self.config.affix_pool[0]), + (self.inherent_footer, self.config.inherent_pool[0]), + ]: + if footer and model: + min_spin = footer.property("min_spin") + if min_spin: + min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) + min_spin.set_minimum(min_allowed) + if model.min_count < min_allowed: + model.min_count = min_allowed + min_spin.set_value(min_allowed) + def convert_all_to_min_percent_of_affix(self, percent: int): for affix_widget in self.iter_affix_widgets(): affix_widget.set_min_percent(percent, convert_mode=True) @@ -1034,10 +1048,13 @@ def open_config_dialog(self): def refresh_display(self): name = Dataloader().affix_dict.get(self.model.name, self.model.name) - prefix = "[REQ] " if getattr(self.model, "required", False) else "" if self.model.want_greater: name += " (GA)" - self.summary_label.setText(f"{prefix}{name}") + + if getattr(self.model, "required", False): + self.summary_label.setText(f'[REQ] {name}') + else: + self.summary_label.setText(name) if self.model.min_percent_of_affix: self.threshold_label.setText(f"{self.model.min_percent_of_affix}%") diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 129ae6fe..fca12997 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -249,6 +249,20 @@ def update_greater_count_label(self): with QSignalBlocker(self.min_greater): self.min_greater.set_value(count) + # Update pool footers with new Min Count constraints + for footer, model in [ + (self.affix_footer, self.unique_model.affix_pool[0]), + (self.inherent_footer, self.unique_model.inherent_pool[0]), + ]: + if footer and model: + min_spin = footer.property("min_spin") + if min_spin: + min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) + min_spin.set_minimum(min_allowed) + if model.min_count < min_allowed: + model.min_count = min_allowed + min_spin.set_value(min_allowed) + def refresh_item_type_summary(self): self.item_type_line_edit.setText(_item_type_summary(self.unique_model.item_type)) From dd7c587b6be99a7b2920934dc8386eac407cd38b Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 20:16:45 -0400 Subject: [PATCH 05/17] Updated all tabs to be consistent. Added duplicate item. --- src/gui/profile_editor/affixes_tab.py | 57 ++- src/gui/profile_editor/aspect_upgrades_tab.py | 101 ++-- src/gui/profile_editor/global_uniques_tab.py | 69 ++- src/gui/profile_editor/profile_editor.py | 1 + src/gui/profile_editor/sigils_tab.py | 445 +++++++++--------- src/gui/profile_editor/tributes_tab.py | 240 ++++++---- 6 files changed, 564 insertions(+), 349 deletions(-) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 47ea211f..1bd5a82e 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -1,3 +1,4 @@ +import copy import logging from typing import override @@ -542,9 +543,13 @@ def save_and_accept(self): class AffixGroupEditor(QWidget): + duplicate_requested = pyqtSignal(DynamicItemFilterModel) + def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): super().__init__(parent) self.settings = QSettings("d4lf", "profile_editor") + self.affix_footer = None + self.inherent_footer = None self.dynamic_filter = dynamic_filter for item_name, config in dynamic_filter.root.items(): self.item_name = item_name @@ -560,11 +565,20 @@ def setup_ui(self): general_form = QFormLayout() # Item Alias/Name + alias_layout = QHBoxLayout() self.alias_edit = QLineEdit() self.alias_edit.setText(self.item_name) self.alias_edit.setMaximumWidth(300) self.alias_edit.textChanged.connect(self.update_item_alias) - general_form.addRow("Item Name / Alias:", self.alias_edit) + alias_layout.addWidget(self.alias_edit) + + duplicate_btn = QPushButton("Duplicate Item") + duplicate_btn.setFixedWidth(120) + duplicate_btn.clicked.connect(self._on_duplicate_clicked) + alias_layout.addWidget(duplicate_btn) + alias_layout.addStretch() + + general_form.addRow("Item Name / Alias:", alias_layout) self.min_power = IgnoreScrollWheelSpinBox() self.min_power.setMaximum(MAX_POWER) @@ -660,6 +674,7 @@ def create_col(title, add_cb, pool_model=None): columns_layout.addWidget(self.aspect_col) columns_layout.addWidget(self.affix_col) columns_layout.addWidget(self.inherent_col) + self.inherent_col.hide() self.content_layout.addLayout(columns_layout) @@ -668,6 +683,9 @@ def create_col(title, add_cb, pool_model=None): self.init_affix_pool() self.init_inherent_pool() + def _on_duplicate_clicked(self): + self.duplicate_requested.emit(self.dynamic_filter) + def init_unique_aspects(self): for aspect in self.config.unique_aspect: self.add_unique_aspect_item(aspect) @@ -1356,6 +1374,7 @@ def _ensure_tab_instantiated(self, index: int): is_current = self.tab_widget.currentIndex() == index with QSignalBlocker(self.tab_widget): editor = AffixGroupEditor(affix_group) + editor.duplicate_requested.connect(self.duplicate_item_tab) self.tab_widget.removeTab(index) self.tab_widget.insertTab(index, editor, item_name) if is_current: @@ -1417,6 +1436,42 @@ def close_tab(self, index): self.affixes_model.pop(index) self._update_plus_tab_button() + def duplicate_item_tab(self, original_filter: DynamicItemFilterModel): + # Find a unique name for the duplicated item + original_name = next(iter(original_filter.root.keys())) + new_name_base = f"{original_name} (Copy)" + new_name = new_name_base + i = 1 + while new_name in self.item_names: + i += 1 + new_name = f"{new_name_base} {i}" + + # Create a deep copy of the filter model + new_filter_model = copy.deepcopy(original_filter) + # Update the key in the root dictionary to the new name + new_filter_model.root = {new_name: new_filter_model.root.pop(original_name)} + + # Add to our internal lists and create a new tab + self.item_names.append(new_name) + self.item_data_map[new_name] = new_filter_model + self.affixes_model.append(new_filter_model) + + # Find the "+" tab index to insert before it + plus_idx = -1 + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + plus_idx = i + break + + # Create the actual editor widget and insert the tab + editor = AffixGroupEditor(new_filter_model) + editor.duplicate_requested.connect(self.duplicate_item_tab) + + if plus_idx != -1: + self.tab_widget.insertTab(plus_idx, editor, new_name) + self.tab_widget.setCurrentIndex(plus_idx) + self._update_plus_tab_button() + def remove_item_type(self): dialog = DeleteItem(self.item_names, self) if dialog.exec() == QDialog.DialogCode.Accepted: diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index 7bd0ec41..d70238d8 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -1,17 +1,43 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QVBoxLayout, QWidget +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import QDialog, QFrame, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QVBoxLayout, QWidget from src.gui.models.dialog import AddAspectUpgrade +from src.gui.profile_editor.affixes_tab import _create_column_header, _create_delete_btn, _create_summary_card_style ASPECT_UPGRADES_TABNAME = "Aspect Upgrades" +class AspectUpgradeSummaryWidget(QWidget): + delete_requested = pyqtSignal() + + def __init__(self, aspect_key: str, parent=None): + super().__init__(parent) + self.aspect_key = aspect_key + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setup_ui() + + def setup_ui(self): + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) + + # Format the aspect key into a friendly title + display_name = self.aspect_key.replace("_", " ").title() + self.summary_label = QLabel(display_name) + self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") + self.main_layout.addWidget(self.summary_label, 1) + + self.delete_btn = _create_delete_btn() + self.delete_btn.clicked.connect(self.delete_requested.emit) + self.main_layout.addWidget(self.delete_btn) + + class AspectUpgradesTab(QWidget): def __init__(self, aspect_upgrades: list[str], parent=None): super().__init__(parent) self.aspect_upgrades = aspect_upgrades - self.upgrade_list_widget = QListWidget() self.loaded = False + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): if not self.loaded: @@ -20,46 +46,49 @@ def load(self): def setup_ui(self): main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(0, 20, 0, 20) + main_layout.setContentsMargins(0, 5, 0, 5) + main_layout.setSpacing(0) main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - label = QLabel( - "Add any legendary aspects you'd like to have favorited if an upgrade is found. See the readme on AspectUpgrades for more information." - ) - label.setWordWrap(True) - main_layout.addWidget(label) - button_layout = self.create_button_layout() - main_layout.addLayout(button_layout) - - self.upgrade_list_widget.insertItems(0, self.aspect_upgrades) - main_layout.addWidget(self.upgrade_list_widget) - self.setLayout(main_layout) - def create_button_layout(self) -> QHBoxLayout: - btn_layout = QHBoxLayout() + header = _create_column_header("Aspect Upgrades", self.add_aspect) + main_layout.addWidget(header) - add_aspect_btn = QPushButton("Add Aspect") - add_aspect_btn.setFixedWidth(140) - add_aspect_btn.clicked.connect(self.add_aspect) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") - remove_aspect_btn = QPushButton("Remove Aspect") - remove_aspect_btn.setFixedWidth(140) - remove_aspect_btn.clicked.connect(self.remove_aspect) + self.scroll_widget = QWidget() + self.list_layout = QVBoxLayout(self.scroll_widget) + self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.list_layout.setContentsMargins(10, 10, 10, 10) + self.list_layout.setSpacing(4) - btn_layout.addWidget(add_aspect_btn) - btn_layout.addWidget(remove_aspect_btn) - btn_layout.addStretch() - return btn_layout + scroll.setWidget(self.scroll_widget) + main_layout.addWidget(scroll) + + # Populate initial items + for aspect in self.aspect_upgrades: + self.add_aspect_item(aspect) + + self.setLayout(main_layout) + + def add_aspect_item(self, aspect_key: str): + widget = AspectUpgradeSummaryWidget(aspect_key) + widget.delete_requested.connect(lambda: self.remove_aspect_item(widget)) + self.list_layout.addWidget(widget) def add_aspect(self): dialog = AddAspectUpgrade(self.aspect_upgrades) if dialog.exec() == QDialog.DialogCode.Accepted: aspect_upgrade = dialog.get_value() - self.aspect_upgrades.append(aspect_upgrade) - self.upgrade_list_widget.addItem(aspect_upgrade) - - def remove_aspect(self): - if not self.upgrade_list_widget.currentRow() >= 0: - return - current_aspect = self.upgrade_list_widget.currentItem().text() - self.aspect_upgrades.remove(current_aspect) - self.upgrade_list_widget.takeItem(self.upgrade_list_widget.currentRow()) + if aspect_upgrade: + self.aspect_upgrades.append(aspect_upgrade) + self.add_aspect_item(aspect_upgrade) + + def remove_aspect_item(self, widget: AspectUpgradeSummaryWidget): + aspect_key = widget.aspect_key + if aspect_key in self.aspect_upgrades: + self.aspect_upgrades.remove(aspect_key) + widget.setParent(None) + widget.deleteLater() diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index fca12997..3ca03fc7 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -1,4 +1,6 @@ -from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer +import copy + +from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer, pyqtSignal from PyQt6.QtWidgets import ( QCheckBox, QDialog, @@ -37,10 +39,14 @@ class UniqueWidget(QWidget): + duplicate_requested = pyqtSignal(GlobalUniqueModel) + def __init__(self, unique_model: GlobalUniqueModel, parent=None): super().__init__(parent) self.settings = QSettings("d4lf", "profile_editor") self.unique_model = unique_model + self.affix_footer = None + self.inherent_footer = None self.item_types = [ item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item) ] @@ -99,6 +105,7 @@ def create_col(title, add_cb, pool_model=None): columns_layout.addWidget(col1_w) columns_layout.addWidget(col2_w) columns_layout.addWidget(col3_w) + col3_w.hide() self.content_layout.addLayout(columns_layout) @@ -143,11 +150,20 @@ def create_general_groupbox(self): self.general_form = QFormLayout() # Profile Alias / Name + alias_layout = QHBoxLayout() self.profile_alias = QLineEdit() self.profile_alias.setMaximumWidth(300) self.profile_alias.setText(self.unique_model.profile_alias) self.profile_alias.textChanged.connect(self.update_profile_alias) - self.general_form.addRow("Rule Alias:", self.profile_alias) + alias_layout.addWidget(self.profile_alias) + + duplicate_btn = QPushButton("Duplicate Rule") + duplicate_btn.setFixedWidth(120) + duplicate_btn.clicked.connect(self._on_duplicate_clicked) + alias_layout.addWidget(duplicate_btn) + alias_layout.addStretch() + + self.general_form.addRow("Rule Alias:", alias_layout) # Item Types (Slots) self.item_type_line_edit = QLineEdit() @@ -196,6 +212,9 @@ def create_general_groupbox(self): self.content_layout.addWidget(self.general_groupbox) QTimer.singleShot(100, self.update_greater_count_label) + def _on_duplicate_clicked(self): + self.duplicate_requested.emit(self.unique_model) + def add_unique_aspect_item(self, model: AspectUniqueFilterModel) -> UniqueAspectWidget: widget = UniqueAspectWidget(model) widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget)) @@ -262,6 +281,18 @@ def update_greater_count_label(self): if model.min_count < min_allowed: model.min_count = min_allowed min_spin.set_value(min_allowed) + # for footer, model in [ + # (self.affix_footer, self.unique_model.affix_pool[0]), + # (self.inherent_footer, self.unique_model.inherent_pool[0]), + # ]: + # if footer and model: + # min_spin = footer.property("min_spin") + # if min_spin: + # min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) + # min_spin.set_minimum(min_allowed) + # if model.min_count < min_allowed: + # model.min_count = min_allowed + # min_spin.set_value(min_allowed) def refresh_item_type_summary(self): self.item_type_line_edit.setText(_item_type_summary(self.unique_model.item_type)) @@ -468,6 +499,7 @@ def _ensure_tab_instantiated(self, index: int): model = self.unique_model_list[model_idx] widget = UniqueWidget(model) + widget.duplicate_requested.connect(self.duplicate_rule_tab) name = self.tab_widget.tabText(index) is_current = self.tab_widget.currentIndex() == index with QSignalBlocker(self.tab_widget): @@ -476,6 +508,38 @@ def _ensure_tab_instantiated(self, index: int): if is_current: self.tab_widget.setCurrentIndex(index) + def duplicate_rule_tab(self, original_model: GlobalUniqueModel): + # Find a unique alias for the duplicated rule + original_alias = original_model.profile_alias or "New Rule" + new_alias_base = f"{original_alias} (Copy)" + new_alias = new_alias_base + + existing_aliases = [m.profile_alias for m in self.unique_model_list] + i = 1 + while new_alias in existing_aliases: + i += 1 + new_alias = f"{new_alias_base} {i}" + + # Create a deep copy of the unique rule model + new_model = copy.deepcopy(original_model) + new_model.profile_alias = new_alias + self.unique_model_list.append(new_model) + + plus_idx = -1 + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == "+": + plus_idx = i + break + + # Create the actual editor widget and insert the tab + editor = UniqueWidget(new_model) + editor.duplicate_requested.connect(self.duplicate_rule_tab) + + if plus_idx != -1: + self.tab_widget.insertTab(plus_idx, editor, new_alias) + self.tab_widget.setCurrentIndex(plus_idx) + self._update_plus_tab_button() + def remove_item_type(self): dialog = DeleteItem([self.tab_widget.tabText(i) for i in range(self.tab_widget.count())], self) if dialog.exec() == QDialog.DialogCode.Accepted: @@ -513,6 +577,7 @@ def add_item_type(self): alias = f"New Rule {self.tab_widget.count()}" unique_model = GlobalUniqueModel(item_type=item_types, profileAlias=alias) group = UniqueWidget(unique_model) + group.duplicate_requested.connect(self.duplicate_rule_tab) self.tab_widget.insertTab(plus_idx, group, alias) self.unique_model_list.append(unique_model) self.tab_widget.setCurrentIndex(plus_idx) diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index febd72c5..35aba23d 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -163,6 +163,7 @@ def on_slot_clicked(self, slot_name: str): if is_gear_slot: # Ensure children are loaded before filtering self.affixes_tab.load() + self.uniques_tab.load() # Ensure gear view components are visible (they might have been hidden by Global Rules) self.affixes_tab.show() self.uniques_tab.hide() diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index dbac883c..5308dcc1 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -1,152 +1,182 @@ +from typing import override + from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( QComboBox, QCompleter, QDialog, + QDialogButtonBox, QFormLayout, + QFrame, + QGroupBox, QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QMessageBox, QPushButton, + QScrollArea, + QSizePolicy, QVBoxLayout, QWidget, ) from src.config.profile_models import SigilConditionModel, SigilFilterModel, SigilPriority from src.dataloader import Dataloader -from src.gui.models.collapsible_widget import Container -from src.gui.models.dialog import CreateSigil, IgnoreScrollWheelComboBox, RemoveSigil -from src.gui.profile_editor.affixes_tab import TruncatingComboBox +from src.gui.models.dialog import IgnoreScrollWheelComboBox +from src.gui.profile_editor.affixes_tab import ( + SelectionDialog, + TruncatingComboBox, + _create_column_header, + _create_delete_btn, + _create_summary_card_style, +) SIGILS_TABNAME = "Sigils" -class ConditionWidget(QWidget): - condition_changed = pyqtSignal(str, str) +class SigilSummaryWidget(QWidget): + delete_requested = pyqtSignal() + config_changed = pyqtSignal() - def __init__(self, condition: str, parent=None): + def __init__(self, model: SigilConditionModel, whitelist: bool, parent=None): super().__init__(parent) - self.condition = condition - widget_layout = QHBoxLayout() - widget_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.model = model + self.whitelist = whitelist + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setup_ui() + + def setup_ui(self): + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) + + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name) + self.name_label = QLabel(name) + self.name_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") + text_layout.addWidget(self.name_label) + + # Build condition summary + cond_text = "No conditions" + if self.model.condition: + names = [Dataloader().affix_sigil_dict.get(c, c) for c in self.model.condition if c] + cond_text = ", ".join(names) + + self.cond_label = QLabel(cond_text) + self.cond_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.cond_label.setWordWrap(True) + text_layout.addWidget(self.cond_label) + + self.main_layout.addLayout(text_layout, 1) + + self.delete_btn = _create_delete_btn() + self.delete_btn.clicked.connect(self.delete_requested.emit) + self.main_layout.addWidget(self.delete_btn) + + @override + def mousePressEvent(self, event): + if event is None or event.button() == Qt.MouseButton.LeftButton: + self.open_config_dialog() + + def open_config_dialog(self): + name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name) + dialog = SigilEditDialog(self, self.model, name) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.refresh_display() + self.config_changed.emit() + + def refresh_display(self): + name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name) + self.name_label.setText(name) + + cond_text = "No conditions" + if self.model.condition: + names = [Dataloader().affix_sigil_dict.get(c, c) for c in self.model.condition if c] + cond_text = ", ".join(names) + self.cond_label.setText(cond_text) + + +class SigilEditDialog(QDialog): + def __init__(self, parent: QWidget, model: SigilConditionModel, dungeon_name: str): + super().__init__(parent) + self.setWindowTitle("Configure Sigil Rule") + self.setMinimumWidth(500) + self.model = model + + layout = QVBoxLayout(self) + form = QFormLayout() + self.name_combo = TruncatingComboBox() self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.name_combo.addItems(sorted(Dataloader().affix_sigil_dict_all["dungeons"].values())) + self.name_combo.setCurrentText(dungeon_name) + form.addRow("Dungeon:", self.name_combo) + layout.addLayout(form) + + layout.addWidget(QLabel("Conditions (Must match ANY):")) + self.cond_list = QListWidget() + self.cond_list.setMinimumHeight(200) + for cond in self.model.condition: + if cond: + self.cond_list.addItem(Dataloader().affix_sigil_dict.get(cond, cond)) + layout.addWidget(self.cond_list) + + btn_layout = QHBoxLayout() + add_btn = QPushButton("+ Add Condition") + add_btn.clicked.connect(self._add_condition) + remove_btn = QPushButton("− Remove Selected") + remove_btn.clicked.connect(self._remove_condition) + btn_layout.addWidget(add_btn) + btn_layout.addWidget(remove_btn) + layout.addLayout(btn_layout) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.save_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _add_condition(self): affix_sigil_dict = { **Dataloader().affix_sigil_dict_all["minor"], **Dataloader().affix_sigil_dict_all["major"], **Dataloader().affix_sigil_dict_all["positive"], } - self.name_combo.addItems(sorted(affix_sigil_dict.values())) - self.name_combo.setMaximumWidth(600) - self.name_combo.setCurrentText(condition) - self.name_combo.currentIndexChanged.connect(self.update_condition) - widget_layout.addWidget(self.name_combo) - self.setLayout(widget_layout) - - def update_condition(self): - old_condition = self.condition - self.condition = self.name_combo.currentText() - self.condition_changed.emit(old_condition, self.condition) - - -class SigilWidget(Container): - dungeon_changed = pyqtSignal() - - def __init__(self, sigil_name: str, sigil: SigilConditionModel, whitelist: bool): - super().__init__(sigil_name, color_background=True) - self.sigil = sigil - self.sigil_name = sigil_name - self.whitelist = whitelist - self.setup_ui() + items = sorted(affix_sigil_dict.values()) + dialog = SelectionDialog(self, "Select Condition", items) + if dialog.exec() == QDialog.DialogCode.Accepted: + val = dialog.get_value() + if val: + # Avoid adding same condition multiple times in UI + existing = [self.cond_list.item(i).text() for i in range(self.cond_list.count())] + if val not in existing: + self.cond_list.addItem(val) + + def _remove_condition(self): + for item in self.cond_list.selectedItems(): + self.cond_list.takeItem(self.cond_list.row(item)) + + def save_and_accept(self): + new_name = self.name_combo.currentText() + reverse_dungeon = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()} + dungeon_id = reverse_dungeon.get(new_name) + if not dungeon_id: + QMessageBox.warning(self, "Warning", "Please select a valid dungeon from the list.") + return - def setup_ui(self): - container_layout = QVBoxLayout(self.content_widget) - widget = QWidget() - layout = QVBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - title_layout = QHBoxLayout() - title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - - form_layout = QFormLayout() - self.sigil_name_combo = IgnoreScrollWheelComboBox() - self.sigil_name_combo.setEditable(True) - self.sigil_name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.sigil_name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.sigil_name_combo.addItems(sorted(Dataloader().affix_sigil_dict_all["dungeons"].values())) - self.sigil_name_combo.setCurrentText(self.sigil_name) - self.sigil_name_combo.setMaximumWidth(150) - self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon) - form_layout.addRow("Dungeon:", self.sigil_name_combo) - - comparison_label = QLabel("Condition") - title_layout.addSpacing(100) - title_layout.addWidget(comparison_label) - self.condition_list = QListWidget() - self.condition_list.setMinimumHeight(50) - self.condition_list.setAlternatingRowColors(True) - for condition in self.sigil.condition: - if not condition: - continue - self.add_condition_to_list(Dataloader().affix_sigil_dict[condition]) - - condition_btn_layout = QHBoxLayout() - add_condition_btn = QPushButton("Add Condition") - add_condition_btn.clicked.connect(self.add_condition) - condition_btn_layout.addWidget(add_condition_btn) - remove_condition_btn = QPushButton("Remove Condition") - remove_condition_btn.clicked.connect(self.remove_selected) - condition_btn_layout.addWidget(remove_condition_btn) - layout.addLayout(form_layout) - layout.addLayout(condition_btn_layout) - layout.addLayout(title_layout) - layout.addWidget(self.condition_list) - widget.setLayout(layout) - container_layout.addWidget(widget) - - def add_condition_to_list(self, condition): - widget_item = QListWidgetItem() - widget = ConditionWidget(condition) - widget.condition_changed.connect(self.on_condition_update) - widget_item.setSizeHint(widget.sizeHint()) - self.condition_list.addItem(widget_item) - self.condition_list.setItemWidget(widget_item, widget) - - def add_condition(self): - self.add_condition_to_list(next(iter(Dataloader().affix_sigil_dict_all["minor"].values()))) - self.sigil.condition.append(next(iter(Dataloader().affix_sigil_dict_all["minor"].keys()))) - - def remove_selected(self): - for item in self.condition_list.selectedItems(): - row = self.condition_list.row(item) - self.condition_list.takeItem(row) - self.sigil.condition.pop(row) - - def revert_sigil_dungeon(self): - self.sigil_name_combo.currentIndexChanged.disconnect() - self.sigil_name_combo.currentTextChanged.connect(lambda: self.update_sigil_dungeon(classic=False)) - self.sigil_name_combo.setCurrentText(self.old_name) - self.sigil_name_combo.currentTextChanged.disconnect() - self.sigil_name_combo.currentIndexChanged.connect(self.update_sigil_dungeon) - - def update_sigil_dungeon(self, classic=True): - new_name = self.sigil_name_combo.currentText() - self.old_name = self.sigil_name - self.sigil_name = new_name - self.header.set_name(new_name) - reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()} - self.sigil.name = reverse_dict.get(new_name) - if classic: - self.dungeon_changed.emit() - - def on_condition_update(self, old_condition, condition: str): - reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict.items()} - index = self.sigil.condition.index(reverse_dict.get(old_condition, "")) - self.sigil.condition.pop(index) - self.sigil.condition.insert(index, reverse_dict.get(condition)) + self.model.name = dungeon_id + + reverse_cond = {v: k for k, v in Dataloader().affix_sigil_dict.items()} + self.model.condition = [] + for i in range(self.cond_list.count()): + text = self.cond_list.item(i).text() + if key := reverse_cond.get(text): + self.model.condition.append(key) + self.accept() class SigilsTab(QWidget): @@ -154,6 +184,7 @@ def __init__(self, sigil_model: SigilFilterModel, parent=None): super().__init__(parent) self.sigil_model = sigil_model self.loaded = False + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): if not self.loaded: @@ -164,140 +195,96 @@ def setup_ui(self): """Populate the grid layout with existing groups.""" self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 5, 0, 5) + self.main_layout.setSpacing(0) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.create_button_layout() - self.create_form() - self.create_containers() - def create_button_layout(self): - btn_layout = QHBoxLayout() + # 1. General Config + self.create_general_groupbox() + + # 2. Columns Layout + columns_layout = QHBoxLayout() + columns_layout.setSpacing(15) + + def create_col(title, add_cb): + col_widget = QWidget() + col_layout = QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(0) + + header = _create_column_header(title, add_cb) + col_layout.addWidget(header) - add_sigil_btn = QPushButton("Add Sigil") - add_sigil_btn.clicked.connect(self.create_sigil) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") - remove_whitelist_sigil_btn = QPushButton("Remove Whitelist Sigil") - remove_whitelist_sigil_btn.clicked.connect(lambda: self.remove_sigil()) + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(inner) - remove_blacklist_sigil_btn = QPushButton("Remove Blacklist Sigil") - remove_blacklist_sigil_btn.clicked.connect(lambda: self.remove_sigil(blacklist=True)) + col_layout.addWidget(scroll) + return col_widget, inner_layout - btn_layout.addWidget(add_sigil_btn) - btn_layout.addWidget(remove_whitelist_sigil_btn) - btn_layout.addWidget(remove_blacklist_sigil_btn) - self.main_layout.addLayout(btn_layout) + self.whitelist_col, self.whitelist_layout = create_col("Whitelist", self.add_whitelist_sigil) + self.blacklist_col, self.blacklist_layout = create_col("Blacklist", self.add_blacklist_sigil) - def create_form(self): - self.general_form = QFormLayout() + columns_layout.addWidget(self.whitelist_col) + columns_layout.addWidget(self.blacklist_col) + self.main_layout.addLayout(columns_layout) + + # 3. Init content + self.init_sigils() + + def create_general_groupbox(self): + group = QGroupBox("Sigil Filtering") + form = QFormLayout(group) self.priority_combobox = IgnoreScrollWheelComboBox() - self.priority_combobox.setEditable(True) - self.priority_combobox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.priority_combobox.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.priority_combobox.addItems(SigilPriority._member_names_) self.priority_combobox.setCurrentText(self.sigil_model.priority) self.priority_combobox.setMaximumWidth(150) self.priority_combobox.currentIndexChanged.connect(self.update_priority) - self.general_form.addRow("Priority:", self.priority_combobox) - self.main_layout.addLayout(self.general_form) + form.addRow("Priority Mode:", self.priority_combobox) + self.main_layout.addWidget(group) - def create_containers(self): - # Blacklist - self.blacklist_container = Container("Blacklist") - self.blacklist_layout = QVBoxLayout(self.blacklist_container.content_widget) - self.blacklist_sigils = [] + def init_sigils(self): + for sigil in self.sigil_model.whitelist: + self.add_sigil_widget(sigil, whitelist=True) + for sigil in self.sigil_model.blacklist: + self.add_sigil_widget(sigil, whitelist=False) - for sigil_condition in self.sigil_model.blacklist: - self.add_sigil(sigil_condition) - self.blacklist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name]) + def add_sigil_widget(self, model: SigilConditionModel, whitelist: bool): + layout = self.whitelist_layout if whitelist else self.blacklist_layout + widget = SigilSummaryWidget(model, whitelist) + widget.delete_requested.connect(lambda: self.remove_sigil_item(widget, whitelist)) + layout.addWidget(widget) + return widget - # Whitelist - self.whitelist_container = Container("Whitelist") - self.whitelist_layout = QVBoxLayout(self.whitelist_container.content_widget) - self.whitelist_sigils = [] + def add_whitelist_sigil(self): + self._create_new_sigil(whitelist=True) - for sigil_condition in self.sigil_model.whitelist: - self.add_sigil(sigil_condition, whitelist=True) - self.whitelist_sigils.append(Dataloader().affix_sigil_dict[sigil_condition.name]) + def add_blacklist_sigil(self): + self._create_new_sigil(whitelist=False) - self.main_layout.addWidget(self.whitelist_container) - self.main_layout.addWidget(self.blacklist_container) + def _create_new_sigil(self, whitelist: bool): + # Default to first dungeon key available + dungeon_key = next(iter(Dataloader().affix_sigil_dict_all["dungeons"].keys())) + new_sigil = SigilConditionModel(name=dungeon_key, condition=[]) - def add_sigil(self, sigil_condition: SigilConditionModel, whitelist: bool = False): - name = Dataloader().affix_sigil_dict_all["dungeons"][sigil_condition.name] if whitelist: - widget = SigilWidget(name, sigil_condition, whitelist=True) - widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget)) - self.whitelist_layout.addWidget(widget) + self.sigil_model.whitelist.append(new_sigil) else: - widget = SigilWidget(name, sigil_condition, whitelist=False) - widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget)) - self.blacklist_layout.addWidget(widget) + self.sigil_model.blacklist.append(new_sigil) - def create_sigil(self): - dialog = CreateSigil(self.whitelist_sigils, self.blacklist_sigils) - if dialog.exec() == QDialog.DialogCode.Accepted: - sigil_name, type_name = dialog.get_value() - reverse_dict = {v: k for k, v in Dataloader().affix_sigil_dict_all["dungeons"].items()} - sigil_condition = SigilConditionModel(name=reverse_dict.get(sigil_name), condition=[]) - if type_name == "whitelist": - widget = SigilWidget(sigil_name, sigil_condition, whitelist=True) - widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget)) - self.whitelist_layout.addWidget(widget) - self.whitelist_sigils.append(sigil_name) - self.sigil_model.whitelist.append(sigil_condition) - elif type_name == "blacklist": - widget = SigilWidget(sigil_name, sigil_condition, whitelist=False) - widget.dungeon_changed.connect(lambda: self.on_dungeon_changed(widget)) - self.blacklist_layout.addWidget(widget) - self.blacklist_sigils.append(sigil_name) - self.sigil_model.blacklist.append(sigil_condition) - - def remove_sigil(self, blacklist: bool = False): - dialog = RemoveSigil(self.blacklist_sigils, blacklist=True) if blacklist else RemoveSigil(self.whitelist_sigils) - if dialog.exec() == QDialog.DialogCode.Accepted: - to_delete = dialog.get_value() - if blacklist: - for sigil in to_delete: - self.blacklist_sigils.remove(sigil) - to_delete_list = [] - for i in range(self.blacklist_layout.count()): - sigil_widget: SigilWidget = self.blacklist_layout.itemAt(i).widget() - if sigil_widget.sigil_name in to_delete: - to_delete_list.append(sigil_widget) - for sig_widget in to_delete_list: - sig_widget.setParent(None) - self.sigil_model.blacklist.remove(sig_widget.sigil) - else: - for sigil in to_delete: - self.whitelist_sigils.remove(sigil) - to_delete_list = [] - for i in range(self.whitelist_layout.count()): - sigil_widget: SigilWidget = self.whitelist_layout.itemAt(i).widget() - if sigil_widget.sigil_name in to_delete: - to_delete_list.append(sigil_widget) - for sig_widget in to_delete_list: - sig_widget.setParent(None) - self.sigil_model.whitelist.remove(sig_widget.sigil) + self.add_sigil_widget(new_sigil, whitelist).open_config_dialog() + + def remove_sigil_item(self, widget: SigilSummaryWidget, whitelist: bool): + model_list = self.sigil_model.whitelist if whitelist else self.sigil_model.blacklist + if widget.model in model_list: + model_list.remove(widget.model) + widget.setParent(None) + widget.deleteLater() def update_priority(self): self.sigil_model.priority = SigilPriority(self.priority_combobox.currentText()) - - def on_dungeon_changed(self, sigil_widget: SigilWidget): - whitelist = sigil_widget.whitelist - new_name = sigil_widget.sigil_name - old_name = sigil_widget.old_name - if whitelist and new_name in self.whitelist_sigils: - QMessageBox.warning(self, "Warning", "Sigil already exist in whitelist. You can modify the existing one.") - sigil_widget.revert_sigil_dungeon() - return - if not whitelist and new_name in self.blacklist_sigils: - QMessageBox.warning(self, "Warning", "Sigil already exist in blacklist. You can modify the existing one.") - sigil_widget.revert_sigil_dungeon() - return - if whitelist and old_name in self.whitelist_sigils: - index = self.whitelist_sigils.index(old_name) - self.whitelist_sigils.pop(index) - self.whitelist_sigils.insert(index, new_name) - if not whitelist and old_name in self.blacklist_sigils: - index = self.blacklist_sigils.index(old_name) - self.blacklist_sigils.pop(index) - self.blacklist_sigils.insert(index, new_name) diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index 6fa85dec..0af07810 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -1,29 +1,145 @@ -from PyQt6.QtCore import Qt +from typing import override + +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( - QAbstractItemView, + QCheckBox, QDialog, + QDialogButtonBox, + QFormLayout, + QFrame, + QGroupBox, QHBoxLayout, QLabel, - QListWidget, - QMessageBox, - QPushButton, + QScrollArea, + QSizePolicy, QVBoxLayout, QWidget, ) from src.config.profile_models import ItemRarity, TributeFilterModel from src.dataloader import Dataloader -from src.gui.models.dialog import AddTributeRarity, CreateTribute +from src.gui.profile_editor.affixes_tab import ( + TruncatingComboBox, + _create_column_header, + _create_delete_btn, + _create_summary_card_style, +) TRIBUTES_TABNAME = "Tributes" +class TributeSummaryWidget(QWidget): + delete_requested = pyqtSignal() + config_changed = pyqtSignal() + + def __init__(self, model: TributeFilterModel, parent=None): + super().__init__(parent) + self.model = model + self.setObjectName("SummaryCard") + self.setStyleSheet(_create_summary_card_style()) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setup_ui() + + def setup_ui(self): + self.main_layout = QHBoxLayout(self) + self.main_layout.setContentsMargins(10, 8, 10, 8) + + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + self.name_label = QLabel() + self.name_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") + text_layout.addWidget(self.name_label) + + self.details_label = QLabel() + self.details_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.details_label.setWordWrap(True) + text_layout.addWidget(self.details_label) + + self.main_layout.addLayout(text_layout, 1) + + self.delete_btn = _create_delete_btn() + self.delete_btn.clicked.connect(self.delete_requested.emit) + self.main_layout.addWidget(self.delete_btn) + + self.refresh_display() + + @override + def mousePressEvent(self, event): + if event is None or event.button() == Qt.MouseButton.LeftButton: + self.open_config_dialog() + + def open_config_dialog(self): + dialog = TributeEditDialog(self, self.model) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.refresh_display() + self.config_changed.emit() + + def refresh_display(self): + if self.model.name: + name = Dataloader().tribute_dict.get(self.model.name, self.model.name) + self.name_label.setText(name.replace("Tribute of ", "")) + else: + self.name_label.setText("Broad Rarity Filter") + + rarity_text = "All Rarities" + if self.model.rarities: + rarity_text = ", ".join(r.name.title() for r in self.model.rarities) + self.details_label.setText(rarity_text) + + +class TributeEditDialog(QDialog): + def __init__(self, parent: QWidget, model: TributeFilterModel): + super().__init__(parent) + self.setWindowTitle("Configure Tribute Rule") + self.setMinimumWidth(450) + self.model = model + self.rarity_checkboxes: dict[ItemRarity, QCheckBox] = {} + + layout = QVBoxLayout(self) + form = QFormLayout() + + self.name_combo = TruncatingComboBox() + self.name_combo.addItems(["[None / Rarity Only]"] + sorted(Dataloader().tribute_dict.values())) + if self.model.name: + self.name_combo.setCurrentText(Dataloader().tribute_dict.get(self.model.name, self.model.name)) + else: + self.name_combo.setCurrentIndex(0) + form.addRow("Tribute:", self.name_combo) + layout.addLayout(form) + + rarity_group = QGroupBox("Target Rarities") + rarity_layout = QVBoxLayout(rarity_group) + for rarity in ItemRarity: + cb = QCheckBox(rarity.name.title()) + cb.setChecked(rarity in self.model.rarities) + self.rarity_checkboxes[rarity] = cb + rarity_layout.addWidget(cb) + layout.addWidget(rarity_group) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.save_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def save_and_accept(self): + val = self.name_combo.currentText() + if val == "[None / Rarity Only]": + self.model.name = None + else: + reverse_dict = {v: k for k, v in Dataloader().tribute_dict.items()} + self.model.name = reverse_dict.get(val) + + self.model.rarities = [r for r, cb in self.rarity_checkboxes.items() if cb.isChecked()] + self.accept() + + class TributesTab(QWidget): def __init__(self, tributes: list[TributeFilterModel] | None, parent=None): super().__init__(parent) self.tributes = tributes if tributes is not None else [] - self.tribute_list_widget = QListWidget() self.loaded = False + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): if not self.loaded: @@ -33,90 +149,52 @@ def load(self): def setup_ui(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 5, 0, 5) + main_layout.setSpacing(0) main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - label = QLabel( - "Add tribute names and tribute rarities you want to keep. These rules are evaluated independently." - ) + + label = QLabel("Add tributes or rarity-based rules you want to keep. These rules are evaluated independently.") label.setWordWrap(True) + label.setStyleSheet("color: #94a3b8; font-size: 11px; padding: 5px 10px;") main_layout.addWidget(label) - button_layout = self.create_button_layout() - main_layout.addLayout(button_layout) - - self.tribute_list_widget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self._reload_tribute_list_widget() - main_layout.addWidget(self.tribute_list_widget) - self.setLayout(main_layout) - def create_button_layout(self) -> QHBoxLayout: - btn_layout = QHBoxLayout() + header = _create_column_header("Tributes", self.add_tribute) + main_layout.addWidget(header) - add_tribute_btn = QPushButton("Add Tribute") - add_tribute_btn.clicked.connect(self.add_tribute) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") - add_rarity_btn = QPushButton("Add Rarity") - add_rarity_btn.clicked.connect(self.add_rarity) + self.scroll_widget = QWidget() + self.list_layout = QVBoxLayout(self.scroll_widget) + self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.list_layout.setContentsMargins(10, 10, 10, 10) + self.list_layout.setSpacing(4) - remove_rule_btn = QPushButton("Remove Selected") - remove_rule_btn.clicked.connect(self.remove_selected) + scroll.setWidget(self.scroll_widget) + main_layout.addWidget(scroll) - btn_layout.addWidget(add_tribute_btn) - btn_layout.addWidget(add_rarity_btn) - btn_layout.addWidget(remove_rule_btn) - return btn_layout + self.init_tributes() + self.setLayout(main_layout) - def _reload_tribute_list_widget(self): - self.tribute_list_widget.clear() + def init_tributes(self): for tribute in self.tributes: - self.tribute_list_widget.addItem(self._display_text(tribute)) - - @staticmethod - def _display_text(tribute: TributeFilterModel) -> str: - if not tribute.name and not tribute.rarities: - return "Empty tribute rule" - - parts = [] - if tribute.name: - tribute_name = Dataloader().tribute_dict.get(tribute.name, tribute.name) - parts.append(f"Tribute: {tribute_name}") + self.add_tribute_widget(tribute) - if tribute.rarities: - rarity_names = ", ".join(ItemRarity(rarity).name for rarity in tribute.rarities) - parts.append(f"Rarities: {rarity_names}") - - return " | ".join(parts) + def add_tribute_widget(self, model: TributeFilterModel): + widget = TributeSummaryWidget(model) + widget.delete_requested.connect(lambda: self.remove_tribute_item(widget)) + self.list_layout.addWidget(widget) + return widget def add_tribute(self): - dialog = CreateTribute(self._existing_tribute_names()) - if dialog.exec() == QDialog.DialogCode.Accepted: - tribute_filter = dialog.get_value() - self.tributes.append(tribute_filter) - self.tribute_list_widget.addItem(self._display_text(tribute_filter)) - - def add_rarity(self): - dialog = AddTributeRarity(self._existing_rarities()) - if dialog.exec() == QDialog.DialogCode.Accepted: - tribute_filter = dialog.get_value() - self.tributes.append(tribute_filter) - self.tribute_list_widget.addItem(self._display_text(tribute_filter)) - - def remove_selected(self): - rows = sorted( - {self.tribute_list_widget.row(item) for item in self.tribute_list_widget.selectedItems()}, reverse=True - ) - if not rows: - QMessageBox.warning(self, "Warning", "Select at least one tribute rule to remove.") - return - - for row in rows: - self.tribute_list_widget.takeItem(row) - self.tributes.pop(row) - - def _existing_tribute_names(self) -> list[str]: - return [tribute.name for tribute in self.tributes if tribute.name and not tribute.rarities] - - def _existing_rarities(self) -> list[ItemRarity]: - return [ - ItemRarity(tribute.rarities[0]) - for tribute in self.tributes - if tribute.rarities and not tribute.name and len(tribute.rarities) == 1 - ] + tribute_id = next(iter(Dataloader().tribute_dict.keys())) + new_rule = TributeFilterModel(name=tribute_id, rarities=[]) + self.tributes.append(new_rule) + self.add_tribute_widget(new_rule).open_config_dialog() + + def remove_tribute_item(self, widget: TributeSummaryWidget): + if widget.model in self.tributes: + self.tributes.remove(widget.model) + widget.setParent(None) + widget.deleteLater() From d7ebece37738e4149c8fe5183164f24cfc4abc62 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 20:41:39 -0400 Subject: [PATCH 06/17] Fix for affixed highlight breakage --- src/config/profile_models.py | 4 +-- src/item/filter.py | 63 ++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index f1c506ac..8d63a16e 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -185,7 +185,7 @@ def unique_aspect_names_must_be_unique(self) -> GlobalUniqueModel: msg = "uniqueAspect names must be unique" raise ValueError(msg) if not self.affix_pool: - self.affix_pool = [AffixFilterCountModel(count=[], min_count=1)] + self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] if not self.inherent_pool: self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self @@ -233,7 +233,7 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: msg = "uniqueAspect names must be unique" raise ValueError(msg) if not self.affix_pool: - self.affix_pool = [AffixFilterCountModel(count=[], min_count=3)] + self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] if not self.inherent_pool: self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self diff --git a/src/item/filter.py b/src/item/filter.py index f48e7c3f..81d6a55a 100644 --- a/src/item/filter.py +++ b/src/item/filter.py @@ -122,22 +122,22 @@ def _check_affixes(self, item: Item) -> FilterResult: # check affixes matched_affixes = [] if filter_spec.affix_pool: - matched_affixes = self._match_affixes_count( + success, matched_affixes = self._match_affixes_count( expected_affixes=filter_spec.affix_pool, item_affixes=non_tempered_affixes, min_greater_affix_count=filter_spec.min_greater_affix_count, ) - if not matched_affixes: + if not success: continue # check inherent matched_inherents = [] if filter_spec.inherent_pool: - matched_inherents = self._match_affixes_count( + success, matched_inherents = self._match_affixes_count( expected_affixes=filter_spec.inherent_pool, item_affixes=item.inherent, min_greater_affix_count=filter_spec.min_greater_affix_count, ) - if not matched_inherents: + if not success: continue all_matches = matched_affixes + matched_inherents # Build a detailed string showing which affixes are GAs @@ -263,6 +263,7 @@ def _check_tribute(self, item: Item) -> FilterResult: def _check_global_unique_filter(self, item: Item) -> FilterResult: res = FilterResult(keep=False, matched=[]) + non_tempered_affixes = [affix for affix in item.affixes if affix.type != AffixType.tempered] if not self.global_unique_filters: keep = IniConfigLoader().general.handle_uniques != UnfilteredUniquesType.junk @@ -282,12 +283,38 @@ def _check_global_unique_filter(self, item: Item) -> FilterResult: expected_percent=filter_item.min_percent_of_aspect, item_aspect_or_affix=item.aspect ): continue - LOGGER.info(f"{item.original_name} -- Matched {profile_name}.GlobalUniques: {item.aspect.name}") + + # Check affixes + matched_affixes = [] + if filter_item.affix_pool: + success, matched_affixes = self._match_affixes_count( + expected_affixes=filter_item.affix_pool, + item_affixes=non_tempered_affixes, + min_greater_affix_count=filter_item.min_greater_affix_count, + ) + if not success: + continue + + # Check inherent + matched_inherents = [] + if filter_item.inherent_pool: + success, matched_inherents = self._match_affixes_count( + expected_affixes=filter_item.inherent_pool, + item_affixes=item.inherent, + min_greater_affix_count=filter_item.min_greater_affix_count, + ) + if not success: + continue + + all_matches = matched_affixes + matched_inherents + LOGGER.info( + f"{item.original_name} -- Matched {profile_name}.GlobalUniques: {item.aspect.name if item.aspect else 'Rule'}" + ) res.keep = True - matched_full_name = f"{profile_name}.{item.aspect.name}" + matched_full_name = f"{profile_name}.{item.aspect.name if item.aspect else 'Unique'}" if filter_item.profile_alias: - matched_full_name = f"{filter_item.profile_alias}.{item.aspect.name}" - res.matched.append(MatchedFilter(matched_full_name, aspect_match=True)) + matched_full_name = f"{filter_item.profile_alias}.{item.aspect.name if item.aspect else 'Unique'}" + res.matched.append(MatchedFilter(matched_full_name, all_matches, aspect_match=True)) return res @@ -309,20 +336,30 @@ def _did_files_change(self) -> bool: def _match_affixes_count( self, expected_affixes: list[AffixFilterCountModel], item_affixes: list[Affix], min_greater_affix_count: int = 0 - ) -> list[Affix]: + ) -> tuple[bool, list[Affix]]: result = [] for count_group in expected_affixes: group_res = [] + # Track required matches + required_names = [a.name for a in count_group.count if getattr(a, "required", False)] + matched_required_names = set() + # Do the normal affix matching first for affix in count_group.count: matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None) if matched_item_affix is not None and self._match_item_aspect_or_affix(affix, matched_item_affix): group_res.append(matched_item_affix) + if getattr(affix, "required", False): + matched_required_names.add(affix.name) + + # Check if all required affixes matched + if len(matched_required_names) < len(required_names): + return False, [] # Check minCount and maxCount if not (count_group.min_count <= len(group_res) <= count_group.max_count): - return [] # if one group fails, everything fails + return False, [] # if one group fails, everything fails # Check want_greater requirements (2-mode system) want_greater_affixes = [a for a in count_group.count if getattr(a, "want_greater", False)] @@ -334,7 +371,7 @@ def _match_affixes_count( for affix in want_greater_affixes: matched_item_affix = next((a for a in item_affixes if a.name == affix.name), None) if matched_item_affix is None or matched_item_affix.type != AffixType.greater: - return [] # Flagged affix is missing or not GA, fail + return False, [] # Flagged affix is missing or not GA, fail else: # Mode 2: At least min_greater_affix_count of the flagged affixes must be GA (flexible) flagged_ga_count = sum( @@ -344,10 +381,10 @@ def _match_affixes_count( and matched.type == AffixType.greater ) if flagged_ga_count < min_greater_affix_count: - return [] # Not enough flagged affixes are GA + return False, [] # Not enough flagged affixes are GA result.extend(group_res) - return result + return True, result @staticmethod def _match_affixes_sigils( From b8c08ab208c0ed4459fc438a1fd4d32df50dbf13 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 21:42:02 -0400 Subject: [PATCH 07/17] additional affix pools for adv users --- src/config/profile_models.py | 1 + src/gui/profile_editor/affixes_tab.py | 328 ++++++++++++------- src/gui/profile_editor/global_uniques_tab.py | 293 ++++++++++------- 3 files changed, 380 insertions(+), 242 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index 8d63a16e..bcdc6424 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -186,6 +186,7 @@ def unique_aspect_names_must_be_unique(self) -> GlobalUniqueModel: raise ValueError(msg) if not self.affix_pool: self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] + self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] if not self.inherent_pool: self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 1bd5a82e..45d9bcba 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -315,14 +315,19 @@ def _create_summary_card_style() -> str: """ -def _create_column_header(title: str, add_callback: callable) -> QWidget: +def _create_column_header(title: str, add_callback: callable, remove_callback: callable | None = None) -> QWidget: header = QWidget() layout = QHBoxLayout(header) layout.setContentsMargins(5, 5, 5, 5) - spacer = QWidget() - spacer.setFixedWidth(30) - layout.addWidget(spacer) + if remove_callback: + btn = _create_delete_btn() + btn.clicked.connect(remove_callback) + layout.addWidget(btn) + else: + spacer = QWidget() + spacer.setFixedWidth(30) + layout.addWidget(spacer) layout.addStretch() lbl = QLabel(title) @@ -548,7 +553,9 @@ class AffixGroupEditor(QWidget): def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): super().__init__(parent) self.settings = QSettings("d4lf", "profile_editor") - self.affix_footer = None + self.affix_column_widgets = [] + self.affix_pool_layouts = [] + self.affix_footers = [] self.inherent_footer = None self.dynamic_filter = dynamic_filter for item_name, config in dynamic_filter.root.items(): @@ -562,44 +569,51 @@ def setup_ui(self): self.content_layout = QVBoxLayout(self) self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - general_form = QFormLayout() + # Row 1: Item Alias, Min Power, Duplicate Button + top_row_layout = QHBoxLayout() + top_row_layout.setContentsMargins(0, 0, 0, 0) - # Item Alias/Name - alias_layout = QHBoxLayout() + top_row_layout.addWidget(QLabel("Item Name / Alias:")) self.alias_edit = QLineEdit() self.alias_edit.setText(self.item_name) - self.alias_edit.setMaximumWidth(300) + self.alias_edit.setFixedWidth(200) self.alias_edit.textChanged.connect(self.update_item_alias) - alias_layout.addWidget(self.alias_edit) + top_row_layout.addWidget(self.alias_edit) - duplicate_btn = QPushButton("Duplicate Item") - duplicate_btn.setFixedWidth(120) - duplicate_btn.clicked.connect(self._on_duplicate_clicked) - alias_layout.addWidget(duplicate_btn) - alias_layout.addStretch() - - general_form.addRow("Item Name / Alias:", alias_layout) + top_row_layout.addSpacing(30) + top_row_layout.addWidget(QLabel("Minimum Power:")) self.min_power = IgnoreScrollWheelSpinBox() self.min_power.setMaximum(MAX_POWER) self.min_power.setValue(self.config.min_power) - self.min_power.setMaximumWidth(150) + self.min_power.setFixedWidth(80) self.min_power.valueChanged.connect(self.update_min_power) - general_form.addRow("Minimum Power:", self.min_power) + top_row_layout.addWidget(self.min_power) - min_greater_layout = QHBoxLayout() + top_row_layout.addStretch() + + duplicate_btn = QPushButton("Duplicate Item") + duplicate_btn.setFixedWidth(120) + duplicate_btn.clicked.connect(self._on_duplicate_clicked) + top_row_layout.addWidget(duplicate_btn) + self.content_layout.addLayout(top_row_layout) + + # Row 2: Min Greater Affixes, Auto Sync, Add Pool Button + ga_row_layout = QHBoxLayout() + ga_row_layout.setContentsMargins(0, 5, 0, 10) + + ga_row_layout.addWidget(QLabel("Min Greater Affixes:")) self.min_greater = CharacterSpinBox() + self.min_greater.set_range(0, 4) self.min_greater.set_value(self.config.min_greater_affix_count) - self.min_greater.set_maximum(4) - self.min_greater.set_minimum(0) self.min_greater.setFixedWidth(100) self.min_greater.setToolTip( "Minimum number of checked affixes that must be Greater Affixes.\n" "0 = Accept items even without GAs (for leveling)\n" "1-4 = At least this many checked affixes must be GA" ) - self.min_greater.value_changed.connect(self.update_min_greater_affix) + self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin) self.auto_sync_checkbox = QCheckBox("Auto Sync") self.auto_sync_checkbox.setToolTip( @@ -616,10 +630,15 @@ def setup_ui(self): self._refresh_widget_style(self.greater_count_label) self.update_greater_count_label() - min_greater_layout.addWidget(self.min_greater) - min_greater_layout.addWidget(self.auto_sync_checkbox) - min_greater_layout.addWidget(self.greater_count_label) - min_greater_layout.addStretch() + ga_row_layout.addWidget(self.min_greater) + ga_row_layout.addWidget(self.auto_sync_checkbox) + ga_row_layout.addWidget(self.greater_count_label) + ga_row_layout.addStretch() + + add_pool_btn = QPushButton("Add Additional Affix Pool") + add_pool_btn.setFixedWidth(180) + add_pool_btn.clicked.connect(self.add_additional_affix_pool_column) + ga_row_layout.addWidget(add_pool_btn) self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked()) @@ -627,52 +646,25 @@ def setup_ui(self): self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003 self._refresh_widget_style(self.min_greater) - general_form.addRow("Min Greater Affixes:", min_greater_layout) - self.content_layout.addLayout(general_form) + self.content_layout.addLayout(ga_row_layout) # 3-Column Layout columns_layout = QHBoxLayout() columns_layout.setSpacing(15) + self.columns_layout = columns_layout - def create_col(title, add_cb, pool_model=None): - col_widget = QWidget() - col_layout = QVBoxLayout(col_widget) - col_layout.setContentsMargins(0, 0, 0, 0) - col_layout.setSpacing(0) - - header = _create_column_header(title, add_cb) - col_layout.addWidget(header) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") - - inner = QWidget() - inner_layout = QVBoxLayout(inner) - inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - scroll.setWidget(inner) - col_layout.addWidget(scroll) - - footer = None - if pool_model is not None: - footer = _create_column_footer(pool_model, self.update_greater_count_label) - footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") - col_layout.addWidget(footer) - - return col_widget, inner_layout, footer - - # Init columns - self.aspect_col, self.aspect_rows_layout, _ = create_col("Unique Aspects", self.add_unique_aspect) - self.affix_col, self.affix_pool_layout, self.affix_footer = create_col( - "Affix Pool", self.add_affix_pool, self.config.affix_pool[0] - ) - self.inherent_col, self.inherent_pool_layout, self.inherent_footer = create_col( + # Column 1: Unique Aspects + self.aspect_col, self.aspect_rows_layout, _ = self._create_col_helper("Unique Aspects", self.add_unique_aspect) + columns_layout.addWidget(self.aspect_col) + + # Column(s) 2: Affix Pool(s) + for pool in self.config.affix_pool: + self._add_affix_pool_column_widget(pool) + + # Column 3: Inherent Pool + self.inherent_col, self.inherent_pool_layout, self.inherent_footer = self._create_col_helper( "Inherent Pool", self.add_inherent_pool, self.config.inherent_pool[0] ) - - columns_layout.addWidget(self.aspect_col) - columns_layout.addWidget(self.affix_col) columns_layout.addWidget(self.inherent_col) self.inherent_col.hide() @@ -691,41 +683,19 @@ def init_unique_aspects(self): self.add_unique_aspect_item(aspect) def init_affix_pool(self): - for affix in self.config.affix_pool[0].count: - self.add_affix_item(affix, inherent=False) + for i, pool in enumerate(self.config.affix_pool): + for affix in pool.count: + self.add_affix_item(affix, inherent=False, pool_idx=i) def init_inherent_pool(self): - for affix in self.config.inherent_pool[0].count: - self.add_affix_item(affix, inherent=True) + for i, pool in enumerate(self.config.inherent_pool): + for affix in pool.count: + self.add_affix_item(affix, inherent=True, pool_idx=i) def _refresh_widget_style(self, widget): widget.style().unpolish(widget) widget.style().polish(widget) - def update_item_alias(self, text: str): - new_name = text.strip() - if not new_name or new_name == self.item_name: - return - - # Update root dictionary key - if self.item_name in self.dynamic_filter.root: - self.dynamic_filter.root[new_name] = self.dynamic_filter.root.pop(self.item_name) - self.item_name = new_name - - # Signal parent to refresh tab text - p = self.parent() - while p: - if isinstance(p, AffixesTab): - # Find index of this item in the parent's map - if self.item_name in p.item_data_map: - idx = p.item_names.index( - next(k for k, v in p.item_data_map.items() if v == self.dynamic_filter) - ) - p.item_names[idx] = self.item_name - p.tab_widget.setTabText(idx, self.item_name) - break - p = p.parent() - def add_unique_aspect_item(self, model: AspectUniqueFilterModel): widget = UniqueAspectWidget(model) widget.delete_requested.connect(lambda: self.remove_unique_aspect_widget(widget)) @@ -748,18 +718,18 @@ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): widget.setParent(None) widget.deleteLater() - def add_affix_item(self, model: AffixFilterModel, inherent: bool = False): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + def add_affix_item(self, model: AffixFilterModel, inherent: bool = False, pool_idx: int = 0): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] widget = AffixSummaryWidget(model) - widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent)) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent, pool_idx)) widget.config_changed.connect(self.update_greater_count_label) layout.addWidget(widget) return widget - def remove_affix_item_widget(self, widget, inherent: bool): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layout - pool = self.config.inherent_pool[0] if inherent else self.config.affix_pool[0] + def remove_affix_item_widget(self, widget, inherent: bool, pool_idx: int = 0): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] + pool = self.config.inherent_pool[0] if inherent else self.config.affix_pool[pool_idx] idx = layout.indexOf(widget) if idx != -1: @@ -768,7 +738,8 @@ def remove_affix_item_widget(self, widget, inherent: bool): widget.deleteLater() self.update_greater_count_label() - def add_affix_pool(self): + def add_affix_to_pool(self, pool_model: AffixFilterCountModel): + idx = self.config.affix_pool.index(pool_model) common_affixes = ["Energy", "Strength", "Dexterity", "Vitality", "Intelligence"] default_name = None reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} @@ -780,8 +751,12 @@ def add_affix_pool(self): default_name = next(iter(Dataloader().affix_dict.keys())) default_affix = AffixFilterModel(name=default_name, value=None) - self.config.affix_pool[0].count.append(default_affix) - self.add_affix_item(default_affix).open_config_dialog() + pool_model.count.append(default_affix) + self.add_affix_item(default_affix, pool_idx=idx).open_config_dialog() + + def add_affix_pool(self): + if self.config.affix_pool: + self.add_affix_to_pool(self.config.affix_pool[0]) def add_inherent_pool(self): common_affixes = ["Strength", "Dexterity", "Vitality", "Intelligence"] @@ -820,12 +795,115 @@ def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): def reorganize_pool(self, layout_widget: QVBoxLayout): pass + def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): + col_widget = QWidget() + col_layout = QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(0) + + header = _create_column_header(title, add_cb, remove_cb) + col_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(inner) + col_layout.addWidget(scroll) + + footer = None + if pool_model is not None: + footer = _create_column_footer(pool_model, self.update_greater_count_label) + footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + col_layout.addWidget(footer) + + return col_widget, inner_layout, footer + + def _add_affix_pool_column_widget(self, pool_model: AffixFilterCountModel): + def add_cb(): + self.add_affix_to_pool(pool_model) + + # Only provide a remove callback for additional pools (index > 0) + is_additional = self.config.affix_pool.index(pool_model) > 0 + remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None + + col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb) + + # Check if inherent_col is in the layout to ensure correct order + inherent_idx = -1 + if hasattr(self, "inherent_col"): + inherent_idx = self.columns_layout.indexOf(self.inherent_col) + + if inherent_idx != -1: + self.columns_layout.insertWidget(inherent_idx, col_widget) + else: + self.columns_layout.addWidget(col_widget) + + self.affix_column_widgets.append(col_widget) + self.affix_pool_layouts.append(inner_layout) + self.affix_footers.append(footer) + + def add_additional_affix_pool_column(self): + new_pool = AffixFilterCountModel(count=[], min_count=1) + self.config.affix_pool.append(new_pool) + self._add_affix_pool_column_widget(new_pool) + + def remove_affix_pool_column(self, pool_model: AffixFilterCountModel): + reply = QMessageBox.question( + self, + "Confirm Deletion", + "Are you sure you want to delete this entire affix pool?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + idx = self.config.affix_pool.index(pool_model) + self.config.affix_pool.pop(idx) + + widget = self.affix_column_widgets.pop(idx) + self.affix_pool_layouts.pop(idx) + self.affix_footers.pop(idx) + + widget.setParent(None) + widget.deleteLater() + self.update_greater_count_label() + + def update_item_alias(self, new_name: str): + new_name = new_name.strip() + if not new_name or new_name == self.item_name: + return + + if self.item_name in self.dynamic_filter.root: + model = self.dynamic_filter.root.pop(self.item_name) + self.dynamic_filter.root[new_name] = model + + old_name = self.item_name + self.item_name = new_name + + p = self.parent() + while p: + if isinstance(p, AffixesTab): + if old_name in p.item_names: + idx = p.item_names.index(old_name) + p.item_names[idx] = new_name + p.item_data_map.pop(old_name, None) + p.item_data_map[new_name] = self.dynamic_filter + p.tab_widget.setTabText(idx, new_name) + break + p = p.parent() + def update_min_power(self): self.config.min_power = self.min_power.value() def update_min_greater_affix(self): self.config.min_greater_affix_count = self.min_greater.value() + def update_min_greater_affix_from_spin(self, value): + self.config.min_greater_affix_count = value + def toggle_auto_sync(self): is_auto_sync = self.auto_sync_checkbox.isChecked() @@ -863,11 +941,12 @@ def iter_affix_widgets(self): return [] def refresh_all_summaries(self): - for layout in (self.affix_pool_layout, self.inherent_pool_layout): - for i in range(layout.count()): - w = layout.itemAt(i).widget() - if isinstance(w, AffixPoolWidget): - w.refresh_display() + for layouts in [self.affix_pool_layouts, [self.inherent_pool_layout]]: + for layout in layouts: + for i in range(layout.count()): + w = layout.itemAt(i).widget() + if isinstance(w, AffixSummaryWidget): + w.refresh_display() for i in range(self.aspect_rows_layout.count()): w = self.aspect_rows_layout.itemAt(i).widget() if isinstance(w, UniqueAspectWidget): @@ -892,19 +971,22 @@ def update_greater_count_label(self): else: self.greater_count_label.setText(f"({count} greater affixes marked)") - # Update pool footers with new Min Count constraints - for footer, model in [ - (self.affix_footer, self.config.affix_pool[0]), - (self.inherent_footer, self.config.inherent_pool[0]), - ]: - if footer and model: - min_spin = footer.property("min_spin") - if min_spin: - min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) - min_spin.set_minimum(min_allowed) - if model.min_count < min_allowed: - model.min_count = min_allowed - min_spin.set_value(min_allowed) + # Update affix pool footers + for footer, model in zip(self.affix_footers, self.config.affix_pool, strict=False): + self._update_footer_constraints(footer, model) + + # Update inherent pool footer + self._update_footer_constraints(self.inherent_footer, self.config.inherent_pool[0]) + + def _update_footer_constraints(self, footer, model): + if footer and model: + min_spin = footer.property("min_spin") + if min_spin: + min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) + min_spin.set_minimum(min_allowed) + if model.min_count < min_allowed: + model.min_count = min_allowed + min_spin.set_value(min_allowed) def convert_all_to_min_percent_of_affix(self, percent: int): for affix_widget in self.iter_affix_widgets(): @@ -961,7 +1043,7 @@ def refresh_display(self): elif self.unique_aspect.value is not None: self.threshold_label.setText(str(self.unique_aspect.value)) else: - self.threshold_label.setText("No Threshold") + self.threshold_label.setText("Any") def update_name(self, current_text=None): aspect_name = current_text or self.name_combo.currentText() @@ -1079,7 +1161,7 @@ def refresh_display(self): elif self.model.value is not None: self.threshold_label.setText(str(self.model.value)) else: - self.threshold_label.setText("No Threshold") + self.threshold_label.setText("Any") class AffixPoolWidget(QWidget): diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 3ca03fc7..0648ff58 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -1,15 +1,15 @@ import copy -from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, QTimer, pyqtSignal +from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal from PyQt6.QtWidgets import ( QCheckBox, QDialog, - QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPushButton, QScrollArea, QSizePolicy, @@ -19,7 +19,12 @@ QWidget, ) -from src.config.profile_models import AffixFilterModel, AspectUniqueFilterModel, GlobalUniqueModel +from src.config.profile_models import ( + AffixFilterCountModel, + AffixFilterModel, + AspectUniqueFilterModel, + GlobalUniqueModel, +) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER from src.gui.models.dialog import DeleteItem, IgnoreScrollWheelSpinBox @@ -45,7 +50,9 @@ def __init__(self, unique_model: GlobalUniqueModel, parent=None): super().__init__(parent) self.settings = QSettings("d4lf", "profile_editor") self.unique_model = unique_model - self.affix_footer = None + self.affix_column_widgets = [] + self.affix_pool_layouts = [] + self.affix_footers = [] self.inherent_footer = None self.item_types = [ item for item in ItemType.__members__.values() if is_armor(item) or is_jewelry(item) or is_weapon(item) @@ -60,83 +67,125 @@ def setup_ui(self): self.create_general_groupbox() # Rule Content - columns_layout = QHBoxLayout() - columns_layout.setSpacing(15) - - def create_col(title, add_cb, pool_model=None): - col_widget = QWidget() - col_layout = QVBoxLayout(col_widget) - col_layout.setContentsMargins(0, 0, 0, 0) - col_layout.setSpacing(0) - - header = _create_column_header(title, add_cb) - col_layout.addWidget(header) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet( - "QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; border-bottom: none; }" - ) - - inner = QWidget() - inner_layout = QVBoxLayout(inner) - inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - scroll.setWidget(inner) - col_layout.addWidget(scroll) - - footer = None - if pool_model is not None: - footer = _create_column_footer(pool_model, self.update_greater_count_label) - footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") - col_layout.addWidget(footer) - - return col_widget, inner_layout, footer - - # Init columns - col1_w, self.aspect_rows_layout, _ = create_col("Unique Aspects", self.add_unique_aspect) - col2_w, self.affix_pool_layout, self.affix_footer = create_col( - "Affix Pool", self.add_affix_pool, self.unique_model.affix_pool[0] - ) - col3_w, self.inherent_pool_layout, self.inherent_footer = create_col( + self.columns_layout = QHBoxLayout() + self.columns_layout.setSpacing(15) + + # Column 1: Unique Aspects + self.aspect_col, self.aspect_rows_layout, _ = self._create_col_helper("Unique Aspects", self.add_unique_aspect) + self.columns_layout.addWidget(self.aspect_col) + + # Column(s) 2: Affix Pool(s) + for pool in self.unique_model.affix_pool: + self._add_affix_pool_column_widget(pool) + + # Column 3: Inherent Pool (Hidden) + self.inherent_col, self.inherent_pool_layout, self.inherent_footer = self._create_col_helper( "Inherent Pool", self.add_inherent_pool, self.unique_model.inherent_pool[0] ) + self.columns_layout.addWidget(self.inherent_col) + self.inherent_col.hide() - columns_layout.addWidget(col1_w) - columns_layout.addWidget(col2_w) - columns_layout.addWidget(col3_w) - col3_w.hide() - - self.content_layout.addLayout(columns_layout) + self.content_layout.addLayout(self.columns_layout) # Initialize content self.init_aspects() self.init_affix_pool() self.init_inherent_pool() + def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): + col_widget = QWidget() + col_layout = QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(0) + + header = _create_column_header(title, add_cb, remove_cb) + col_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; border-bottom: none; }" + ) + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + scroll.setWidget(inner) + col_layout.addWidget(scroll) + + footer = None + if pool_model is not None: + footer = _create_column_footer(pool_model, self.update_greater_count_label) + footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + col_layout.addWidget(footer) + + return col_widget, inner_layout, footer + + def _add_affix_pool_column_widget(self, pool_model: AffixFilterCountModel): + def add_cb(): + self.add_affix_to_pool(pool_model) + + # Only provide a remove callback for additional pools (index > 0) + is_additional = self.unique_model.affix_pool.index(pool_model) > 0 + remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None + + col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb) + insert_idx = self.columns_layout.count() - 1 + self.columns_layout.insertWidget(insert_idx, col_widget) + self.affix_column_widgets.append(col_widget) + self.affix_pool_layouts.append(inner_layout) + self.affix_footers.append(footer) + + def add_additional_affix_pool_column(self): + new_pool = AffixFilterCountModel(count=[], min_count=1) + self.unique_model.affix_pool.append(new_pool) + self._add_affix_pool_column_widget(new_pool) + + def remove_affix_pool_column(self, pool_model: AffixFilterCountModel): + reply = QMessageBox.question( + self, + "Confirm Deletion", + "Are you sure you want to delete this entire affix pool?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + idx = self.unique_model.affix_pool.index(pool_model) + self.unique_model.affix_pool.pop(idx) + + widget = self.affix_column_widgets.pop(idx) + self.affix_pool_layouts.pop(idx) + self.affix_footers.pop(idx) + + widget.setParent(None) + widget.deleteLater() + self.update_greater_count_label() + def init_aspects(self): for aspect in self.unique_model.unique_aspect: self.add_unique_aspect_item(aspect) def init_affix_pool(self): - for affix in self.unique_model.affix_pool[0].count: - self.add_affix_item(affix, inherent=False) + for i, pool in enumerate(self.unique_model.affix_pool): + for affix in pool.count: + self.add_affix_item(affix, inherent=False, pool_idx=i) def init_inherent_pool(self): - for affix in self.unique_model.inherent_pool[0].count: - self.add_affix_item(affix, inherent=True) + for i, pool in enumerate(self.unique_model.inherent_pool): + for affix in pool.count: + self.add_affix_item(affix, inherent=True, pool_idx=i) - def add_affix_item(self, model: AffixFilterModel, inherent: bool = False): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layout + def add_affix_item(self, model: AffixFilterModel, inherent: bool = False, pool_idx: int = 0): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] widget = AffixSummaryWidget(model) - widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent)) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent, pool_idx)) widget.config_changed.connect(self.update_greater_count_label) layout.addWidget(widget) return widget - def remove_affix_item_widget(self, widget, inherent: bool): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layout - pool = self.unique_model.inherent_pool[0] if inherent else self.unique_model.affix_pool[0] + def remove_affix_item_widget(self, widget, inherent: bool, pool_idx: int = 0): + layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] + pool = self.unique_model.inherent_pool[0] if inherent else self.unique_model.affix_pool[pool_idx] idx = layout.indexOf(widget) if idx != -1: pool.count.pop(idx) @@ -146,51 +195,59 @@ def remove_affix_item_widget(self, widget, inherent: bool): def create_general_groupbox(self): self.general_groupbox = QGroupBox() - self.general_groupbox.setTitle("Global Unique Rule") - self.general_form = QFormLayout() + self.general_groupbox.setTitle("Global Unique Rule Configuration") + + main_vbox = QVBoxLayout(self.general_groupbox) + main_vbox.setContentsMargins(10, 15, 10, 10) # Profile Alias / Name - alias_layout = QHBoxLayout() + top_row = QHBoxLayout() + top_row.addWidget(QLabel("Rule Alias:")) self.profile_alias = QLineEdit() - self.profile_alias.setMaximumWidth(300) + self.profile_alias.setFixedWidth(200) self.profile_alias.setText(self.unique_model.profile_alias) self.profile_alias.textChanged.connect(self.update_profile_alias) - alias_layout.addWidget(self.profile_alias) + top_row.addWidget(self.profile_alias) + + top_row.addSpacing(30) + + top_row.addWidget(QLabel("Minimum Power:")) + self.min_power = IgnoreScrollWheelSpinBox() + self.min_power.setRange(0, MAX_POWER) + self.min_power.setValue(self.unique_model.min_power) + self.min_power.setFixedWidth(80) + self.min_power.valueChanged.connect(self.update_min_power) + top_row.addWidget(self.min_power) + + top_row.addStretch() duplicate_btn = QPushButton("Duplicate Rule") duplicate_btn.setFixedWidth(120) duplicate_btn.clicked.connect(self._on_duplicate_clicked) - alias_layout.addWidget(duplicate_btn) - alias_layout.addStretch() - - self.general_form.addRow("Rule Alias:", alias_layout) + top_row.addWidget(duplicate_btn) + main_vbox.addLayout(top_row) # Item Types (Slots) + slots_row = QHBoxLayout() + slots_row.addWidget(QLabel("Target Slots:")) self.item_type_line_edit = QLineEdit() self.item_type_line_edit.setReadOnly(True) self.refresh_item_type_summary() - item_type_layout = QHBoxLayout() - item_type_layout.addWidget(self.item_type_line_edit) + slots_row.addWidget(self.item_type_line_edit) edit_item_types_btn = QPushButton("Select Slots") edit_item_types_btn.setMaximumWidth(100) edit_item_types_btn.clicked.connect(self.edit_item_types) - item_type_layout.addWidget(edit_item_types_btn) - self.general_form.addRow("Target Slots:", item_type_layout) - - self.min_power = IgnoreScrollWheelSpinBox() - self.min_power.setRange(0, MAX_POWER) - self.min_power.setValue(self.unique_model.min_power) - self.min_power.setMaximumWidth(150) - self.min_power.valueChanged.connect(self.update_min_power) - self.general_form.addRow("Minimum Power:", self.min_power) + slots_row.addWidget(edit_item_types_btn) + main_vbox.addLayout(slots_row) # Min Greater Affixes with Auto Sync - min_greater_layout = QHBoxLayout() + ga_row = QHBoxLayout() + ga_row.addWidget(QLabel("Min Greater Affixes:")) self.min_greater = CharacterSpinBox() self.min_greater.set_range(0, 4) self.min_greater.set_value(self.unique_model.min_greater_affix_count) self.min_greater.setFixedWidth(100) - self.min_greater.value_changed.connect(self.update_min_greater_affix) + self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin) self.auto_sync_checkbox = QCheckBox("Auto Sync") self.auto_sync_checkbox.setChecked( @@ -200,17 +257,19 @@ def create_general_groupbox(self): self.greater_count_label = QLabel() self.greater_count_label.setStyleSheet("color: gray; font-style: italic;") - min_greater_layout.addWidget(self.min_greater) - min_greater_layout.addWidget(self.auto_sync_checkbox) - min_greater_layout.addWidget(self.greater_count_label) - min_greater_layout.addStretch() + ga_row.addWidget(self.min_greater) + ga_row.addWidget(self.auto_sync_checkbox) + ga_row.addWidget(self.greater_count_label) + ga_row.addStretch() - self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked()) - self.general_form.addRow("Min Greater Affixes:", self.min_greater) + add_pool_btn = QPushButton("Add Additional Affix Pool") + add_pool_btn.setFixedWidth(180) + add_pool_btn.clicked.connect(self.add_additional_affix_pool_column) + ga_row.addWidget(add_pool_btn) + main_vbox.addLayout(ga_row) - self.general_groupbox.setLayout(self.general_form) + self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked()) self.content_layout.addWidget(self.general_groupbox) - QTimer.singleShot(100, self.update_greater_count_label) def _on_duplicate_clicked(self): self.duplicate_requested.emit(self.unique_model) @@ -233,11 +292,16 @@ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): widget.setParent(None) widget.deleteLater() - def add_affix_pool(self): + def add_affix_to_pool(self, pool_model: AffixFilterCountModel): + idx = self.unique_model.affix_pool.index(pool_model) affix_name = next(iter(Dataloader().affix_dict.keys())) new_affix = AffixFilterModel(name=affix_name) - self.unique_model.affix_pool[0].count.append(new_affix) - self.add_affix_item(new_affix).open_config_dialog() + pool_model.count.append(new_affix) + self.add_affix_item(new_affix, pool_idx=idx).open_config_dialog() + + def add_affix_pool(self): + if self.unique_model.affix_pool: + self.add_affix_to_pool(self.unique_model.affix_pool[0]) def add_inherent_pool(self): affix_name = next(iter(Dataloader().affix_dict.keys())) @@ -268,31 +332,22 @@ def update_greater_count_label(self): with QSignalBlocker(self.min_greater): self.min_greater.set_value(count) - # Update pool footers with new Min Count constraints - for footer, model in [ - (self.affix_footer, self.unique_model.affix_pool[0]), - (self.inherent_footer, self.unique_model.inherent_pool[0]), - ]: - if footer and model: - min_spin = footer.property("min_spin") - if min_spin: - min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) - min_spin.set_minimum(min_allowed) - if model.min_count < min_allowed: - model.min_count = min_allowed - min_spin.set_value(min_allowed) - # for footer, model in [ - # (self.affix_footer, self.unique_model.affix_pool[0]), - # (self.inherent_footer, self.unique_model.inherent_pool[0]), - # ]: - # if footer and model: - # min_spin = footer.property("min_spin") - # if min_spin: - # min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) - # min_spin.set_minimum(min_allowed) - # if model.min_count < min_allowed: - # model.min_count = min_allowed - # min_spin.set_value(min_allowed) + # Update affix pool footers + for footer, model in zip(self.affix_footers, self.unique_model.affix_pool, strict=False): + self._update_footer_constraints(footer, model) + + # Update inherent pool footer + self._update_footer_constraints(self.inherent_footer, self.unique_model.inherent_pool[0]) + + def _update_footer_constraints(self, footer, model): + if footer and model: + min_spin = footer.property("min_spin") + if min_spin: + min_allowed = sum(1 for a in model.count if getattr(a, "required", False)) + min_spin.set_minimum(min_allowed) + if model.min_count < min_allowed: + model.min_count = min_allowed + min_spin.set_value(min_allowed) def refresh_item_type_summary(self): self.item_type_line_edit.setText(_item_type_summary(self.unique_model.item_type)) @@ -321,8 +376,8 @@ def update_min_power(self): def update_min_greater_affix(self): self.unique_model.min_greater_affix_count = self.min_greater.value() - def update_min_percent(self): - self.unique_model.min_percent_of_aspect = self.min_percent.value() + def update_min_greater_affix_from_spin(self, value: int): + self.unique_model.min_greater_affix_count = value class UniquesTab(QWidget): From 81d9a32b85163f2427cc2981184d35a350f55726 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Fri, 5 Jun 2026 22:35:23 -0400 Subject: [PATCH 08/17] style cleanup --- src/gui/profile_editor/affixes_tab.py | 74 +++++++++++++++---- src/gui/profile_editor/aspect_upgrades_tab.py | 30 +++++++- src/gui/profile_editor/global_uniques_tab.py | 13 ++-- src/gui/profile_editor/sigils_tab.py | 11 +++ src/gui/profile_editor/tributes_tab.py | 11 +++ 5 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 45d9bcba..deb4e3ec 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -3,7 +3,7 @@ from typing import override from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal -from PyQt6.QtGui import QDoubleValidator, QIntValidator +from PyQt6.QtGui import QDoubleValidator, QIntValidator, QPainter from PyQt6.QtWidgets import ( QCheckBox, QComboBox, @@ -21,6 +21,8 @@ QPushButton, QScrollArea, QSizePolicy, + QStyle, + QStyleOption, QTabBar, QTabWidget, QVBoxLayout, @@ -302,15 +304,17 @@ def _affix_card_summary(model: AffixFilterModel) -> str: def _create_summary_card_style() -> str: return """ - #SummaryCard { - border: 1px solid #3c3c3c; - border-radius: 6px; - background-color: #1a1a1a; + QWidget#SummaryCard { + border: 1px solid #2d2d2d; + border-left: 3px solid #3b82f6; + border-radius: 4px; + background-color: #1e1e1e; margin-bottom: 4px; } - #SummaryCard:hover { - background-color: #242424; - border-color: #5c5c5c; + QWidget#SummaryCard:hover { + background-color: #262626; + border-color: #404040; + border-left-color: #60a5fa; } """ @@ -348,9 +352,19 @@ def _create_column_header(title: str, add_callback: callable, remove_callback: c def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) -> QWidget: footer = QWidget() - layout = QHBoxLayout(footer) - layout.setContentsMargins(5, 5, 5, 5) + main_layout = QVBoxLayout(footer) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.setSpacing(2) + + flavor_lbl = QLabel("Set the quantity of affixes wanted for a match.") + flavor_lbl.setStyleSheet("color: #64748b; font-size: 10px; font-style: italic;") + flavor_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(flavor_lbl) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) + main_layout.addLayout(layout) layout.addStretch() @@ -499,6 +513,10 @@ def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): scroll = QScrollArea() scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent;") + scroll.viewport().setStyleSheet("background: transparent;") + self.rows_container = QWidget() self.rows_layout = QVBoxLayout(self.rows_container) self.rows_layout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -567,6 +585,7 @@ def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): def setup_ui(self): self.content_layout = QVBoxLayout(self) + self.content_layout.setContentsMargins(0, 10, 0, 0) self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Row 1: Item Alias, Min Power, Duplicate Button @@ -806,8 +825,15 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.viewport().setAutoFillBackground(False) + if pool_model is not None: + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #2d2d2d; border-bottom: none; background-color: #121212; }" + ) + else: + scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; background: transparent; }") + scroll.viewport().setStyleSheet("background: transparent;") inner = QWidget() inner_layout = QVBoxLayout(inner) @@ -1023,6 +1049,14 @@ def setup_ui(self): self.refresh_display() + @override + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + p = QPainter(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self) + p.end() + @override def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: @@ -1135,6 +1169,14 @@ def setup_ui(self): self.refresh_display() + @override + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + p = QPainter(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self) + p.end() + @override def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: @@ -1270,9 +1312,6 @@ def create_affix_name_combobox(self): self.name_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) if self.affix.name in Dataloader().affix_dict: self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name]) - self.name_combo.setCurrentText( - Dataloader().affix_dict[self.affix.name] - ) # TruncatingComboBox handles truncation self.name_combo.currentTextChanged.connect(self.update_name) def create_required_checkbox(self): @@ -1400,9 +1439,12 @@ def load(self): def setup_ui(self): """Populate the grid layout with existing groups.""" self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 5, 0, 5) + self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) + self.tab_widget.setStyleSheet( + "QTabWidget { background: transparent; } QTabWidget::pane { border: none; } QTabBar { background: transparent; }" + ) with QSignalBlocker(self.tab_widget): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_tab) diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index d70238d8..fae94ea7 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -1,8 +1,26 @@ +from typing import override + from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QDialog, QFrame, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from PyQt6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QSizePolicy, + QStyle, + QStyleOption, + QVBoxLayout, + QWidget, +) from src.gui.models.dialog import AddAspectUpgrade -from src.gui.profile_editor.affixes_tab import _create_column_header, _create_delete_btn, _create_summary_card_style +from src.gui.profile_editor.affixes_tab import ( + QPainter, + _create_column_header, + _create_delete_btn, + _create_summary_card_style, +) ASPECT_UPGRADES_TABNAME = "Aspect Upgrades" @@ -31,6 +49,14 @@ def setup_ui(self): self.delete_btn.clicked.connect(self.delete_requested.emit) self.main_layout.addWidget(self.delete_btn) + @override + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + p = QPainter(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self) + p.end() + class AspectUpgradesTab(QWidget): def __init__(self, aspect_upgrades: list[str], parent=None): diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 0648ff58..3b402644 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -35,7 +35,6 @@ UniqueAspectWidget, _create_column_footer, _create_column_header, - _create_summary_card_style, _item_type_summary, ) from src.item.data.item_type import ItemType, is_armor, is_jewelry, is_weapon @@ -61,8 +60,8 @@ def __init__(self, unique_model: GlobalUniqueModel, parent=None): def setup_ui(self): self.content_layout = QVBoxLayout(self) + self.content_layout.setContentsMargins(0, 10, 0, 0) self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setStyleSheet(_create_summary_card_style()) self.create_general_groupbox() @@ -103,9 +102,10 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.Panel) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.viewport().setAutoFillBackground(False) scroll.setStyleSheet( - "QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; border-bottom: none; }" + "QScrollArea { border: 1px solid #2d2d2d; background-color: #121212; border-bottom: none; }" ) inner = QWidget() @@ -396,8 +396,11 @@ def load(self): def setup_ui(self): self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 5, 0, 5) + self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) + self.tab_widget.setStyleSheet( + "QTabWidget { background: transparent; } QTabWidget::pane { border: none; } QTabBar { background: transparent; }" + ) with QSignalBlocker(self.tab_widget): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_tab) diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index 5308dcc1..32ac3c78 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -16,6 +16,8 @@ QPushButton, QScrollArea, QSizePolicy, + QStyle, + QStyleOption, QVBoxLayout, QWidget, ) @@ -24,6 +26,7 @@ from src.dataloader import Dataloader from src.gui.models.dialog import IgnoreScrollWheelComboBox from src.gui.profile_editor.affixes_tab import ( + QPainter, SelectionDialog, TruncatingComboBox, _create_column_header, @@ -76,6 +79,14 @@ def setup_ui(self): self.delete_btn.clicked.connect(self.delete_requested.emit) self.main_layout.addWidget(self.delete_btn) + @override + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + p = QPainter(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self) + p.end() + @override def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index 0af07810..1615915e 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -12,6 +12,8 @@ QLabel, QScrollArea, QSizePolicy, + QStyle, + QStyleOption, QVBoxLayout, QWidget, ) @@ -19,6 +21,7 @@ from src.config.profile_models import ItemRarity, TributeFilterModel from src.dataloader import Dataloader from src.gui.profile_editor.affixes_tab import ( + QPainter, TruncatingComboBox, _create_column_header, _create_delete_btn, @@ -64,6 +67,14 @@ def setup_ui(self): self.refresh_display() + @override + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + p = QPainter(self) + self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, p, self) + p.end() + @override def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: From 4fd7835c19e33e1e91ddc639ede917110a3a38f8 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 00:01:42 -0400 Subject: [PATCH 09/17] more importer cleanup --- src/gui/models/dialog.py | 24 ++- src/gui/profile_editor/affixes_tab.py | 197 ++++++++++-------- src/gui/profile_editor/aspect_upgrades_tab.py | 2 +- src/gui/profile_editor/global_uniques_tab.py | 62 +++++- src/gui/profile_editor/paper_doll.py | 4 +- src/gui/profile_editor/profile_editor.py | 19 +- src/gui/profile_editor/sigils_tab.py | 4 +- src/gui/profile_editor/tributes_tab.py | 8 +- 8 files changed, 205 insertions(+), 115 deletions(-) diff --git a/src/gui/models/dialog.py b/src/gui/models/dialog.py index 4ab9ee56..449c372a 100644 --- a/src/gui/models/dialog.py +++ b/src/gui/models/dialog.py @@ -1,6 +1,5 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QCheckBox, QComboBox, QCompleter, QDialog, @@ -28,6 +27,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER, PLAYER_CLASSES, normalize_profile_file_name +from src.gui.models.checkmark_checkbox import CheckmarkCheckBox from src.gui.settings_tab import IgnoreScrollWheelComboBox from src.item.data.item_type import ItemType @@ -36,6 +36,16 @@ class IgnoreScrollWheelSpinBox(QSpinBox): def __init__(self): super().__init__() self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self.setStyleSheet(""" + QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + } + QSpinBox:focus { border-color: #3b82f6; } + """) def wheelEvent(self, event): # noqa: N802 if self.hasFocus(): @@ -259,7 +269,7 @@ def __init__(self, item_names, parent=None): self.checkbox_list = [] for name in item_names: - checkbox = QCheckBox(name) + checkbox = CheckmarkCheckBox(name) scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) @@ -308,7 +318,7 @@ def __init__(self, nb_affix_pool, inherent: bool = False, parent=None): self.checkbox_list = [] for i in range(nb_affix_pool): - checkbox = QCheckBox(f"Count {i}") + checkbox = CheckmarkCheckBox(f"Count {i}") scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) @@ -415,7 +425,7 @@ def __init__(self, sigils: list[str], blacklist: bool = False, parent=None): self.checkbox_list = [] for sigil in self.sigils: - checkbox = QCheckBox(sigil) + checkbox = CheckmarkCheckBox(sigil) scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) @@ -563,7 +573,7 @@ def __init__(self, tributes: list[str], parent=None): self.checkbox_list = [] for tribute in self.tributes: - checkbox = QCheckBox(Dataloader().tribute_dict[tribute]) if tribute else QCheckBox("None") + checkbox = CheckmarkCheckBox(Dataloader().tribute_dict[tribute]) if tribute else CheckmarkCheckBox("None") scrollable_layout.addWidget(checkbox) self.checkbox_list.append(checkbox) scroll_widget.setLayout(scrollable_layout) @@ -647,8 +657,8 @@ def __init__(self, parent=None): self.checkbox_list = [] - checkbox_aspect = QCheckBox("Aspect") - checkbox_affixe = QCheckBox("Affixes") + checkbox_aspect = CheckmarkCheckBox("Aspect") + checkbox_affixe = CheckmarkCheckBox("Affixes") self.groupbox_layout.addWidget(checkbox_aspect) self.groupbox_layout.addWidget(checkbox_affixe) self.checkbox_list.append(checkbox_aspect) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index deb4e3ec..87477fab 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -5,7 +5,6 @@ from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal from PyQt6.QtGui import QDoubleValidator, QIntValidator, QPainter from PyQt6.QtWidgets import ( - QCheckBox, QComboBox, QCompleter, QDialog, @@ -38,6 +37,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER +from src.gui.models.checkmark_checkbox import CheckmarkCheckBox from src.gui.models.dialog import ( CreateItem, DeleteAffixPool, @@ -86,79 +86,33 @@ def _get_display_text(self, text: str) -> str: return text -class CharacterSpinBox(QWidget): +class CharacterSpinBox(IgnoreScrollWheelSpinBox): value_changed = pyqtSignal(int) def __init__(self, value=0, min_val=0, max_val=100, step=1, parent=None): - super().__init__(parent) - self._value = value - self.min_val = min_val - self.max_val = max_val - self.step = step - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self.down_btn = QPushButton("−") - self.down_btn.setObjectName("left") - self.down_btn.setFixedSize(26, 26) - self.down_btn.clicked.connect(self._decrement) - self.down_btn.setStyleSheet( - "QPushButton { border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none; font-size: 16px; padding: 0; }" - ) - - self.edit = QLineEdit(str(self._value)) - self.edit.setReadOnly(True) - self.edit.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.edit.setFixedHeight(26) - self.edit.setStyleSheet("QLineEdit { border-radius: 0; }") - - self.up_btn = QPushButton("+") - self.up_btn.setObjectName("right") - self.up_btn.setFixedSize(26, 26) - self.up_btn.clicked.connect(self._increment) - self.up_btn.setStyleSheet( - "QPushButton { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: none; font-size: 16px; padding: 0; }" - ) - - layout.addWidget(self.down_btn) - layout.addWidget(self.edit) - layout.addWidget(self.up_btn) - - def _increment(self): - if self._value + self.step <= self.max_val: - self._value += self.step - self.edit.setText(str(self._value)) - self.value_changed.emit(self._value) - - def _decrement(self): - if self._value - self.step >= self.min_val: - self._value -= self.step - self.edit.setText(str(self._value)) - self.value_changed.emit(self._value) - - def value(self) -> int: - return self._value + super().__init__() + self.setRange(min_val, max_val) + self.setValue(value) + self.setSingleStep(step) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setFixedHeight(26) + self.valueChanged.connect(self.value_changed.emit) def set_value(self, val: int): - self._value = val - self.edit.setText(str(self._value)) + self.setValue(val) def set_range(self, min_val: int, max_val: int): - self.min_val = min_val - self.max_val = max_val + self.setRange(min_val, max_val) def set_minimum(self, val: int): - self.min_val = val + self.setMinimum(val) def set_maximum(self, val: int): - self.max_val = val + self.setMaximum(val) @override def setFixedWidth(self, w: int): super().setFixedWidth(w) - self.edit.setFixedWidth(max(10, w - 52)) def _item_type_summary(item_types: list[ItemType]) -> str: @@ -172,7 +126,7 @@ def __init__(self, parent: QWidget, item_types: list[ItemType], selected_item_ty super().__init__(parent) self.setWindowTitle("Select Item Types") self.resize(650, 500) - self.checkboxes: dict[ItemType, QCheckBox] = {} + self.checkboxes: dict[ItemType, CheckmarkCheckBox] = {} selected_item_type_set = set(selected_item_types) weapon_item_types = [ @@ -213,7 +167,7 @@ def _create_item_type_group( content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) for item_type in item_types: - checkbox = QCheckBox(item_type.value) + checkbox = CheckmarkCheckBox(item_type.value) checkbox.setChecked(item_type in selected_item_types) self.checkboxes[item_type] = checkbox content_layout.addWidget(checkbox) @@ -321,6 +275,10 @@ def _create_summary_card_style() -> str: def _create_column_header(title: str, add_callback: callable, remove_callback: callable | None = None) -> QWidget: header = QWidget() + header.setObjectName("ColumnHeader") + header.setStyleSheet( + "QWidget#ColumnHeader { background-color: #1e3a5f; border-top-left-radius: 4px; border-top-right-radius: 4px; }" + ) layout = QHBoxLayout(header) layout.setContentsMargins(5, 5, 5, 5) @@ -329,13 +287,13 @@ def _create_column_header(title: str, add_callback: callable, remove_callback: c btn.clicked.connect(remove_callback) layout.addWidget(btn) else: - spacer = QWidget() - spacer.setFixedWidth(30) - layout.addWidget(spacer) + layout.addSpacing(30) layout.addStretch() lbl = QLabel(title) - lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #94a3b8; text-transform: uppercase;") + lbl.setStyleSheet( + "font-weight: bold; font-size: 13px; color: #e2e8f0; text-transform: uppercase; border: none; background: transparent;" + ) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(lbl) layout.addStretch() @@ -344,6 +302,16 @@ def _create_column_header(title: str, add_callback: callable, remove_callback: c btn.setFixedWidth(30) btn.setToolTip(f"Add to {title}") btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(""" + QPushButton { + color: #22c55e; + font-weight: bold; + font-size: 16px; + border: 1px solid #064e3b; + background-color: #06201b; + } + QPushButton:hover { background-color: #064e3b; color: white; } + """) btn.clicked.connect(add_callback) layout.addWidget(btn) @@ -357,7 +325,7 @@ def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) main_layout.setSpacing(2) flavor_lbl = QLabel("Set the quantity of affixes wanted for a match.") - flavor_lbl.setStyleSheet("color: #64748b; font-size: 10px; font-style: italic;") + flavor_lbl.setStyleSheet("color: #64748b; font-size: 12px; font-style: italic;") flavor_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) main_layout.addWidget(flavor_lbl) @@ -369,7 +337,7 @@ def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) layout.addStretch() min_lbl = QLabel("Min:") - min_lbl.setStyleSheet("color: #94a3b8; font-size: 11px;") + min_lbl.setStyleSheet("color: #94a3b8; font-size: 11px; border: none;") layout.addWidget(min_lbl) min_spin = CharacterSpinBox() @@ -380,7 +348,7 @@ def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) layout.addWidget(min_spin) max_lbl = QLabel("Max:") - max_lbl.setStyleSheet("color: #94a3b8; font-size: 11px;") + max_lbl.setStyleSheet("color: #94a3b8; font-size: 11px; border: none;") layout.addWidget(max_lbl) max_spin = CharacterSpinBox() @@ -425,6 +393,15 @@ def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): form.addRow("Mode:", self.mode_combo) self.value_edit = QLineEdit() + self.value_edit.setStyleSheet(""" + QLineEdit { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + } + QLineEdit:focus { border-color: #3b82f6; } + """) if model.min_percent_of_aspect: self.value_edit.setText(str(model.min_percent_of_aspect)) elif model.value is not None: @@ -586,7 +563,6 @@ def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): def setup_ui(self): self.content_layout = QVBoxLayout(self) self.content_layout.setContentsMargins(0, 10, 0, 0) - self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Row 1: Item Alias, Min Power, Duplicate Button top_row_layout = QHBoxLayout() @@ -595,6 +571,15 @@ def setup_ui(self): top_row_layout.addWidget(QLabel("Item Name / Alias:")) self.alias_edit = QLineEdit() self.alias_edit.setText(self.item_name) + self.alias_edit.setStyleSheet(""" + QLineEdit { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + } + QLineEdit:focus { border-color: #3b82f6; } + """) self.alias_edit.setFixedWidth(200) self.alias_edit.textChanged.connect(self.update_item_alias) top_row_layout.addWidget(self.alias_edit) @@ -634,11 +619,12 @@ def setup_ui(self): ) self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin) - self.auto_sync_checkbox = QCheckBox("Auto Sync") + self.auto_sync_checkbox = CheckmarkCheckBox("Auto Sync") self.auto_sync_checkbox.setToolTip( "When checked: Min Greater Affixes automatically matches the number of affixes marked as 'want greater'\n" "When unchecked: You can manually set Min Greater Affixes to any value" ) + self.auto_sync_checkbox.setStyleSheet("background: transparent;") self.auto_sync_checkbox.setChecked( self.settings.value(f"auto_sync_ga_{self.item_name}", defaultValue=False, type=bool) ) @@ -669,6 +655,7 @@ def setup_ui(self): # 3-Column Layout columns_layout = QHBoxLayout() + columns_layout.setContentsMargins(0, 0, 0, 0) columns_layout.setSpacing(15) self.columns_layout = columns_layout @@ -827,13 +814,9 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.viewport().setAutoFillBackground(False) - if pool_model is not None: - scroll.setStyleSheet( - "QScrollArea { border: 1px solid #2d2d2d; border-bottom: none; background-color: #121212; }" - ) - else: - scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; background: transparent; }") - scroll.viewport().setStyleSheet("background: transparent;") + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #2d2d2d; border-left: none; border-bottom: none; background-color: #121212; }" + ) inner = QWidget() inner_layout = QVBoxLayout(inner) @@ -844,7 +827,9 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): footer = None if pool_model is not None: footer = _create_column_footer(pool_model, self.update_greater_count_label) - footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + footer.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #2d2d2d; border-left: none; border-top: none;" + ) col_layout.addWidget(footer) return col_widget, inner_layout, footer @@ -1033,14 +1018,15 @@ def __init__(self, unique_aspect: AspectUniqueFilterModel, parent=None): def setup_ui(self): self.main_layout = QHBoxLayout(self) - self.main_layout.setContentsMargins(10, 8, 10, 8) + self.main_layout.setContentsMargins(22, 8, 10, 8) self.summary_label = QLabel() self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") self.main_layout.addWidget(self.summary_label, 1) self.threshold_label = QLabel() - self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.threshold_label.setMinimumWidth(60) + self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;") self.main_layout.addWidget(self.threshold_label) self.delete_btn = _create_delete_btn() @@ -1153,14 +1139,15 @@ def __init__(self, model: AffixFilterModel, parent=None): def setup_ui(self): self.main_layout = QHBoxLayout(self) - self.main_layout.setContentsMargins(10, 8, 10, 8) + self.main_layout.setContentsMargins(22, 8, 10, 8) self.summary_label = QLabel() self.summary_label.setStyleSheet("font-weight: bold; color: #e2e8f0;") self.main_layout.addWidget(self.summary_label, 1) self.threshold_label = QLabel() - self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.threshold_label.setMinimumWidth(60) + self.threshold_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;") self.main_layout.addWidget(self.threshold_label) self.delete_btn = _create_delete_btn() @@ -1220,7 +1207,7 @@ def __init__(self, pool: AffixFilterCountModel, parent=None): def setup_ui(self): self.main_layout = QHBoxLayout(self) - self.main_layout.setContentsMargins(10, 8, 10, 8) + self.main_layout.setContentsMargins(22, 8, 10, 8) self.main_layout.setSpacing(10) # Container for labels on the left @@ -1234,7 +1221,7 @@ def setup_ui(self): text_layout.addWidget(self.affix_summary) self.count_label = QLabel() - self.count_label.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.count_label.setStyleSheet("color: #94a3b8; font-size: 13px; font-weight: bold;") text_layout.addWidget(self.count_label) self.main_layout.addLayout(text_layout, 1) @@ -1315,7 +1302,7 @@ def create_affix_name_combobox(self): self.name_combo.currentTextChanged.connect(self.update_name) def create_required_checkbox(self): - self.required_checkbox = QCheckBox("Required") + self.required_checkbox = CheckmarkCheckBox("Required") self.required_checkbox.setChecked(getattr(self.affix, "required", False)) self.required_checkbox.setFixedWidth(85) self.required_checkbox.stateChanged.connect(self.update_required) @@ -1324,7 +1311,7 @@ def update_required(self): self.affix.required = self.required_checkbox.isChecked() def create_greater_checkbox(self): - self.greater_checkbox = QCheckBox("GA") + self.greater_checkbox = CheckmarkCheckBox("GA") self.greater_checkbox.setChecked(getattr(self.affix, "want_greater", False)) self.greater_checkbox.setFixedWidth(80) self.greater_checkbox.setProperty("greaterCheckbox", True) # noqa: FBT003 @@ -1357,6 +1344,15 @@ def create_mode_combobox(self): def create_value_input(self): self.value_edit = QLineEdit() self.value_edit.setFixedWidth(80) + self.value_edit.setStyleSheet(""" + QLineEdit { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + } + QLineEdit:focus { border-color: #3b82f6; } + """) self.value_edit.textChanged.connect(self.update_value) def update_name(self, current_text=None): @@ -1442,9 +1438,32 @@ def setup_ui(self): self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) - self.tab_widget.setStyleSheet( - "QTabWidget { background: transparent; } QTabWidget::pane { border: none; } QTabBar { background: transparent; }" - ) + self.tab_widget.setStyleSheet(""" + QTabWidget { background: transparent; } + QTabWidget::pane { border: none; } + QTabBar::tab { + background: #1a1a1a; + color: #94a3b8; + padding: 8px 16px; + border: 1px solid #334155; + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-right: 2px; + } + QTabBar::close-button { right: 8px; } + QTabBar::tab:selected { + background: #1e3a5f; + color: #e2e8f0; + border: 1px solid #3b82f6; + border-bottom: 2px solid #3b82f6; + } + QTabBar::tab:last, QTabBar::tab:selected:last { + background: #06201b; + color: #22c55e; + border: 1px solid #064e3b; + } + """) with QSignalBlocker(self.tab_widget): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_tab) diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index fae94ea7..1a0f0e4d 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -82,7 +82,7 @@ def setup_ui(self): scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }") self.scroll_widget = QWidget() self.list_layout = QVBoxLayout(self.scroll_widget) diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 3b402644..e82836de 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -2,7 +2,6 @@ from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal from PyQt6.QtWidgets import ( - QCheckBox, QDialog, QFrame, QGroupBox, @@ -27,6 +26,7 @@ ) from src.dataloader import Dataloader from src.gui.importer.gui_common import MAX_POWER +from src.gui.models.checkmark_checkbox import CheckmarkCheckBox from src.gui.models.dialog import DeleteItem, IgnoreScrollWheelSpinBox from src.gui.profile_editor.affixes_tab import ( AffixSummaryWidget, @@ -61,12 +61,12 @@ def __init__(self, unique_model: GlobalUniqueModel, parent=None): def setup_ui(self): self.content_layout = QVBoxLayout(self) self.content_layout.setContentsMargins(0, 10, 0, 0) - self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.create_general_groupbox() # Rule Content self.columns_layout = QHBoxLayout() + self.columns_layout.setContentsMargins(0, 0, 0, 0) self.columns_layout.setSpacing(15) # Column 1: Unique Aspects @@ -105,7 +105,7 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.viewport().setAutoFillBackground(False) scroll.setStyleSheet( - "QScrollArea { border: 1px solid #2d2d2d; background-color: #121212; border-bottom: none; }" + "QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; border-bottom: none; }" ) inner = QWidget() @@ -117,7 +117,9 @@ def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): footer = None if pool_model is not None: footer = _create_column_footer(pool_model, self.update_greater_count_label) - footer.setStyleSheet("background-color: #1a1a1a; border: 1px solid #3c3c3c; border-top: none;") + footer.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #2d2d2d; border-left: none; border-top: none;" + ) col_layout.addWidget(footer) return col_widget, inner_layout, footer @@ -131,7 +133,15 @@ def add_cb(): remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb) - insert_idx = self.columns_layout.count() - 1 + + # Match the insertion logic in affixes_tab to ensure Aspects are on the left + # and Affix Pools are in the middle. + inherent_idx = -1 + if hasattr(self, "inherent_col"): + inherent_idx = self.columns_layout.indexOf(self.inherent_col) + + insert_idx = inherent_idx if inherent_idx != -1 else self.columns_layout.count() + self.columns_layout.insertWidget(insert_idx, col_widget) self.affix_column_widgets.append(col_widget) self.affix_pool_layouts.append(inner_layout) @@ -196,6 +206,7 @@ def remove_affix_item_widget(self, widget, inherent: bool, pool_idx: int = 0): def create_general_groupbox(self): self.general_groupbox = QGroupBox() self.general_groupbox.setTitle("Global Unique Rule Configuration") + self.general_groupbox.setStyleSheet("QGroupBox { border-left: none; border-right: none; }") main_vbox = QVBoxLayout(self.general_groupbox) main_vbox.setContentsMargins(10, 15, 10, 10) @@ -205,6 +216,15 @@ def create_general_groupbox(self): top_row.addWidget(QLabel("Rule Alias:")) self.profile_alias = QLineEdit() self.profile_alias.setFixedWidth(200) + self.profile_alias.setStyleSheet(""" + QLineEdit { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + } + QLineEdit:focus { border-color: #3b82f6; } + """) self.profile_alias.setText(self.unique_model.profile_alias) self.profile_alias.textChanged.connect(self.update_profile_alias) top_row.addWidget(self.profile_alias) @@ -249,7 +269,8 @@ def create_general_groupbox(self): self.min_greater.setFixedWidth(100) self.min_greater.value_changed.connect(self.update_min_greater_affix_from_spin) - self.auto_sync_checkbox = QCheckBox("Auto Sync") + self.auto_sync_checkbox = CheckmarkCheckBox("Auto Sync") + self.auto_sync_checkbox.setStyleSheet("background: transparent;") self.auto_sync_checkbox.setChecked( self.settings.value(f"auto_sync_ga_global_{self.unique_model.profile_alias}", defaultValue=False, type=bool) ) @@ -398,9 +419,32 @@ def setup_ui(self): self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) - self.tab_widget.setStyleSheet( - "QTabWidget { background: transparent; } QTabWidget::pane { border: none; } QTabBar { background: transparent; }" - ) + self.tab_widget.setStyleSheet(""" + QTabWidget { background: transparent; } + QTabWidget::pane { border: none; } + QTabBar::tab { + background: #1a1a1a; + color: #94a3b8; + padding: 8px 16px; + border: 1px solid #334155; + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-right: 2px; + } + QTabBar::close-button { right: 8px; } + QTabBar::tab:selected { + background: #1e3a5f; + color: #e2e8f0; + border: 1px solid #3b82f6; + border-bottom: 2px solid #3b82f6; + } + QTabBar::tab:last, QTabBar::tab:selected:last { + background: #06201b; + color: #22c55e; + border: 1px solid #064e3b; + } + """) with QSignalBlocker(self.tab_widget): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_tab) diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py index b1a6b480..5f473931 100644 --- a/src/gui/profile_editor/paper_doll.py +++ b/src/gui/profile_editor/paper_doll.py @@ -339,7 +339,9 @@ def setup_ui(self): title_label = QLabel("Equipment") title_label.setProperty("titleLabel", True) # noqa: FBT003 - title_label.setStyleSheet("QLabel { color: #e2e8f0; font-size: 18px; font-weight: bold; padding: 8px;}") + title_label.setStyleSheet( + "QLabel { color: #e2e8f0; font-size: 18px; font-weight: bold; padding: 10px; border-top: 1px solid #334155; border-bottom: 1px solid #334155; border-left: none; border-right: none; }" + ) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) char_layout.addWidget(title_label) diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 35aba23d..2859eb3f 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -113,11 +113,19 @@ def setup_ui(self): self.gear_view_scroll.setFrameShape(QFrame.Shape.NoFrame) self.gear_view_container = QWidget() self.gear_view_layout = QVBoxLayout(self.gear_view_container) - self.gear_view_layout.setContentsMargins(0, 0, 10, 0) + self.gear_view_layout.setContentsMargins(0, 0, 0, 0) self.gear_view_header = QLabel() self.gear_view_header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px;") self.gear_view_layout.addWidget(self.gear_view_header) + + self.gear_view_subheader = QLabel() + self.gear_view_subheader.setStyleSheet( + "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px;" + ) + self.gear_view_layout.addWidget(self.gear_view_subheader) + self.gear_view_subheader.hide() + self.gear_view_layout.addWidget(self.affixes_tab) self.gear_view_layout.addWidget(self.uniques_tab) self.gear_view_scroll.setWidget(self.gear_view_container) @@ -149,6 +157,7 @@ def on_slot_clicked(self, slot_name: str): ]: widget.hide() self.paper_doll.clear_side_panel() + self.gear_view_subheader.hide() self._adjust_window_size(expanding=False) return @@ -173,6 +182,7 @@ def on_slot_clicked(self, slot_name: str): self.affixes_tab.filter_by_item_types(item_types, slot_name) self.gear_view_header.setText(f"Slot: {slot_name}") + self.gear_view_subheader.hide() self.side_content_widget = self.gear_view_scroll elif slot_name == "Aspect Upgrades": self.aspect_upgrades_tab.load() @@ -193,9 +203,12 @@ def on_slot_clicked(self, slot_name: str): self.uniques_tab.filter_by_item_types(None) self.uniques_tab.show() - # Hide the gear-specific parts + # Show header for Global Rules and hide the affixes tab + self.gear_view_header.setText("Global Rules") + self.gear_view_subheader.setText("These are rules that cover more than one item type.") + self.gear_view_subheader.show() + self.gear_view_header.show() self.affixes_tab.hide() - self.gear_view_header.hide() self.side_content_widget = self.gear_view_scroll else: diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index 32ac3c78..c0e1f7c1 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -228,7 +228,9 @@ def create_col(title, add_cb): scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + scroll.setStyleSheet( + "QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }" + ) inner = QWidget() inner_layout = QVBoxLayout(inner) diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index 1615915e..fe3edb03 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -2,7 +2,6 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( - QCheckBox, QDialog, QDialogButtonBox, QFormLayout, @@ -20,6 +19,7 @@ from src.config.profile_models import ItemRarity, TributeFilterModel from src.dataloader import Dataloader +from src.gui.models.checkmark_checkbox import CheckmarkCheckBox from src.gui.profile_editor.affixes_tab import ( QPainter, TruncatingComboBox, @@ -105,7 +105,7 @@ def __init__(self, parent: QWidget, model: TributeFilterModel): self.setWindowTitle("Configure Tribute Rule") self.setMinimumWidth(450) self.model = model - self.rarity_checkboxes: dict[ItemRarity, QCheckBox] = {} + self.rarity_checkboxes: dict[ItemRarity, CheckmarkCheckBox] = {} layout = QVBoxLayout(self) form = QFormLayout() @@ -122,7 +122,7 @@ def __init__(self, parent: QWidget, model: TributeFilterModel): rarity_group = QGroupBox("Target Rarities") rarity_layout = QVBoxLayout(rarity_group) for rarity in ItemRarity: - cb = QCheckBox(rarity.name.title()) + cb = CheckmarkCheckBox(rarity.name.title()) cb.setChecked(rarity in self.model.rarities) self.rarity_checkboxes[rarity] = cb rarity_layout.addWidget(cb) @@ -174,7 +174,7 @@ def setup_ui(self): scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.Panel) - scroll.setStyleSheet("QScrollArea { border: 1px solid #3c3c3c; background-color: #121212; }") + scroll.setStyleSheet("QScrollArea { border: 1px solid #2d2d2d; border-left: none; background-color: #121212; }") self.scroll_widget = QWidget() self.list_layout = QVBoxLayout(self.scroll_widget) From 4945c683d5e4da22deb0bd360886f86a58e5c05f Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 09:59:33 -0400 Subject: [PATCH 10/17] window failure fix --- src/gui/profile_editor/affixes_tab.py | 8 +-- src/gui/profile_editor/aspect_upgrades_tab.py | 24 +++++---- src/gui/profile_editor/global_uniques_tab.py | 8 +-- src/gui/profile_editor/profile_editor.py | 52 ++++++++++--------- src/gui/profile_editor/sigils_tab.py | 8 +-- src/gui/profile_editor/tributes_tab.py | 8 +-- 6 files changed, 62 insertions(+), 46 deletions(-) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 87477fab..df56d28a 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -1,3 +1,4 @@ +import contextlib import copy import logging from typing import override @@ -1428,9 +1429,10 @@ def __init__(self, affixes_model: list[DynamicItemFilterModel], parent=None): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True + with contextlib.suppress(RuntimeError): + if not self.loaded: + self.setup_ui() + self.loaded = True def setup_ui(self): """Populate the grid layout with existing groups.""" diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index 1a0f0e4d..f00c90b4 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -1,3 +1,6 @@ +"""Tab widget for managing the whitelist of aspects to track for Codex upgrades.""" + +import contextlib from typing import override from PyQt6.QtCore import Qt, pyqtSignal @@ -66,18 +69,19 @@ def __init__(self, aspect_upgrades: list[str], parent=None): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True + with contextlib.suppress(RuntimeError): + if not self.loaded: + self.setup_ui() + self.loaded = True def setup_ui(self): - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(0, 5, 0, 5) - main_layout.setSpacing(0) - main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 5, 0, 5) + self.main_layout.setSpacing(0) + self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) header = _create_column_header("Aspect Upgrades", self.add_aspect) - main_layout.addWidget(header) + self.main_layout.addWidget(header) scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -91,13 +95,13 @@ def setup_ui(self): self.list_layout.setSpacing(4) scroll.setWidget(self.scroll_widget) - main_layout.addWidget(scroll) + self.main_layout.addWidget(scroll) # Populate initial items for aspect in self.aspect_upgrades: self.add_aspect_item(aspect) - self.setLayout(main_layout) + self.setLayout(self.main_layout) def add_aspect_item(self, aspect_key: str): widget = AspectUpgradeSummaryWidget(aspect_key) diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index e82836de..d1bdc790 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -1,3 +1,4 @@ +import contextlib import copy from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal @@ -411,9 +412,10 @@ def __init__(self, unique_model_list: list[GlobalUniqueModel], parent=None): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True + with contextlib.suppress(RuntimeError): + if not self.loaded: + self.setup_ui() + self.loaded = True def setup_ui(self): self.main_layout = QVBoxLayout(self) diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 2859eb3f..76535c60 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -1,5 +1,6 @@ """Profile editor with paper doll layout.""" +import contextlib import logging from PyQt6.QtCore import QTimer, pyqtSignal @@ -155,7 +156,8 @@ def on_slot_clicked(self, slot_name: str): self.tributes_tab, self.uniques_tab, ]: - widget.hide() + with contextlib.suppress(RuntimeError): + widget.hide() self.paper_doll.clear_side_panel() self.gear_view_subheader.hide() self._adjust_window_size(expanding=False) @@ -185,32 +187,34 @@ def on_slot_clicked(self, slot_name: str): self.gear_view_subheader.hide() self.side_content_widget = self.gear_view_scroll elif slot_name == "Aspect Upgrades": - self.aspect_upgrades_tab.load() - self.aspect_upgrades_tab.show() - self.side_content_widget = self.aspect_upgrades_tab + with contextlib.suppress(RuntimeError): + self.aspect_upgrades_tab.load() + self.aspect_upgrades_tab.show() + self.side_content_widget = self.aspect_upgrades_tab elif slot_name == "Sigils": - self.sigils_tab.load() - self.sigils_tab.show() - self.side_content_widget = self.sigils_tab + with contextlib.suppress(RuntimeError): + self.sigils_tab.load() + self.sigils_tab.show() + self.side_content_widget = self.sigils_tab elif slot_name == "Tributes": - self.tributes_tab.load() - self.tributes_tab.show() - self.side_content_widget = self.tributes_tab + with contextlib.suppress(RuntimeError): + self.tributes_tab.load() + self.tributes_tab.show() + self.side_content_widget = self.tributes_tab elif slot_name == "Global Rules": - self.uniques_tab.load() - # When clicking global tab, show all rules via the integrated view - # to avoid widget reparenting issues that break the layout. - self.uniques_tab.filter_by_item_types(None) - self.uniques_tab.show() - - # Show header for Global Rules and hide the affixes tab - self.gear_view_header.setText("Global Rules") - self.gear_view_subheader.setText("These are rules that cover more than one item type.") - self.gear_view_subheader.show() - self.gear_view_header.show() - self.affixes_tab.hide() - - self.side_content_widget = self.gear_view_scroll + with contextlib.suppress(RuntimeError): + self.uniques_tab.load() + # When clicking global tab, show all rules via the integrated view + # to avoid widget reparenting issues that break the layout. + self.uniques_tab.filter_by_item_types(None) + self.uniques_tab.show() + # Show header for Global Rules and hide the affixes tab + self.gear_view_header.setText("Global Rules") + self.gear_view_subheader.setText("These are rules that cover more than one item type.") + self.gear_view_subheader.show() + self.gear_view_header.show() + self.affixes_tab.hide() + self.side_content_widget = self.gear_view_scroll else: self.side_content_widget = None diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index c0e1f7c1..a2febf6d 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -1,3 +1,4 @@ +import contextlib from typing import override from PyQt6.QtCore import Qt, pyqtSignal @@ -198,9 +199,10 @@ def __init__(self, sigil_model: SigilFilterModel, parent=None): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True + with contextlib.suppress(RuntimeError): + if not self.loaded: + self.setup_ui() + self.loaded = True def setup_ui(self): """Populate the grid layout with existing groups.""" diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index fe3edb03..82413ac4 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -1,3 +1,4 @@ +import contextlib from typing import override from PyQt6.QtCore import Qt, pyqtSignal @@ -153,9 +154,10 @@ def __init__(self, tributes: list[TributeFilterModel] | None, parent=None): self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def load(self): - if not self.loaded: - self.setup_ui() - self.loaded = True + with contextlib.suppress(RuntimeError): + if not self.loaded: + self.setup_ui() + self.loaded = True def setup_ui(self): main_layout = QVBoxLayout(self) From 4b41332b05b954e5e9da953355b1adeb8d1e8b94 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 10:54:32 -0400 Subject: [PATCH 11/17] logical minimum dimensions for paperdoll and sidebar --- src/gui/profile_editor/paper_doll.py | 15 ++++- src/gui/profile_editor/profile_editor.py | 63 +++++++++++-------- src/gui/profile_tab.py | 79 ++++++++++++------------ 3 files changed, 89 insertions(+), 68 deletions(-) diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py index 5f473931..eab3fee2 100644 --- a/src/gui/profile_editor/paper_doll.py +++ b/src/gui/profile_editor/paper_doll.py @@ -256,9 +256,12 @@ class CharacterCanvas(QFrame): def __init__(self, parent: QWidget | None = None): super().__init__(parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.setMinimumSize(370, 255) self.setStyleSheet("QFrame { background-color: #0f172a; border: none;}") + @override + def sizeHint(self) -> QSize: + return QSize(self.REF_WIDTH, self.REF_HEIGHT) + @override def resizeEvent(self, event): super().resizeEvent(event) @@ -329,6 +332,7 @@ def setup_ui(self): # Special Navigation Tabs at the Top self.special_nav_layout = QHBoxLayout() self.special_nav_layout.setSpacing(15) + self.special_nav_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) for name in SPECIAL_TABS: btn = EquipmentSlotButton(name, [], QRect(0, 0, 145, 60)) btn.clicked.connect(lambda n=name: self._on_slot_clicked(n)) @@ -355,6 +359,7 @@ def setup_ui(self): # Side panel (right side) - initially shows placeholder self.side_panel = QFrame() self.side_panel.setStyleSheet("QFrame { background-color: #1e293b; border-left: 1px solid #334155;}") + self.side_panel.setMinimumWidth(600) side_layout = QVBoxLayout(self.side_panel) side_layout.setContentsMargins(20, 10, 20, 20) @@ -402,13 +407,19 @@ def position_slots(self) -> None: sx = self.character_canvas.width() / 740.0 sy = self.character_canvas.height() / 510.0 - for button in self._slot_buttons.values(): + # Scale the layout spacing for the top navigation + self.special_nav_layout.setSpacing(int(15 * sx)) + + for name, button in self._slot_buttons.items(): if button.parent() == self.character_canvas: # Use the rect stored internally during add_slot rect = button._slot_rect button.setGeometry( int(rect.x() * sx), int(rect.y() * sy), int(rect.width() * sx), int(rect.height() * sy) ) + elif name in SPECIAL_TABS: + # Scale the special buttons to match the canvas items + button.setFixedSize(int(145 * sx), int(60 * sy)) button.show() def _on_slot_clicked(self, slot_name: str): diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 76535c60..7a4d8c10 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -54,13 +54,22 @@ def __init__(self, profile_model: ProfileModel, parent: QWidget | None = None): self.setup_ui() # Reset window expansion state on load to prevent accumulation when switching profiles - QTimer.singleShot(50, lambda: self._adjust_window_size(expanding=False)) + QTimer.singleShot(50, self._safe_initial_resize) + + def _safe_initial_resize(self): + """Perform initial window adjustment safely to avoid RuntimeError if widget is deleted.""" + with contextlib.suppress(RuntimeError): + self._adjust_window_size(expanding=False) def _detect_class(self) -> str: """Return the character class defined in the profile model.""" return self.profile_model.class_name.lower() def setup_ui(self): + # Set a base minimum height to prevent the window from being "rolled up" + # into an unusable state. + self.setMinimumHeight(750) + main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) @@ -226,31 +235,33 @@ def on_slot_clicked(self, slot_name: str): def _adjust_window_size(self, expanding: bool): """Resize the top-level window to accommodate the side panel.""" - win = self.window() - if not win or win.isMaximized(): - return - - # Use dynamic properties on the main window to track expansion state globally across instances. - # This prevents the window from getting wider and wider when switching profiles. - is_already_expanded = win.property("profile_editor_expanded") is True - - if expanding and not is_already_expanded: - # Store the current width before expanding so we can return to it exactly - win.setProperty("profile_editor_pre_expansion_width", win.width()) - current_size = win.size() - win.resize(current_size.width() + 850, current_size.height()) - win.setProperty("profile_editor_expanded", True) # noqa: FBT003 - elif not expanding and is_already_expanded: - # Restore the window to the exact width it had before the expansion - pre_width = win.property("profile_editor_pre_expansion_width") - current_size = win.size() - if pre_width is not None: - win.resize(pre_width, current_size.height()) - else: - # Fallback if the property is missing - new_width = max(800, current_size.width() - 850) - win.resize(new_width, current_size.height()) - win.setProperty("profile_editor_expanded", False) # noqa: FBT003 + with contextlib.suppress(RuntimeError): + win = self.window() + if not win or win.isMaximized(): + return + + # Use dynamic properties on the main window to track expansion state globally across instances. + # This prevents the window from getting wider and wider when switching profiles. + is_already_expanded = win.property("profile_editor_expanded") is True + + if expanding and not is_already_expanded: + # Use the actual sidebar minimum width to determine expansion delta + sidebar_w = self.paper_doll.side_panel.minimumWidth() + # Store the current width before expanding so we can return to it exactly + win.setProperty("profile_editor_pre_expansion_width", win.width()) + win.resize(win.width() + sidebar_w, win.height()) + win.setProperty("profile_editor_expanded", True) # noqa: FBT003 + elif not expanding: + # Restore the window to the exact width it had before the expansion + pre_width = win.property("profile_editor_pre_expansion_width") + if pre_width is not None and is_already_expanded: + win.resize(int(pre_width), win.height()) + else: + # Force to the smallest allowed width (paper doll base width) + win.resize(350, win.height()) + win.setProperty("profile_editor_expanded", False) # noqa: FBT003 + + self.updateGeometry() def save_all(self): """Save all tabs' configurations.""" diff --git a/src/gui/profile_tab.py b/src/gui/profile_tab.py index deb9a0f6..07cf72e9 100644 --- a/src/gui/profile_tab.py +++ b/src/gui/profile_tab.py @@ -5,18 +5,7 @@ import yaml from pydantic import ValidationError from PyQt6.QtCore import QSettings, QSignalBlocker, Qt -from PyQt6.QtWidgets import ( - QComboBox, - QGroupBox, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QScrollArea, - QTextBrowser, - QVBoxLayout, - QWidget, -) +from PyQt6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout, QWidget from src.config.loader import IniConfigLoader from src.config.profile_models import ProfileModel @@ -43,47 +32,55 @@ def __init__(self): self.model_editor = None self.first_show = True self.main_layout = QVBoxLayout(self) - - scroll_area = QScrollArea(self) - scroll_widget = QWidget(scroll_area) - self.scrollable_layout = QVBoxLayout(scroll_widget) - scroll_area.setWidgetResizable(True) + self.main_layout.setContentsMargins(10, 0, 10, 0) + self.main_layout.setSpacing(0) info_layout = QHBoxLayout() - info_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + info_layout.setContentsMargins(0, 0, 0, 0) tools_groupbox = QGroupBox("Profile") - tools_groupbox_layout = QHBoxLayout() + tools_groupbox.setStyleSheet("QGroupBox { margin-top: 8px; padding-top: 12px; }") + tools_groupbox_layout = QVBoxLayout() + tools_groupbox_layout.setContentsMargins(10, 5, 10, 10) + button_layout = QHBoxLayout() + self.profile_combo = QComboBox() + self.profile_combo.setMinimumWidth(250) self.save_button = QPushButton("Save Profile") + self.save_button.setFixedWidth(130) self.refresh_button = QPushButton("Revert to Saved") + self.refresh_button.setFixedWidth(130) self.profile_combo.currentIndexChanged.connect(self.profile_selection_changed) self.save_button.clicked.connect(self.save_yaml) self.refresh_button.clicked.connect(self.refresh) - tools_groupbox_layout.addWidget(self.profile_combo) - tools_groupbox_layout.addWidget(self.save_button) - tools_groupbox_layout.addWidget(self.refresh_button) + + button_layout.addWidget(self.profile_combo) + button_layout.addWidget(self.save_button) + button_layout.addWidget(self.refresh_button) + button_layout.addStretch() + tools_groupbox_layout.addLayout(button_layout) + + instructions_text = QLabel( + "Select a profile from the dropdown. Click 'Save Profile' to persist your changes. " + "Click 'Revert to Saved' to discard unsaved edits." + ) + instructions_text.setStyleSheet("color: #94a3b8; font-size: 11px; font-style: italic;") + instructions_text.setWordWrap(True) + tools_groupbox_layout.addWidget(instructions_text) + tools_groupbox.setLayout(tools_groupbox_layout) info_layout.addWidget(tools_groupbox) + info_layout.addStretch() self.main_layout.addLayout(info_layout) self.itemTypes = Dataloader().item_types_dict self.affixesNames = Dataloader().affix_dict self.profile_editor_created = False - scroll_widget.setLayout(self.scrollable_layout) - scroll_area.setWidget(scroll_widget) - self.main_layout.addWidget(scroll_area) - instructions_label = QLabel("Instructions") - self.main_layout.addWidget(instructions_label) - - instructions_text = QTextBrowser() - instructions_text.append( - "Select a profile from the dropdown. Click 'Save Profile' to persist your changes. Click 'Revert to Saved' to discard unsaved edits." - ) - - instructions_text.setFixedHeight(50) - self.main_layout.addWidget(instructions_text) + self.editor_container = QWidget() + self.editor_layout = QVBoxLayout(self.editor_container) + self.editor_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.addWidget(self.editor_container, stretch=1) self.setLayout(self.main_layout) self.populate_profile_dropdown() @@ -146,9 +143,10 @@ def load_selected_profile(self, profile_name): self.file_path = self.profile_paths[profile_name] if self.load_yaml(): if self.model_editor: - self.scrollable_layout.removeWidget(self.model_editor) + self.editor_layout.removeWidget(self.model_editor) + self.model_editor.deleteLater() self.model_editor = ProfileEditor(self.root) - self.scrollable_layout.addWidget(self.model_editor) + self.editor_layout.addWidget(self.model_editor) self.current_profile_name = profile_name self.set_current_profile_combo(profile_name) LOGGER.info(f"Profile {self.root.name} loaded into profile editor.") @@ -232,7 +230,7 @@ def select_initial_profile(self): def create_profile_editor(self): if not self.profile_editor_created and self.root: self.model_editor = ProfileEditor(self.root) - self.scrollable_layout.addWidget(self.model_editor) + self.editor_layout.addWidget(self.model_editor) self.profile_editor_created = True LOGGER.info(f"Profile {self.root.name} loaded into profile editor.") @@ -308,9 +306,10 @@ def check_close_save(self): def refresh(self): if not self.load_yaml(): return - self.scrollable_layout.removeWidget(self.model_editor) + self.editor_layout.removeWidget(self.model_editor) + self.model_editor.deleteLater() self.model_editor = ProfileEditor(self.root) - self.scrollable_layout.addWidget(self.model_editor) + self.editor_layout.addWidget(self.model_editor) LOGGER.info(f"Profile {self.root.name} refreshed.") def set_unsaved_changes(self, has_changes: bool): From 5bec0bba6ea27c8ef51bf86be282c0c398c4d75d Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 10:56:27 -0400 Subject: [PATCH 12/17] widen sidebar --- src/gui/profile_editor/paper_doll.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py index eab3fee2..04aa808a 100644 --- a/src/gui/profile_editor/paper_doll.py +++ b/src/gui/profile_editor/paper_doll.py @@ -359,7 +359,7 @@ def setup_ui(self): # Side panel (right side) - initially shows placeholder self.side_panel = QFrame() self.side_panel.setStyleSheet("QFrame { background-color: #1e293b; border-left: 1px solid #334155;}") - self.side_panel.setMinimumWidth(600) + self.side_panel.setMinimumWidth(650) side_layout = QVBoxLayout(self.side_panel) side_layout.setContentsMargins(20, 10, 20, 20) From c5c9545729d4dd52fdbfb453054eb654926b4b6b Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 11:40:41 -0400 Subject: [PATCH 13/17] cleanup theme and backgrounds --- src/gui/profile_editor/affixes_tab.py | 25 +++++++++++++++++--- src/gui/profile_editor/global_uniques_tab.py | 19 +++++++++++++++ src/gui/profile_editor/paper_doll.py | 3 ++- src/gui/profile_editor/profile_editor.py | 20 +++++++++------- src/gui/profile_editor/sigils_tab.py | 1 + src/gui/profile_editor/tributes_tab.py | 1 + 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index df56d28a..3c97c5a9 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -278,7 +278,7 @@ def _create_column_header(title: str, add_callback: callable, remove_callback: c header = QWidget() header.setObjectName("ColumnHeader") header.setStyleSheet( - "QWidget#ColumnHeader { background-color: #1e3a5f; border-top-left-radius: 4px; border-top-right-radius: 4px; }" + "QWidget#ColumnHeader { background-color: #1e3a5f; border-top-left-radius: 8px; border-top-right-radius: 8px; }" ) layout = QHBoxLayout(header) layout.setContentsMargins(5, 5, 5, 5) @@ -293,7 +293,7 @@ def _create_column_header(title: str, add_callback: callable, remove_callback: c lbl = QLabel(title) lbl.setStyleSheet( - "font-weight: bold; font-size: 13px; color: #e2e8f0; text-transform: uppercase; border: none; background: transparent;" + "font-weight: bold; font-size: 15px; color: #e2e8f0; text-transform: uppercase; border: none; background: transparent;" ) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(lbl) @@ -599,6 +599,15 @@ def setup_ui(self): duplicate_btn = QPushButton("Duplicate Item") duplicate_btn.setFixedWidth(120) + duplicate_btn.setStyleSheet(""" + QPushButton { + background-color: #1e3a5f; + border: 1px solid #3b82f6; + color: #e2e8f0; + border-radius: 4px; + } + QPushButton:hover { background-color: #2563eb; } + """) duplicate_btn.clicked.connect(self._on_duplicate_clicked) top_row_layout.addWidget(duplicate_btn) @@ -643,6 +652,15 @@ def setup_ui(self): add_pool_btn = QPushButton("Add Additional Affix Pool") add_pool_btn.setFixedWidth(180) + add_pool_btn.setStyleSheet(""" + QPushButton { + background-color: #06201b; + border: 1px solid #064e3b; + color: #22c55e; + border-radius: 4px; + } + QPushButton:hover { background-color: #064e3b; color: white; } + """) add_pool_btn.clicked.connect(self.add_additional_affix_pool_column) ga_row_layout.addWidget(add_pool_btn) @@ -1436,12 +1454,13 @@ def load(self): def setup_ui(self): """Populate the grid layout with existing groups.""" + self.setStyleSheet("background: transparent; border: none;") self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) self.tab_widget.setStyleSheet(""" - QTabWidget { background: transparent; } + QTabWidget { background: transparent; border: none; } QTabWidget::pane { border: none; } QTabBar::tab { background: #1a1a1a; diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index d1bdc790..14177fd7 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -244,6 +244,15 @@ def create_general_groupbox(self): duplicate_btn = QPushButton("Duplicate Rule") duplicate_btn.setFixedWidth(120) + duplicate_btn.setStyleSheet(""" + QPushButton { + background-color: #1e3a5f; + border: 1px solid #3b82f6; + color: #e2e8f0; + border-radius: 4px; + } + QPushButton:hover { background-color: #2563eb; } + """) duplicate_btn.clicked.connect(self._on_duplicate_clicked) top_row.addWidget(duplicate_btn) main_vbox.addLayout(top_row) @@ -286,6 +295,15 @@ def create_general_groupbox(self): add_pool_btn = QPushButton("Add Additional Affix Pool") add_pool_btn.setFixedWidth(180) + add_pool_btn.setStyleSheet(""" + QPushButton { + background-color: #06201b; + border: 1px solid #064e3b; + color: #22c55e; + border-radius: 4px; + } + QPushButton:hover { background-color: #064e3b; color: white; } + """) add_pool_btn.clicked.connect(self.add_additional_affix_pool_column) ga_row.addWidget(add_pool_btn) main_vbox.addLayout(ga_row) @@ -418,6 +436,7 @@ def load(self): self.loaded = True def setup_ui(self): + self.setStyleSheet("background: transparent; border: none;") self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.tab_widget = QTabWidget(self) diff --git a/src/gui/profile_editor/paper_doll.py b/src/gui/profile_editor/paper_doll.py index 04aa808a..7f5d136f 100644 --- a/src/gui/profile_editor/paper_doll.py +++ b/src/gui/profile_editor/paper_doll.py @@ -319,6 +319,7 @@ def setup_ui(self): main_layout = QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) + main_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) # Character silhouette panel (left side) - fill remaining space self.character_panel = QFrame() @@ -359,7 +360,7 @@ def setup_ui(self): # Side panel (right side) - initially shows placeholder self.side_panel = QFrame() self.side_panel.setStyleSheet("QFrame { background-color: #1e293b; border-left: 1px solid #334155;}") - self.side_panel.setMinimumWidth(650) + self.side_panel.setMinimumWidth(700) side_layout = QVBoxLayout(self.side_panel) side_layout.setContentsMargins(20, 10, 20, 20) diff --git a/src/gui/profile_editor/profile_editor.py b/src/gui/profile_editor/profile_editor.py index 7a4d8c10..e94ca7d7 100644 --- a/src/gui/profile_editor/profile_editor.py +++ b/src/gui/profile_editor/profile_editor.py @@ -121,17 +121,22 @@ def setup_ui(self): self.gear_view_scroll = QScrollArea() self.gear_view_scroll.setWidgetResizable(True) self.gear_view_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.gear_view_scroll.setStyleSheet("background: transparent; border: none;") + self.gear_view_container = QWidget() + self.gear_view_container.setStyleSheet("background: transparent;") self.gear_view_layout = QVBoxLayout(self.gear_view_container) self.gear_view_layout.setContentsMargins(0, 0, 0, 0) self.gear_view_header = QLabel() - self.gear_view_header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px;") + self.gear_view_header.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;" + ) self.gear_view_layout.addWidget(self.gear_view_header) self.gear_view_subheader = QLabel() self.gear_view_subheader.setStyleSheet( - "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px;" + "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;" ) self.gear_view_layout.addWidget(self.gear_view_subheader) self.gear_view_subheader.hide() @@ -252,13 +257,10 @@ def _adjust_window_size(self, expanding: bool): win.resize(win.width() + sidebar_w, win.height()) win.setProperty("profile_editor_expanded", True) # noqa: FBT003 elif not expanding: - # Restore the window to the exact width it had before the expansion - pre_width = win.property("profile_editor_pre_expansion_width") - if pre_width is not None and is_already_expanded: - win.resize(int(pre_width), win.height()) - else: - # Force to the smallest allowed width (paper doll base width) - win.resize(350, win.height()) + # Snap back to the base width (800) whenever the sidebar is closed. + # This eliminates empty space on the right and ensures the paper doll + # always opens at its "perfect" resolution. + win.resize(800, win.height()) win.setProperty("profile_editor_expanded", False) # noqa: FBT003 self.updateGeometry() diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index a2febf6d..e344062c 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -206,6 +206,7 @@ def load(self): def setup_ui(self): """Populate the grid layout with existing groups.""" + self.setStyleSheet("background: transparent; border: none;") self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 5, 0, 5) self.main_layout.setSpacing(0) diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index 82413ac4..c02e59ba 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -160,6 +160,7 @@ def load(self): self.loaded = True def setup_ui(self): + self.setStyleSheet("background: transparent; border: none;") main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 5, 0, 5) main_layout.setSpacing(0) From 27006866e9f597083aa72e5fef9d612846432bd4 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 12:46:10 -0400 Subject: [PATCH 14/17] alignment and dialog improvements --- src/gui/models/dialog.py | 293 +++++++++++++++--- src/gui/profile_editor/affixes_tab.py | 253 +++++++++++---- src/gui/profile_editor/aspect_upgrades_tab.py | 15 + src/gui/profile_editor/global_uniques_tab.py | 31 +- src/gui/profile_editor/sigils_tab.py | 59 +++- src/gui/profile_editor/tributes_tab.py | 66 +++- src/gui/settings_tab.py | 4 +- 7 files changed, 603 insertions(+), 118 deletions(-) diff --git a/src/gui/models/dialog.py b/src/gui/models/dialog.py index 449c372a..16ddee93 100644 --- a/src/gui/models/dialog.py +++ b/src/gui/models/dialog.py @@ -58,11 +58,41 @@ class MinPowerDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Power") - self.setFixedSize(250, 150) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + + self.main_layout = QVBoxLayout(self) + + header = QLabel("Set Minimum Power") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Set the minimum item power for all filtered items in this profile.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) self.form_layout = QFormLayout() - self.label = QLabel("Min Power:") + self.label = QLabel("Item Power:") + self.label.setStyleSheet("color: #e2e8f0;") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, MAX_POWER) self.spinBox.setValue(MAX_POWER) @@ -78,7 +108,6 @@ def __init__(self, parent=None): self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() @@ -88,11 +117,41 @@ class MinGreaterDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Greater Affix") - self.setFixedSize(250, 150) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + + self.main_layout = QVBoxLayout(self) + + header = QLabel("Set Minimum Greater Affixes") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Set the minimum number of Greater Affixes required for all items in this profile.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) self.form_layout = QFormLayout() - self.label = QLabel("Min Greater Affix:") + self.label = QLabel("GA Count:") + self.label.setStyleSheet("color: #e2e8f0;") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, 4) self.spinBox.setValue(0) @@ -108,7 +167,6 @@ def __init__(self, parent=None): self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() @@ -118,11 +176,41 @@ class MinPercentDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Set Min Percent Of Affix") - self.setFixedSize(250, 150) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + + self.main_layout = QVBoxLayout(self) + + header = QLabel("Set Minimum Roll Percentage") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Update all affixes in this profile to use a minimum roll percentage threshold.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) self.form_layout = QFormLayout() - self.label = QLabel("Min Percent Of Affix:") + self.label = QLabel("Roll %:") + self.label.setStyleSheet("color: #e2e8f0;") self.spinBox = IgnoreScrollWheelSpinBox() self.spinBox.setRange(0, 100) self.spinBox.setValue(70) @@ -138,7 +226,6 @@ def __init__(self, parent=None): self.buttonLayout.addWidget(self.cancelButton) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) def get_value(self): return self.spinBox.value() @@ -148,12 +235,42 @@ class CreateItem(QDialog): def __init__(self, item_list: list[str], parent=None): super().__init__(parent) self.setWindowTitle("Create Item") - self.setFixedSize(300, 150) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + + self.main_layout = QVBoxLayout(self) + + header = QLabel("Create New Item Filter") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Enter a descriptive name for this item configuration (e.g., 'Tornado Gloves').") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) self.form_layout = QFormLayout() self.name_label = QLabel("Item Name:") + self.name_label.setStyleSheet("color: #e2e8f0;") self.name_input = QLineEdit() self.form_layout.addRow(self.name_label, self.name_input) self.item_list = item_list @@ -169,8 +286,6 @@ def __init__(self, item_list: list[str], parent=None): self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) - def accept(self): if not self.name_input.text(): QMessageBox.warning(self, "Warning", "Item name cannot be empty") @@ -199,14 +314,44 @@ class CreateProfileDialog(QDialog): def __init__(self, existing_profile_names: list[str], parent=None): super().__init__(parent) self.setWindowTitle("Create New Profile") - self.setFixedSize(300, 150) + self.setMinimumWidth(450) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + self.existing_profile_names = existing_profile_names + self.main_layout = QVBoxLayout(self) + + header = QLabel("Create New Profile") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Enter a name and select your character class to initialize a new filtering profile.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) - self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() # Profile Name self.name_label = QLabel("Profile Name:") + self.name_label.setStyleSheet("color: #e2e8f0;") self.name_input = QLineEdit() self.form_layout.addRow(self.name_label, self.name_input) @@ -229,8 +374,6 @@ def __init__(self, existing_profile_names: list[str], parent=None): self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) - def accept(self): profile_name = self.name_input.text().strip() if not profile_name: @@ -253,20 +396,42 @@ class DeleteItem(QDialog): def __init__(self, item_names, parent=None): super().__init__(parent) self.setWindowTitle("Delete Items") - self.setFixedSize(300, 200) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + QScrollArea { background-color: #09090b; border: 1px solid #3f3f46; border-radius: 4px; } + """) + + self.main_layout = QVBoxLayout(self) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + header = QLabel("Delete Item Filters") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ef4444; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Select the item configurations you wish to permanently remove.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) + self.groupbox = QGroupBox("Items") + self.groupbox.setStyleSheet( + "QGroupBox { border: 1px solid #3f3f46; margin-top: 10px; padding-top: 10px; color: #e2e8f0; }" + ) scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) + scrollable_layout.setContentsMargins(10, 10, 10, 10) self.groupbox_layout = QVBoxLayout() - label = QLabel("Select items to delete:") - label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - self.groupbox_layout.addWidget(label) - self.checkbox_list = [] for name in item_names: checkbox = CheckmarkCheckBox(name) @@ -288,8 +453,6 @@ def __init__(self, item_names, parent=None): self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) - def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] @@ -303,18 +466,41 @@ def __init__(self, nb_affix_pool, inherent: bool = False, parent=None): else: self.setWindowTitle("Delete Affix Pool") self.groupbox = QGroupBox("Affix Pool") - self.setFixedSize(300, 200) - self.main_layout = QVBoxLayout() + self.setMinimumWidth(400) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + QScrollArea { background-color: #09090b; border: 1px solid #3f3f46; border-radius: 4px; } + """) + + self.main_layout = QVBoxLayout(self) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + header = QLabel("Remove Affix Pools") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ef4444; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Select the specific affix pools you want to remove from this item.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) + scroll_area = QScrollArea(self) scroll_widget = QWidget(scroll_area) scrollable_layout = QVBoxLayout(scroll_widget) + scrollable_layout.setContentsMargins(10, 10, 10, 10) self.groupbox_layout = QVBoxLayout() - label = QLabel("Select items to delete:") - label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - self.groupbox_layout.addWidget(label) + self.groupbox.setStyleSheet( + "QGroupBox { border: 1px solid #3f3f46; margin-top: 10px; padding-top: 10px; color: #e2e8f0; }" + ) self.checkbox_list = [] for i in range(nb_affix_pool): @@ -337,8 +523,6 @@ def __init__(self, nb_affix_pool, inherent: bool = False, parent=None): self.main_layout.addWidget(self.groupbox) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) - def get_value(self): return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()] @@ -606,14 +790,45 @@ def __init__(self, aspect_upgrades: list[str], parent=None): self.aspect_upgrades = aspect_upgrades self.setWindowTitle("Add Aspect") - self.setFixedSize(300, 150) + self.setMinimumWidth(450) + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) + + self.main_layout = QVBoxLayout(self) + + header = QLabel("Add Aspect to Whitelist") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + self.main_layout.addWidget(header) + + desc = QLabel("Select a legendary aspect to track for Codex of Power upgrades.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + self.main_layout.addWidget(desc) - self.main_layout = QVBoxLayout() self.form_layout = QFormLayout() unchosen_aspect_ugprades = [x for x in Dataloader().aspect_list if x not in aspect_upgrades] - self.name_label = QLabel("Aspect:") + self.name_label = QLabel("Aspect Name:") + self.name_label.setStyleSheet("color: #e2e8f0;") + self.name_input = IgnoreScrollWheelComboBox() self.name_input.setEditable(True) self.name_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) @@ -633,8 +848,6 @@ def __init__(self, aspect_upgrades: list[str], parent=None): self.main_layout.addLayout(self.form_layout) self.main_layout.addLayout(self.buttonLayout) - self.setLayout(self.main_layout) - def get_value(self): return self.name_input.currentText() diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index 3c97c5a9..ae2efc2f 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -62,10 +62,7 @@ class TruncatingComboBox(IgnoreScrollWheelComboBox): def __init__(self, max_length=MAX_DROPDOWN_TEXT_LENGTH, parent=None): - # Initialize QComboBox (grandparent) with parent - QComboBox.__init__(self, parent) - # Then call the immediate parent's __init__ (IgnoreScrollWheelComboBox) without parent - IgnoreScrollWheelComboBox.__init__(self) + super().__init__(parent) self.max_length = max_length @override @@ -374,10 +371,38 @@ class UniqueAspectDialog(QDialog): def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): super().__init__(parent) self.setWindowTitle("Configure Unique Aspect") - self.setMinimumWidth(500) + self.setMinimumWidth(550) self.model = model + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) layout = QVBoxLayout(self) + header = QLabel("Unique Aspect Configuration") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + layout.addWidget(header) + + desc = QLabel("Set the name and threshold value or percentage for this unique aspect.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + layout.addWidget(desc) + form = QFormLayout() self.name_combo = TruncatingComboBox() @@ -394,15 +419,6 @@ def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): form.addRow("Mode:", self.mode_combo) self.value_edit = QLineEdit() - self.value_edit.setStyleSheet(""" - QLineEdit { - background-color: #09090b; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #e2e8f0; - } - QLineEdit:focus { border-color: #3b82f6; } - """) if model.min_percent_of_aspect: self.value_edit.setText(str(model.min_percent_of_aspect)) elif model.value is not None: @@ -443,12 +459,74 @@ class AffixEditDialog(QDialog): def __init__(self, parent: QWidget, model: AffixFilterModel): super().__init__(parent) self.setWindowTitle("Configure Affix") - self.setMinimumWidth(500) + self.setMinimumWidth(550) self.model = model + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) layout = QVBoxLayout(self) - self.editor = AffixWidget(model) - layout.addWidget(self.editor) + header = QLabel("Affix Configuration") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + layout.addWidget(header) + + desc = QLabel("Configure the properties, GA requirements, and thresholds for this affix.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + layout.addWidget(desc) + + form = QFormLayout() + + self.name_combo = TruncatingComboBox() + self.name_combo.setEditable(True) + self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) + self.name_combo.addItems(sorted(Dataloader().affix_dict.values())) + if model.name in Dataloader().affix_dict: + self.name_combo.setCurrentText(Dataloader().affix_dict[model.name]) + form.addRow("Affix:", self.name_combo) + + options_layout = QHBoxLayout() + self.required_checkbox = CheckmarkCheckBox("Required") + self.required_checkbox.setChecked(getattr(model, "required", False)) + self.greater_checkbox = CheckmarkCheckBox("GA") + self.greater_checkbox.setChecked(model.want_greater) + self.greater_checkbox.setProperty("greaterCheckbox", True) # noqa: FBT003 + options_layout.addWidget(self.required_checkbox) + options_layout.addWidget(self.greater_checkbox) + options_layout.addStretch() + form.addRow("Options:", options_layout) + + self.mode_combo = IgnoreScrollWheelComboBox() + self.mode_combo.addItems([AFFIX_VALUE_MODE, AFFIX_PERCENT_MODE]) + self.mode_combo.setCurrentText(AFFIX_PERCENT_MODE if model.min_percent_of_affix else AFFIX_VALUE_MODE) + form.addRow("Mode:", self.mode_combo) + + self.value_edit = QLineEdit() + if model.min_percent_of_affix: + self.value_edit.setText(str(model.min_percent_of_affix)) + elif model.value is not None: + self.value_edit.setText(str(model.value)) + form.addRow("Threshold:", self.value_edit) + + layout.addLayout(form) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.save_and_accept) @@ -456,10 +534,29 @@ def __init__(self, parent: QWidget, model: AffixFilterModel): layout.addWidget(buttons) def save_and_accept(self): - # AffixWidget updates model live on changes, but we ensure final strings are set - self.editor.update_name() - self.editor.update_required() - self.editor.update_value(self.editor.value_edit.text()) + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + affix_id = reverse_dict.get(self.name_combo.currentText()) + if affix_id: + self.model.name = affix_id + + self.model.required = self.required_checkbox.isChecked() + self.model.want_greater = self.greater_checkbox.isChecked() + + mode = self.mode_combo.currentText() + val_str = self.value_edit.text() + + if mode == AFFIX_PERCENT_MODE: + try: + self.model.min_percent_of_affix = int(val_str) if val_str else 0 + except ValueError: + self.model.min_percent_of_affix = 0 + self.model.value = None + else: + try: + self.model.value = float(val_str) if val_str else None + except ValueError: + self.model.value = None + self.model.min_percent_of_affix = 0 self.accept() @@ -467,10 +564,37 @@ class AffixPoolDialog(QDialog): def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): super().__init__(parent) self.setWindowTitle(title) - self.setMinimumSize(600, 500) + self.setMinimumSize(700, 600) self.pool = pool + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) layout = QVBoxLayout(self) + header = QLabel(title) + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + layout.addWidget(header) + + desc = QLabel("Manage the list of affixes in this pool and define how many matches are required.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + layout.addWidget(desc) config_layout = QHBoxLayout() self.min_count = CharacterSpinBox() @@ -728,14 +852,12 @@ def add_unique_aspect_item(self, model: AspectUniqueFilterModel): return widget def add_unique_aspect(self): - items = sorted(Dataloader().aspect_unique_dict.keys()) - dialog = SelectionDialog(self, "Select Unique Aspect", items) - if dialog.exec() == QDialog.DialogCode.Accepted: - val = dialog.get_value() - if val: - new_model = AspectUniqueFilterModel(name=val, value=None) - self.config.unique_aspect.append(new_model) - self.add_unique_aspect_item(new_model).open_config_dialog() + aspect_name = next(iter(Dataloader().aspect_unique_dict.keys())) + new_model = AspectUniqueFilterModel(name=aspect_name) + self.config.unique_aspect.append(new_model) + widget = self.add_unique_aspect_item(new_model) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_unique_aspect_widget(widget) def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): if widget.unique_aspect in self.config.unique_aspect: @@ -777,7 +899,9 @@ def add_affix_to_pool(self, pool_model: AffixFilterCountModel): default_affix = AffixFilterModel(name=default_name, value=None) pool_model.count.append(default_affix) - self.add_affix_item(default_affix, pool_idx=idx).open_config_dialog() + widget = self.add_affix_item(default_affix, pool_idx=idx) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_affix_item_widget(widget, inherent=False, pool_idx=idx) def add_affix_pool(self): if self.config.affix_pool: @@ -796,7 +920,9 @@ def add_inherent_pool(self): default_affix = AffixFilterModel(name=default_name, value=None) self.config.inherent_pool[0].count.append(default_affix) - self.add_affix_item(default_affix, inherent=True).open_config_dialog() + widget = self.add_affix_item(default_affix, inherent=True) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_affix_item_widget(widget, inherent=True) def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): nb_pool = layout_widget.count() @@ -1067,11 +1193,13 @@ def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: self.open_config_dialog() - def open_config_dialog(self): + def open_config_dialog(self) -> QDialog.DialogCode: dialog = UniqueAspectDialog(self, self.unique_aspect) - if dialog.exec() == QDialog.DialogCode.Accepted: + result = dialog.exec() + if result == QDialog.DialogCode.Accepted: self.refresh_display() self.config_changed.emit() + return result def refresh_display(self): name = Dataloader().aspect_unique_dict.get(self.unique_aspect.name, {}).get("name", self.unique_aspect.name) @@ -1188,11 +1316,13 @@ def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: self.open_config_dialog() - def open_config_dialog(self): + def open_config_dialog(self) -> QDialog.DialogCode: dialog = AffixEditDialog(self, self.model) - if dialog.exec() == QDialog.DialogCode.Accepted: + result = dialog.exec() + if result == QDialog.DialogCode.Accepted: self.refresh_display() self.config_changed.emit() + return result def refresh_display(self): name = Dataloader().affix_dict.get(self.model.name, self.model.name) @@ -1282,12 +1412,13 @@ class AffixWidget(QWidget): def __init__(self, affix: AffixFilterModel, parent=None): super().__init__(parent) self.affix = affix + self.setStyleSheet("background: transparent; border: none;") self.setup_ui() def setup_ui(self): - layout = QHBoxLayout() - layout.setContentsMargins(0, 2, 0, 2) - layout.setSpacing(10) + main_vbox = QVBoxLayout(self) + main_vbox.setContentsMargins(0, 5, 0, 5) + main_vbox.setSpacing(8) self.create_affix_name_combobox() self.create_greater_checkbox() @@ -1298,13 +1429,18 @@ def setup_ui(self): self.mode_combo.currentTextChanged.connect(self.update_mode) self.update_mode(self.mode_combo.currentText()) - layout.addWidget(self.name_combo) - layout.addWidget(self.required_checkbox) - layout.addWidget(self.greater_checkbox) - layout.addWidget(self.mode_combo, stretch=0) - layout.addWidget(self.value_edit, stretch=0) + # Top row: Affix selection + main_vbox.addWidget(self.name_combo) - self.setLayout(layout) + # Bottom row: Options and Values + bottom_hbox = QHBoxLayout() + bottom_hbox.setSpacing(10) + bottom_hbox.addWidget(self.required_checkbox) + bottom_hbox.addWidget(self.greater_checkbox) + bottom_hbox.addStretch() + bottom_hbox.addWidget(self.mode_combo) + bottom_hbox.addWidget(self.value_edit) + main_vbox.addLayout(bottom_hbox) def create_affix_name_combobox(self): # The previous line `self.name_combo = IgnoreScrollWheelComboBox()` was redundant and overwritten. @@ -1363,15 +1499,6 @@ def create_mode_combobox(self): def create_value_input(self): self.value_edit = QLineEdit() self.value_edit.setFixedWidth(80) - self.value_edit.setStyleSheet(""" - QLineEdit { - background-color: #09090b; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #e2e8f0; - } - QLineEdit:focus { border-color: #3b82f6; } - """) self.value_edit.textChanged.connect(self.update_value) def update_name(self, current_text=None): @@ -1465,24 +1592,25 @@ def setup_ui(self): QTabBar::tab { background: #1a1a1a; color: #94a3b8; - padding: 8px 16px; + padding: 8px 24px 8px 12px; border: 1px solid #334155; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; margin-right: 2px; } - QTabBar::close-button { right: 8px; } + QTabBar::close-button:hover { background-color: rgba(255, 255, 255, 0.1); } QTabBar::tab:selected { background: #1e3a5f; color: #e2e8f0; border: 1px solid #3b82f6; border-bottom: 2px solid #3b82f6; } - QTabBar::tab:last, QTabBar::tab:selected:last { + QTabBar::tab:last, QTabBar::tab:selected:last, QTabBar::tab:only-one, QTabBar::tab:selected:only-one { background: #06201b; color: #22c55e; border: 1px solid #064e3b; + border-bottom: 1px solid #064e3b; } """) with QSignalBlocker(self.tab_widget): @@ -1593,6 +1721,17 @@ def close_tab(self, index): if self.tab_widget.tabText(index) == "+": return + item_name = self.item_names[index] + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete the item filter '{item_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + with QSignalBlocker(self.tab_widget): name = self.item_names.pop(index) self.item_data_map.pop(name, None) diff --git a/src/gui/profile_editor/aspect_upgrades_tab.py b/src/gui/profile_editor/aspect_upgrades_tab.py index f00c90b4..95f2dd23 100644 --- a/src/gui/profile_editor/aspect_upgrades_tab.py +++ b/src/gui/profile_editor/aspect_upgrades_tab.py @@ -80,6 +80,21 @@ def setup_ui(self): self.main_layout.setSpacing(0) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.header = QLabel("Aspect Upgrades") + self.header.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;" + ) + self.main_layout.addWidget(self.header) + + self.desc = QLabel( + "Whitelist specific aspects to track for Codex upgrades. Items matching these will be favorited and highlighted in orange when an upgrade is detected." + ) + self.desc.setWordWrap(True) + self.desc.setStyleSheet( + "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;" + ) + self.main_layout.addWidget(self.desc) + header = _create_column_header("Aspect Upgrades", self.add_aspect) self.main_layout.addWidget(header) diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 14177fd7..0096774d 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -324,7 +324,9 @@ def add_unique_aspect(self): aspect_name = next(iter(Dataloader().aspect_unique_dict.keys())) new_aspect = AspectUniqueFilterModel(name=aspect_name) self.unique_model.unique_aspect.append(new_aspect) - self.add_unique_aspect_item(new_aspect).open_config_dialog() + widget = self.add_unique_aspect_item(new_aspect) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_unique_aspect_widget(widget) def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): if widget.unique_aspect in self.unique_model.unique_aspect: @@ -337,7 +339,9 @@ def add_affix_to_pool(self, pool_model: AffixFilterCountModel): affix_name = next(iter(Dataloader().affix_dict.keys())) new_affix = AffixFilterModel(name=affix_name) pool_model.count.append(new_affix) - self.add_affix_item(new_affix, pool_idx=idx).open_config_dialog() + widget = self.add_affix_item(new_affix, pool_idx=idx) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_affix_item_widget(widget, inherent=False, pool_idx=idx) def add_affix_pool(self): if self.unique_model.affix_pool: @@ -347,7 +351,9 @@ def add_inherent_pool(self): affix_name = next(iter(Dataloader().affix_dict.keys())) new_affix = AffixFilterModel(name=affix_name) self.unique_model.inherent_pool[0].count.append(new_affix) - self.add_affix_item(new_affix, inherent=True).open_config_dialog() + widget = self.add_affix_item(new_affix, inherent=True) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_affix_item_widget(widget, inherent=True) def toggle_auto_sync(self): is_auto = self.auto_sync_checkbox.isChecked() @@ -446,24 +452,25 @@ def setup_ui(self): QTabBar::tab { background: #1a1a1a; color: #94a3b8; - padding: 8px 16px; + padding: 8px 24px 8px 12px; border: 1px solid #334155; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; margin-right: 2px; } - QTabBar::close-button { right: 8px; } + QTabBar::close-button:hover { background-color: rgba(255, 255, 255, 0.1); } QTabBar::tab:selected { background: #1e3a5f; color: #e2e8f0; border: 1px solid #3b82f6; border-bottom: 2px solid #3b82f6; } - QTabBar::tab:last, QTabBar::tab:selected:last { + QTabBar::tab:last, QTabBar::tab:selected:last, QTabBar::tab:only-one, QTabBar::tab:selected:only-one { background: #06201b; color: #22c55e; border: 1px solid #064e3b; + border-bottom: 1px solid #064e3b; } """) with QSignalBlocker(self.tab_widget): @@ -502,6 +509,18 @@ def _update_plus_tab_button(self): def close_tab(self, index): if self.tab_widget.tabText(index) == "+": return + + rule_name = self.tab_widget.tabText(index) + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete the global rule '{rule_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + with QSignalBlocker(self.tab_widget): self.tab_widget.removeTab(index) self.unique_model_list.pop(index) diff --git a/src/gui/profile_editor/sigils_tab.py b/src/gui/profile_editor/sigils_tab.py index e344062c..2dacd673 100644 --- a/src/gui/profile_editor/sigils_tab.py +++ b/src/gui/profile_editor/sigils_tab.py @@ -93,12 +93,14 @@ def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: self.open_config_dialog() - def open_config_dialog(self): + def open_config_dialog(self) -> QDialog.DialogCode: name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name) dialog = SigilEditDialog(self, self.model, name) - if dialog.exec() == QDialog.DialogCode.Accepted: + result = dialog.exec() + if result == QDialog.DialogCode.Accepted: self.refresh_display() self.config_changed.emit() + return result def refresh_display(self): name = Dataloader().affix_sigil_dict_all["dungeons"].get(self.model.name, self.model.name) @@ -115,10 +117,39 @@ class SigilEditDialog(QDialog): def __init__(self, parent: QWidget, model: SigilConditionModel, dungeon_name: str): super().__init__(parent) self.setWindowTitle("Configure Sigil Rule") - self.setMinimumWidth(500) + self.setMinimumWidth(550) self.model = model + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox, QListWidget { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QListWidget::item:selected { background-color: #1e3a5f; color: #e2e8f0; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) layout = QVBoxLayout(self) + header = QLabel("Sigil Rule Configuration") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + layout.addWidget(header) + + desc = QLabel("Select a dungeon and define the specific affixes required for this rule.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + layout.addWidget(desc) + form = QFormLayout() self.name_combo = TruncatingComboBox() @@ -212,6 +243,21 @@ def setup_ui(self): self.main_layout.setSpacing(0) self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.header = QLabel("Sigil Filtering") + self.header.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;" + ) + self.main_layout.addWidget(self.header) + + self.desc = QLabel( + "Define which dungeons and affixes you want to whitelist or blacklist. Priority mode determines which list takes precedence." + ) + self.desc.setWordWrap(True) + self.desc.setStyleSheet( + "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;" + ) + self.main_layout.addWidget(self.desc) + # 1. General Config self.create_general_groupbox() @@ -254,7 +300,8 @@ def create_col(title, add_cb): self.init_sigils() def create_general_groupbox(self): - group = QGroupBox("Sigil Filtering") + group = QGroupBox("Configuration") + group.setStyleSheet("QGroupBox { margin-top: 10px; }") form = QFormLayout(group) self.priority_combobox = IgnoreScrollWheelComboBox() self.priority_combobox.addItems(SigilPriority._member_names_) @@ -293,7 +340,9 @@ def _create_new_sigil(self, whitelist: bool): else: self.sigil_model.blacklist.append(new_sigil) - self.add_sigil_widget(new_sigil, whitelist).open_config_dialog() + widget = self.add_sigil_widget(new_sigil, whitelist) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_sigil_item(widget, whitelist) def remove_sigil_item(self, widget: SigilSummaryWidget, whitelist: bool): model_list = self.sigil_model.whitelist if whitelist else self.sigil_model.blacklist diff --git a/src/gui/profile_editor/tributes_tab.py b/src/gui/profile_editor/tributes_tab.py index c02e59ba..0e45aa7c 100644 --- a/src/gui/profile_editor/tributes_tab.py +++ b/src/gui/profile_editor/tributes_tab.py @@ -81,11 +81,13 @@ def mousePressEvent(self, event): if event is None or event.button() == Qt.MouseButton.LeftButton: self.open_config_dialog() - def open_config_dialog(self): + def open_config_dialog(self) -> QDialog.DialogCode: dialog = TributeEditDialog(self, self.model) - if dialog.exec() == QDialog.DialogCode.Accepted: + result = dialog.exec() + if result == QDialog.DialogCode.Accepted: self.refresh_display() self.config_changed.emit() + return result def refresh_display(self): if self.model.name: @@ -104,11 +106,47 @@ class TributeEditDialog(QDialog): def __init__(self, parent: QWidget, model: TributeFilterModel): super().__init__(parent) self.setWindowTitle("Configure Tribute Rule") - self.setMinimumWidth(450) + self.setMinimumWidth(500) self.model = model self.rarity_checkboxes: dict[ItemRarity, CheckmarkCheckBox] = {} + self.setStyleSheet(""" + QDialog { background-color: #1a1a1a; color: #e2e8f0; } + QLineEdit, QComboBox, QSpinBox { + background-color: #09090b; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #e2e8f0; + padding: 4px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #3b82f6; } + QGroupBox { + font-weight: bold; + color: #3b82f6; + border: 1px solid #334155; + margin-top: 1.1em; + padding-top: 10px; + } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; } + QPushButton { + background-color: #262626; + border: 1px solid #3f3f46; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 4px; + } + QPushButton:hover { background-color: #323232; border-color: #52525b; } + """) layout = QVBoxLayout(self) + header = QLabel("Tribute Rule Configuration") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 5px;") + layout.addWidget(header) + + desc = QLabel("Set a specific tribute or configure rarity-based filtering.") + desc.setStyleSheet("font-size: 12px; color: #94a3b8; font-style: italic; margin-bottom: 15px;") + desc.setWordWrap(True) + layout.addWidget(desc) + form = QFormLayout() self.name_combo = TruncatingComboBox() @@ -166,10 +204,20 @@ def setup_ui(self): main_layout.setSpacing(0) main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - label = QLabel("Add tributes or rarity-based rules you want to keep. These rules are evaluated independently.") - label.setWordWrap(True) - label.setStyleSheet("color: #94a3b8; font-size: 11px; padding: 5px 10px;") - main_layout.addWidget(label) + self.header = QLabel("Tributes") + self.header.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #3b82f6; margin-bottom: 10px; background: transparent; border: none;" + ) + main_layout.addWidget(self.header) + + self.desc = QLabel( + "Add tributes or rarity-based rules you want to keep. These rules are evaluated independently." + ) + self.desc.setWordWrap(True) + self.desc.setStyleSheet( + "font-size: 13px; color: #94a3b8; font-style: italic; margin-bottom: 15px; background: transparent; border: none;" + ) + main_layout.addWidget(self.desc) header = _create_column_header("Tributes", self.add_tribute) main_layout.addWidget(header) @@ -205,7 +253,9 @@ def add_tribute(self): tribute_id = next(iter(Dataloader().tribute_dict.keys())) new_rule = TributeFilterModel(name=tribute_id, rarities=[]) self.tributes.append(new_rule) - self.add_tribute_widget(new_rule).open_config_dialog() + widget = self.add_tribute_widget(new_rule) + if widget.open_config_dialog() == QDialog.DialogCode.Rejected: + self.remove_tribute_item(widget) def remove_tribute_item(self, widget: TributeSummaryWidget): if widget.model in self.tributes: diff --git a/src/gui/settings_tab.py b/src/gui/settings_tab.py index 522e277b..6bb92870 100644 --- a/src/gui/settings_tab.py +++ b/src/gui/settings_tab.py @@ -613,8 +613,8 @@ def setEnabled(self, enabled): # noqa: N802 class IgnoreScrollWheelComboBox(QComboBox): - def __init__(self): - super().__init__() + def __init__(self, parent=None): + super().__init__(parent) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def wheelEvent(self, event): # noqa: N802 From 8f66b7ed30132439b75a191939dd1578df96c6a4 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 13:16:11 -0400 Subject: [PATCH 15/17] cleaned up inherent so it stops being added to yaml --- src/config/profile_models.py | 5 -- src/gui/profile_editor/affixes_tab.py | 68 +++------------ src/gui/profile_editor/global_uniques_tab.py | 88 ++++++++++---------- 3 files changed, 56 insertions(+), 105 deletions(-) diff --git a/src/config/profile_models.py b/src/config/profile_models.py index bcdc6424..720cc97b 100644 --- a/src/config/profile_models.py +++ b/src/config/profile_models.py @@ -186,9 +186,6 @@ def unique_aspect_names_must_be_unique(self) -> GlobalUniqueModel: raise ValueError(msg) if not self.affix_pool: self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] - self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] - if not self.inherent_pool: - self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self @@ -235,8 +232,6 @@ def unique_aspect_names_must_be_unique(self) -> ItemFilterModel: raise ValueError(msg) if not self.affix_pool: self.affix_pool = [AffixFilterCountModel(count=[], min_count=0)] - if not self.inherent_pool: - self.inherent_pool = [AffixFilterCountModel(count=[], min_count=0)] return self diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index ae2efc2f..ecc5b745 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -676,7 +676,6 @@ def __init__(self, dynamic_filter: DynamicItemFilterModel, parent=None): self.affix_column_widgets = [] self.affix_pool_layouts = [] self.affix_footers = [] - self.inherent_footer = None self.dynamic_filter = dynamic_filter for item_name, config in dynamic_filter.root.items(): self.item_name = item_name @@ -810,19 +809,11 @@ def setup_ui(self): for pool in self.config.affix_pool: self._add_affix_pool_column_widget(pool) - # Column 3: Inherent Pool - self.inherent_col, self.inherent_pool_layout, self.inherent_footer = self._create_col_helper( - "Inherent Pool", self.add_inherent_pool, self.config.inherent_pool[0] - ) - columns_layout.addWidget(self.inherent_col) - self.inherent_col.hide() - self.content_layout.addLayout(columns_layout) # Initialize content self.init_unique_aspects() self.init_affix_pool() - self.init_inherent_pool() def _on_duplicate_clicked(self): self.duplicate_requested.emit(self.dynamic_filter) @@ -834,12 +825,7 @@ def init_unique_aspects(self): def init_affix_pool(self): for i, pool in enumerate(self.config.affix_pool): for affix in pool.count: - self.add_affix_item(affix, inherent=False, pool_idx=i) - - def init_inherent_pool(self): - for i, pool in enumerate(self.config.inherent_pool): - for affix in pool.count: - self.add_affix_item(affix, inherent=True, pool_idx=i) + self.add_affix_item(affix, pool_idx=i) def _refresh_widget_style(self, widget): widget.style().unpolish(widget) @@ -865,19 +851,17 @@ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): widget.setParent(None) widget.deleteLater() - def add_affix_item(self, model: AffixFilterModel, inherent: bool = False, pool_idx: int = 0): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] - + def add_affix_item(self, model: AffixFilterModel, pool_idx: int = 0): + layout = self.affix_pool_layouts[pool_idx] widget = AffixSummaryWidget(model) - widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent, pool_idx)) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, pool_idx)) widget.config_changed.connect(self.update_greater_count_label) layout.addWidget(widget) return widget - def remove_affix_item_widget(self, widget, inherent: bool, pool_idx: int = 0): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] - pool = self.config.inherent_pool[0] if inherent else self.config.affix_pool[pool_idx] - + def remove_affix_item_widget(self, widget, pool_idx: int = 0): + layout = self.affix_pool_layouts[pool_idx] + pool = self.config.affix_pool[pool_idx] idx = layout.indexOf(widget) if idx != -1: pool.count.pop(idx) @@ -901,29 +885,12 @@ def add_affix_to_pool(self, pool_model: AffixFilterCountModel): pool_model.count.append(default_affix) widget = self.add_affix_item(default_affix, pool_idx=idx) if widget.open_config_dialog() == QDialog.DialogCode.Rejected: - self.remove_affix_item_widget(widget, inherent=False, pool_idx=idx) + self.remove_affix_item_widget(widget, pool_idx=idx) def add_affix_pool(self): if self.config.affix_pool: self.add_affix_to_pool(self.config.affix_pool[0]) - def add_inherent_pool(self): - common_affixes = ["Strength", "Dexterity", "Vitality", "Intelligence"] - default_name = None - reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} - for affix in common_affixes: - if affix in reverse_dict: - default_name = reverse_dict[affix] - break - if default_name is None: - default_name = next(iter(Dataloader().affix_dict.keys())) - - default_affix = AffixFilterModel(name=default_name, value=None) - self.config.inherent_pool[0].count.append(default_affix) - widget = self.add_affix_item(default_affix, inherent=True) - if widget.open_config_dialog() == QDialog.DialogCode.Rejected: - self.remove_affix_item_widget(widget, inherent=True) - def remove_selected(self, layout_widget: QVBoxLayout, inherent: bool = False): nb_pool = layout_widget.count() dialog = DeleteAffixPool(nb_pool, inherent) @@ -986,18 +953,8 @@ def add_cb(): # Only provide a remove callback for additional pools (index > 0) is_additional = self.config.affix_pool.index(pool_model) > 0 remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None - col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb) - - # Check if inherent_col is in the layout to ensure correct order - inherent_idx = -1 - if hasattr(self, "inherent_col"): - inherent_idx = self.columns_layout.indexOf(self.inherent_col) - - if inherent_idx != -1: - self.columns_layout.insertWidget(inherent_idx, col_widget) - else: - self.columns_layout.addWidget(col_widget) + self.columns_layout.addWidget(col_widget) self.affix_column_widgets.append(col_widget) self.affix_pool_layouts.append(inner_layout) @@ -1097,7 +1054,7 @@ def iter_affix_widgets(self): return [] def refresh_all_summaries(self): - for layouts in [self.affix_pool_layouts, [self.inherent_pool_layout]]: + for layouts in [self.affix_pool_layouts]: for layout in layouts: for i in range(layout.count()): w = layout.itemAt(i).widget() @@ -1131,9 +1088,6 @@ def update_greater_count_label(self): for footer, model in zip(self.affix_footers, self.config.affix_pool, strict=False): self._update_footer_constraints(footer, model) - # Update inherent pool footer - self._update_footer_constraints(self.inherent_footer, self.config.inherent_pool[0]) - def _update_footer_constraints(self, footer, model): if footer and model: min_spin = footer.property("min_spin") @@ -1592,7 +1546,7 @@ def setup_ui(self): QTabBar::tab { background: #1a1a1a; color: #94a3b8; - padding: 8px 24px 8px 12px; + padding: 8px 30px 8px 12px; border: 1px solid #334155; border-bottom: none; border-top-left-radius: 4px; diff --git a/src/gui/profile_editor/global_uniques_tab.py b/src/gui/profile_editor/global_uniques_tab.py index 0096774d..9ca4cd87 100644 --- a/src/gui/profile_editor/global_uniques_tab.py +++ b/src/gui/profile_editor/global_uniques_tab.py @@ -78,19 +78,11 @@ def setup_ui(self): for pool in self.unique_model.affix_pool: self._add_affix_pool_column_widget(pool) - # Column 3: Inherent Pool (Hidden) - self.inherent_col, self.inherent_pool_layout, self.inherent_footer = self._create_col_helper( - "Inherent Pool", self.add_inherent_pool, self.unique_model.inherent_pool[0] - ) - self.columns_layout.addWidget(self.inherent_col) - self.inherent_col.hide() - self.content_layout.addLayout(self.columns_layout) # Initialize content self.init_aspects() self.init_affix_pool() - self.init_inherent_pool() def _create_col_helper(self, title, add_cb, pool_model=None, remove_cb=None): col_widget = QWidget() @@ -134,16 +126,8 @@ def add_cb(): remove_cb = (lambda: self.remove_affix_pool_column(pool_model)) if is_additional else None col_widget, inner_layout, footer = self._create_col_helper("Affix Pool", add_cb, pool_model, remove_cb) + self.columns_layout.addWidget(col_widget) - # Match the insertion logic in affixes_tab to ensure Aspects are on the left - # and Affix Pools are in the middle. - inherent_idx = -1 - if hasattr(self, "inherent_col"): - inherent_idx = self.columns_layout.indexOf(self.inherent_col) - - insert_idx = inherent_idx if inherent_idx != -1 else self.columns_layout.count() - - self.columns_layout.insertWidget(insert_idx, col_widget) self.affix_column_widgets.append(col_widget) self.affix_pool_layouts.append(inner_layout) self.affix_footers.append(footer) @@ -179,24 +163,19 @@ def init_aspects(self): def init_affix_pool(self): for i, pool in enumerate(self.unique_model.affix_pool): for affix in pool.count: - self.add_affix_item(affix, inherent=False, pool_idx=i) + self.add_affix_item(affix, pool_idx=i) - def init_inherent_pool(self): - for i, pool in enumerate(self.unique_model.inherent_pool): - for affix in pool.count: - self.add_affix_item(affix, inherent=True, pool_idx=i) - - def add_affix_item(self, model: AffixFilterModel, inherent: bool = False, pool_idx: int = 0): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] + def add_affix_item(self, model: AffixFilterModel, pool_idx: int = 0): + layout = self.affix_pool_layouts[pool_idx] widget = AffixSummaryWidget(model) - widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, inherent, pool_idx)) + widget.delete_requested.connect(lambda: self.remove_affix_item_widget(widget, pool_idx)) widget.config_changed.connect(self.update_greater_count_label) layout.addWidget(widget) return widget - def remove_affix_item_widget(self, widget, inherent: bool, pool_idx: int = 0): - layout = self.inherent_pool_layout if inherent else self.affix_pool_layouts[pool_idx] - pool = self.unique_model.inherent_pool[0] if inherent else self.unique_model.affix_pool[pool_idx] + def remove_affix_item_widget(self, widget, pool_idx: int = 0): + layout = self.affix_pool_layouts[pool_idx] + pool = self.unique_model.affix_pool[pool_idx] idx = layout.indexOf(widget) if idx != -1: pool.count.pop(idx) @@ -285,8 +264,10 @@ def create_general_groupbox(self): self.settings.value(f"auto_sync_ga_global_{self.unique_model.profile_alias}", defaultValue=False, type=bool) ) self.auto_sync_checkbox.stateChanged.connect(self.toggle_auto_sync) + self.greater_count_label = QLabel() - self.greater_count_label.setStyleSheet("color: gray; font-style: italic;") + self.greater_count_label.setProperty("greaterCountLabel", True) # noqa: FBT003 + self._refresh_widget_style(self.greater_count_label) ga_row.addWidget(self.min_greater) ga_row.addWidget(self.auto_sync_checkbox) @@ -306,11 +287,20 @@ def create_general_groupbox(self): """) add_pool_btn.clicked.connect(self.add_additional_affix_pool_column) ga_row.addWidget(add_pool_btn) + + if self.auto_sync_checkbox.isChecked(): + self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003 + self._refresh_widget_style(self.min_greater) + main_vbox.addLayout(ga_row) self.min_greater.setEnabled(not self.auto_sync_checkbox.isChecked()) self.content_layout.addWidget(self.general_groupbox) + def _refresh_widget_style(self, widget): + widget.style().unpolish(widget) + widget.style().polish(widget) + def _on_duplicate_clicked(self): self.duplicate_requested.emit(self.unique_model) @@ -334,31 +324,44 @@ def remove_unique_aspect_widget(self, widget: UniqueAspectWidget): widget.setParent(None) widget.deleteLater() + def _get_default_affix_name(self) -> str: + common_affixes = ["Energy", "Strength", "Dexterity", "Vitality", "Intelligence"] + reverse_dict = {v: k for k, v in Dataloader().affix_dict.items()} + for affix in common_affixes: + if affix in reverse_dict: + return reverse_dict[affix] + return next(iter(Dataloader().affix_dict.keys())) + def add_affix_to_pool(self, pool_model: AffixFilterCountModel): idx = self.unique_model.affix_pool.index(pool_model) - affix_name = next(iter(Dataloader().affix_dict.keys())) - new_affix = AffixFilterModel(name=affix_name) + new_affix = AffixFilterModel(name=self._get_default_affix_name()) pool_model.count.append(new_affix) widget = self.add_affix_item(new_affix, pool_idx=idx) if widget.open_config_dialog() == QDialog.DialogCode.Rejected: - self.remove_affix_item_widget(widget, inherent=False, pool_idx=idx) + self.remove_affix_item_widget(widget, pool_idx=idx) def add_affix_pool(self): if self.unique_model.affix_pool: self.add_affix_to_pool(self.unique_model.affix_pool[0]) def add_inherent_pool(self): - affix_name = next(iter(Dataloader().affix_dict.keys())) - new_affix = AffixFilterModel(name=affix_name) + new_affix = AffixFilterModel(name=self._get_default_affix_name()) self.unique_model.inherent_pool[0].count.append(new_affix) - widget = self.add_affix_item(new_affix, inherent=True) + widget = self.add_affix_item(new_affix) if widget.open_config_dialog() == QDialog.DialogCode.Rejected: - self.remove_affix_item_widget(widget, inherent=True) + self.remove_affix_item_widget(widget) def toggle_auto_sync(self): is_auto = self.auto_sync_checkbox.isChecked() self.settings.setValue(f"auto_sync_ga_global_{self.unique_model.profile_alias}", is_auto) + self.min_greater.setEnabled(not is_auto) + if is_auto: + self.min_greater.setProperty("autoSyncSpin", True) # noqa: FBT003 + else: + self.min_greater.setProperty("autoSyncSpin", False) # noqa: FBT003 + self._refresh_widget_style(self.min_greater) + if is_auto: self.update_greater_count_label() @@ -372,8 +375,10 @@ def update_greater_count_label(self): if count == 0: self.greater_count_label.setText("(no greater affixes marked)") + elif count == 1: + self.greater_count_label.setText("(1 greater affix marked)") else: - self.greater_count_label.setText(f"({count} GAs required)") + self.greater_count_label.setText(f"({count} greater affixes marked)") if self.auto_sync_checkbox.isChecked(): with QSignalBlocker(self.min_greater): self.min_greater.set_value(count) @@ -382,9 +387,6 @@ def update_greater_count_label(self): for footer, model in zip(self.affix_footers, self.unique_model.affix_pool, strict=False): self._update_footer_constraints(footer, model) - # Update inherent pool footer - self._update_footer_constraints(self.inherent_footer, self.unique_model.inherent_pool[0]) - def _update_footer_constraints(self, footer, model): if footer and model: min_spin = footer.property("min_spin") @@ -452,14 +454,14 @@ def setup_ui(self): QTabBar::tab { background: #1a1a1a; color: #94a3b8; - padding: 8px 24px 8px 12px; + padding: 8px 50px 8px 16px; border: 1px solid #334155; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; margin-right: 2px; } - QTabBar::close-button:hover { background-color: rgba(255, 255, 255, 0.1); } + QTabBar::close-button:hover { background-color: #f87171; } QTabBar::tab:selected { background: #1e3a5f; color: #e2e8f0; From 49a6f6c014c53f34f460025edb63832a8cb61b4c Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 15:14:19 -0400 Subject: [PATCH 16/17] gendata updates to include more unique data, and create affix_metadata to be used for affix_tab limitation by slot --- assets/lang/enUS/affix_metadata.json | 9975 +++++++++++++++++++++++++ assets/lang/enUS/uniques.json | 590 ++ src/gui/profile_editor/affixes_tab.py | 225 +- src/tools/gen_data.py | 238 +- 4 files changed, 11002 insertions(+), 26 deletions(-) create mode 100644 assets/lang/enUS/affix_metadata.json diff --git a/assets/lang/enUS/affix_metadata.json b/assets/lang/enUS/affix_metadata.json new file mode 100644 index 00000000..928d7509 --- /dev/null +++ b/assets/lang/enUS/affix_metadata.json @@ -0,0 +1,9975 @@ +{ + "abyss_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "advance_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "agility_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "all_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "all_stats": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "all_stats_per_ferocity_or_resolve_stack": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "ancient_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "arbiter_of_justice_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "archfiend_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "armor": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "armor_in_arbiter_form": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "armor_while_in_human_form": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "attack_speed_for_seconds_after_casting_a_defensive_skill": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "attack_speed_for_seconds_after_dodging_an_attack": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "attack_speed_while_berserking": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "attacks_reduce_evades_cooldown_by_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "attacks_reduce_ultimate_cooldown_by_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "ball_lightning_projectile_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "barrier_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "basic_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "basic_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "basic_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "bleeding_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "blizzard_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "block_chance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Quarterstaff", + "Ring", + "Shield", + "Staff" + ] + }, + "blood_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "blood_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "blood_orbs_restore_essence": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "bone_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "bone_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "bone_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "bone_spirit_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "bone_spirit_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "boulder_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "brandish_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "brawling_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "burning_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "casting_justice_skills_restores_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "casting_macabre_skills_restores_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "casting_ultimate_skills_restores_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "casting_valor_skills_restores_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "casting_wrath_skills_restores_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "cataclysm_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "centipede_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_arbiter_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_army_of_the_dead_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_basic_skills_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_blood_lance_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_bone_storm_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_brandish_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_clash_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_concussive_stomp_to_extra_hit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_corpse_explosion_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_incinerate_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_judgement_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_payback_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_pestilent_swarm_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_potency_skills_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_ravens_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_retribution_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_rock_splitter_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_rushing_claw_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_sever_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_shield_bash_to_deal_double_damage": { + "slots": [ + "Shield" + ] + }, + "chance_for_shield_charge_to_deal_double_damage": { + "slots": [ + "Shield" + ] + }, + "chance_for_soar_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_soulrift_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_spear_of_the_heavens_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_for_the_devourer_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_the_hunter_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_the_protector_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_the_seeker_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_thrash_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_thunderspike_to_deal_double_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_vortex_to_extra_hit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "chance_for_withering_fist_to_deal_double_damage": { + "slots": [ + "Ring" + ] + }, + "chance_for_zeal_to_deal_double_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_to_cluck_thrice": { + "slots": [ + "Bow" + ] + }, + "chance_when_struck_to_fortify_for_life": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "chance_when_struck_to_gain_life_as_barrier_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "charge_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "clash_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "cold_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cold_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cold_mage_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cold_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "companion_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "conjuration_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "core_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "core_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "core_resource_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "corpse_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "corpse_explosion_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "corpse_tendrils_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "corrupting_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "counterattack_charges": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "crackling_energy_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "critical_strike_and_vulnerable_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_against_chilled_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_against_close_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_against_crowd_controlled_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_against_feared_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_against_injured_enemies": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "critical_strike_chance_against_stunned_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_chance_to_each_enhanced_rapid_fire_bonus": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "critical_strike_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cutthroat_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cutthroat_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cutthroat_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cutthroat_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "cyclone_armor_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "cyclone_armor_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_for_seconds_after_dodging_an_attack": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_for_seconds_after_gaining_resolve": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_for_seconds_after_killing_an_elite": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_for_seconds_after_picking_up_a_blood_orb": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_on_next_attack_after_entering_stealth": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_over_time": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_over_time_duration": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_over_time_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_per_combo_point_spent": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_per_overpower_stack": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_for_each_active_ball_lightning": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_for_your_summons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_bleeding_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_burning_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_close_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_corrupted_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_distant_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_elites": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_enemies_affected_by_blood_skills": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_enemies_affected_by_curse_skills": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_enemies_affected_by_trap_skills": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_from_poisoned_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_per_crackling_energy_charge": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_reduction_while_fortified": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_while_healthy": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_while_injured": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_while_standing_still": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_while_unstoppable": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_reduction_while_you_have_a_barrier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_angels_and_demons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_bleeding_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_burning_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_chilled_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_close_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_corrupted_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_crowd_controlled_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_cursed_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_dazed_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_distant_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_elites": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_frozen_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_immobilized_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_injured_enemies": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_to_judged_enemies": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_to_knockeddown_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_poisoned_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_poultry": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_slowed_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_stunned_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_trapped_enemies": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_to_weakened_enemies": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_when_spending_resolve": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_when_swapping_weapons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_berserking": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_fortified": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_healthy": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_in_arbiter_form": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_while_in_human_form": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_iron_maelstrom_is_active": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "damage_while_shadowform_is_active": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_shapeshifted": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_war_cry_is_active": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_while_wrath_of_the_berserker_is_active": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_with_dualwielded_weapons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_with_ranged_weapons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_with_twohanded_bludgeoning_weapons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "damage_with_twohanded_slashing_weapons": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "darkness_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "dash_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "death_blow_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "defensive_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "defensive_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "demonform_damage_bonus": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "demonology_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "desecrated_ground_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "dexterity": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "disciple_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "drinking_a_potion_grants_movement_speed_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "dust_devil_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "eagle_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "earth_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "earth_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "earth_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "earth_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "earthquake_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "enchantment_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "energy_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "energy_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "energy_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "energy_when_a_stun_grenade_explodes": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "essence_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "essence_on_hit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "essence_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "essence_per_enemy_drained_by_blood_surge": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "essence_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "evade_grants_attack_speed_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "evade_grants_movement_speed_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "faith_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "faith_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "familiar_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "feast_every_kills_chains_hook_nearby_enemies": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_gain_berserking_for_seconds": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_release_a_bloodsplosion_for_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_reset_random_cooldowns": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_restore_of_your_maximum_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_savagely_bite_times_for_damage_and_apply_vulnerable": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "feast_every_kills_your_next_core_skill_cast_deals_additional_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "ferocity_potency": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "fire_and_cold_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "fire_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "fire_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "fire_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "fireball_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "fireball_projectile_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "focus_cooldown_reduction": { + "slots": [ + "Focus" + ] + }, + "focus_damage": { + "slots": [ + "Focus" + ] + }, + "fortify_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "fortress_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "frost_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "frost_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "fury_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "fury_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "fury_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "golem_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "gorilla_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "grenade_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "ground_stomp_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "heavens_fury_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hellfire_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "holy_bolt_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "holy_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "holy_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "human_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "hunger_after_you_cast_a_basic_skill_chance_for_kill_to_your_kill_streak": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_after_you_cast_a_cooldown_kill_to_your_kill_streak": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_after_you_kill_an_enemy_chance_for_kill_to_your_kill_streak": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_every_resource_chance_for_kill_to_your_kill_streak": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_additional_gold_during_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_additional_salvage_materials_during_your_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_feast_items_during_your_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_hunger_items_during_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_rampage_items_during_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_increased_chance_for_runes_during_your_kill_streaks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hunger_lucky_hit_up_to_a_chance_for_kill_to_your_kill_streak": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "hurricane_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "hydra_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "hydra_resource_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "ice_blades_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "ice_spike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "imbued_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "imbued_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "imbuement_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "incarnate_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "indestructible": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "intelligence": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "invigorating_strike_energy_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "iron_maelstrom_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "iron_maiden_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "iron_skin_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "jaguar_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "judicator_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "juggernaut_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "justice_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "justice_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "kick_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lacerate_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "leap_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "life_on_hit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "life_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "life_per_seconds": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "life_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "life_steal": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "lightning_bolt_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lightning_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lightning_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lightning_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lightning_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "lightning_spear_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_chance": { + "slots": [ + "Ring" + ] + }, + "lucky_hit_critical_strikes_have_up_to_a_chance_to_daze_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_critical_strikes_have_up_to_a_chance_to_immobilize_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_critical_strikes_have_up_to_a_chance_to_slow_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_critical_strikes_have_up_to_a_chance_to_stun_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_cold_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_fire_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_holy_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_lightning_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_physical_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_poison_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_deal_shadow_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_heal_life": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "lucky_hit_up_to_a_chance_to_make_enemies_vulnerable_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_chance_to_restore_primary_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "lucky_hit_up_to_a_chance_to_weaken_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lucky_hit_up_to_a_damage_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "lunging_strike_healing": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "macabre_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "main_hand_weapon_damage": { + "slots": [ + "Shield" + ] + }, + "mana_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "mana_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "mana_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "marksman_attack_speed_per_precison_stack": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "marksman_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "marksman_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "marksman_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "mastery_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "maximum_energy": { + "slots": [ + "Axe" + ] + }, + "maximum_essence": { + "slots": [ + "Axe" + ] + }, + "maximum_fury": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_life": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_mana": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_resolve_stacks": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_spirit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "maximum_vigor": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "mobility_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "mobility_skills_grant_movement_speed_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_for_seconds_after_killing_an_elite": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_for_seconds_after_killing_an_enemy": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_for_seconds_after_picking_up_crackling_energy": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "movement_speed_while_berserking": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_while_cataclysm_is_active": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_while_hurricane_is_active": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_while_in_human_form": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_while_shapeshifted_into_a_werewolf": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "movement_speed_while_the_inner_sight_gauge_is_full": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "mystic_circle_potency": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "nonphysical_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "occult_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "overpower_critical_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "pestilent_swarm_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "physical_critical_strike_chance_against_elites": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "physical_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "physical_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "physical_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "pickup_radius": { + "slots": [ + "Staff" + ] + }, + "poison_creeper_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "poison_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "poison_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "poison_damage_over_time_duration": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "poison_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "poisoning_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "potency_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "potency_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "primary_centipede_spirit_hall_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "primary_eagle_spirit_hall_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "primary_gorilla_spirit_hall_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "primary_jaguar_spirit_hall_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "puncture_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "pyromancy_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "pyromancy_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "pyromancy_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "rabies_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "rain_of_arrows_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "rampage_attack_speed_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_cooldown_reduction_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_critical_strike_chance_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_dexterity_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_intelligence_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_life_on_hit_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_lucky_hit_chance_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_maximum_life_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_movement_speed_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_resource_cost_reduction_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_strength_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rampage_willpower_per_kill_streak_tier": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "ravager_on_kill_duration_extension": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "ravens_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "ravens_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "razor_wings_charges": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resistance_to_all_elements": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "resolve_generated": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation_and_maximum": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation_while_wielding_a_scythe": { + "slots": [ + "Scythe" + ] + }, + "resource_generation_while_wielding_a_shield": { + "slots": [ + "Shield" + ] + }, + "resource_generation_with_dualwielded_weapons": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation_with_polearms": { + "slots": [ + "Polearm" + ] + }, + "resource_generation_with_twohanded_bludgeoning_weapons": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation_with_twohanded_slashing_weapons": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_generation_with_twohanded_weapons": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "resource_on_hit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rock_splitter_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "rupture_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "rushing_claw_charges": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "scourge_poisoning_duration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "shade_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shadow_clone_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shadow_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shadow_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shadow_resistance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "shadow_step_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shapeshifting_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shield_charge_cooldown_reduction": { + "slots": [ + "Shield" + ] + }, + "shock_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shock_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shock_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "shred_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "sigil_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "skeleton_mage_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "smoke_grenade_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "soar_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "soar_deals_up_to_damage_based_on_distance_traveled": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "soar_grants_maximum_life_as_barrier_for_seconds": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "spirit_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "spirit_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "spirit_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "steel_grasp_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "storm_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "storm_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "storm_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "storm_feather_potency": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "storm_strike_chains_to_targets": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "strength": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "stun_grenade_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "summon_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "summon_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "summon_movement_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "teleport_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "the_devourer_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "the_hunter_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "the_protector_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "the_seeker_charges": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "the_seeker_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "thorns": { + "slots": [ + "Shield" + ] + }, + "thrash_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "thunderspike_resource_generation": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_armored_hide": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_blessed_shield": { + "slots": [ + "Shield" + ] + }, + "to_bone_spirit": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_bone_splinters": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_centipede_skills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_concussive_stomp": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_core_skills": { + "slots": [ + "Ring" + ] + }, + "to_counterattack": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_crushing_hand": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_cyclone_armor": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_eagle_skills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_flame_shield": { + "slots": [ + "Shield" + ] + }, + "to_focus_skills": { + "slots": [ + "Focus" + ] + }, + "to_gorilla_skills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_ice_armor": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_invigorating_strike": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_jaguar_skills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_lunging_strike": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_mighty_throw": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "to_payback": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_potency_skills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_prime_bone_storms_damage_reduction": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "to_quill_volley": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_rake": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_ravager": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_razor_wings": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_rock_splitter": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_rushing_claw": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_scourge": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_shield_bash": { + "slots": [ + "Shield" + ] + }, + "to_shield_charge": { + "slots": [ + "Shield" + ] + }, + "to_soar": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_stinger": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_storm_strike": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_the_pack_leader_spirit_boons_lucky_hit_chance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_thrash": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_thunderspike": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_touch_of_death": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_toxic_skin": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_vortex": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "to_weapon_mastery_skills": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "to_withering_fist": { + "slots": [ + "Ring" + ] + }, + "total_armor": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "total_armor_while_in_werebear_form": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "total_armor_while_in_werewolf_form": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "trample_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "trap_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "ultimate_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "valor_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "versatile_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "vigor_cost_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "vigor_on_kill": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "vigor_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "vigor_when_resolve_is_lost": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "vulnerable_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Boots", + "Bow", + "ChestArmor", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Gloves", + "Helm", + "Legs", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "vulnerable_damage_multiplier": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "weapon_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "weapon_mastery_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "weapon_mastery_cooldown_reduction": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "weapon_mastery_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "werebear_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "werewolf_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "werewolf_critical_strike_chance": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "werewolf_critical_strike_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "werewolf_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "while_injured_your_potion_also_grants_movement_speed_for_seconds": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "while_injured_your_potion_also_restores_resource": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "willpower": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "wing_strike_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "withering_fist_resource_generation": { + "slots": [ + "Ring" + ] + }, + "wolves_attack_speed": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "wolves_damage": { + "slots": [ + "Amulet", + "Axe", + "Axe2H", + "Bow", + "Crossbow2H", + "Dagger", + "Flail", + "Focus", + "Glaive", + "Mace", + "Mace2H", + "OffHandTotem", + "Polearm", + "Quarterstaff", + "Ring", + "Scythe", + "Scythe2H", + "Shield", + "Staff", + "Sword", + "Sword2H", + "Wand" + ] + }, + "wrath_every_kills": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "wrath_regeneration": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "zealot_critical_strike_chance": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "zealot_critical_strike_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "zealot_damage": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + }, + "zenith_cooldown_reduction": { + "slots": [ + "Amulet", + "Boots", + "ChestArmor", + "Gloves", + "Helm", + "Legs", + "Ring", + "Shield" + ] + } +} diff --git a/assets/lang/enUS/uniques.json b/assets/lang/enUS/uniques.json index c6e61f45..b4285fd7 100644 --- a/assets/lang/enUS/uniques.json +++ b/assets/lang/enUS/uniques.json @@ -1,887 +1,1477 @@ { "100000_steps": { + "class": "barbarian", + "item_type": "Boots", "num_inherents": 0 }, "accord_of_the_wilds": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "aegroms_schism": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "ahavarion_spear_of_lycander": { + "class": "all", + "item_type": "Staff", "num_inherents": 0 }, "airidahs_inexorable_will": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "anathema_of_the_primes": { + "class": "all", + "item_type": "Sword2H", "num_inherents": 0 }, "ancients_oath": { + "class": "barbarian", + "item_type": "Axe2H", "num_inherents": 0 }, "andariels_visage": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "arcadia": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "argent_veil": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "arreats_bearing": { + "class": "barbarian", + "item_type": "Legs", "num_inherents": 0 }, "ashearas_khanjar": { + "class": "rogue", + "item_type": "Dagger", "num_inherents": 0 }, "assassins_stride": { + "class": "rogue", + "item_type": "Boots", "num_inherents": 0 }, "autumnal_crown": { + "class": "druid", + "item_type": "Helm", "num_inherents": 0 }, "axial_conduit": { + "class": "sorcerer", + "item_type": "Legs", "num_inherents": 0 }, "azurewrath": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "balazans_maxtlatl": { + "class": "spiritborn", + "item_type": "Legs", "num_inherents": 0 }, "band_of_first_breath": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "bands_of_ichorous_rose": { + "class": "rogue", + "item_type": "Gloves", "num_inherents": 0 }, "bane_of_ahjad-den": { + "class": "barbarian", + "item_type": "Gloves", "num_inherents": 0 }, "banished_lords_talisman": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "bastion_of_sir_matthias": { + "class": "all", + "item_type": "Shield", "num_inherents": 2 }, "battle_trance": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "beastfall_boots": { + "class": "rogue", + "item_type": "Boots", "num_inherents": 0 }, "bindings_of_attrition": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "black_river": { + "class": "necromancer", + "item_type": "Scythe", "num_inherents": 0 }, "blood-mad_idol": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "blood_artisans_cuirass": { + "class": "necromancer", + "item_type": "ChestArmor", "num_inherents": 0 }, "blood_moon_breeches": { + "class": "necromancer", + "item_type": "Legs", "num_inherents": 0 }, "blood_wake": { + "class": "necromancer", + "item_type": "Boots", "num_inherents": 0 }, "bloodless_scream": { + "class": "necromancer", + "item_type": "Scythe2H", "num_inherents": 0 }, "blue_rose": { + "class": "sorcerer", + "item_type": "Ring", "num_inherents": 0 }, "bridle_of_torbaalos": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "cage_of_madness": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "cassias_grace": { + "class": "rogue", + "item_type": "Bow", "num_inherents": 0 }, "cathedrals_song": { + "class": "all", + "item_type": "Shield", "num_inherents": 2 }, "chainscourged_mail": { + "class": "barbarian", + "item_type": "Legs", "num_inherents": 0 }, "cluckeye": { + "class": "all", + "item_type": "Bow", "num_inherents": 0 }, "cluckonomicon": { + "class": "all", + "item_type": "Staff", "num_inherents": 0 }, "condemnation": { + "class": "rogue", + "item_type": "Dagger", "num_inherents": 0 }, "coop_de_grâce": { + "class": "all", + "item_type": "Polearm", "num_inherents": 0 }, "cowl_of_malefic_torment": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "cowl_of_the_nameless": { + "class": "rogue", + "item_type": "Helm", "num_inherents": 0 }, "craze_of_the_dead_god": { + "class": "spiritborn", + "item_type": "Gloves", "num_inherents": 0 }, "crown_of_lucion": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "cruors_embrace": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "dark_howl": { + "class": "druid", + "item_type": "Gloves", "num_inherents": 0 }, "dark_stalkers_medallion": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "dawnfire": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "deathgrip": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "deathless_visage": { + "class": "necromancer", + "item_type": "Helm", "num_inherents": 0 }, "deathmask_of_nirmitruq": { + "class": "rogue", + "item_type": "Helm", "num_inherents": 0 }, "deaths_pavane": { + "class": "rogue", + "item_type": "Legs", "num_inherents": 0 }, "deathspeakers_pendant": { + "class": "necromancer", + "item_type": "Amulet", "num_inherents": 0 }, "desperate_march": { + "class": "rogue", + "item_type": "Boots", "num_inherents": 0 }, "dirge_of_airidah": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "dirge_of_odium": { + "class": "all", + "item_type": "Axe2H", "num_inherents": 0 }, "dolmen_stone": { + "class": "druid", + "item_type": "Amulet", "num_inherents": 0 }, "doombringer": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "drognans_anguish": { + "class": "sorcerer", + "item_type": "Ring", "num_inherents": 0 }, "eaglehorn": { + "class": "rogue", + "item_type": "Bow", "num_inherents": 0 }, "earthbreaker": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "ebonpiercer": { + "class": "necromancer", + "item_type": "Amulet", "num_inherents": 0 }, "echo_of_kwatli": { + "class": "spiritborn", + "item_type": "Amulet", "num_inherents": 0 }, "eggcecutioner": { + "class": "all", + "item_type": "Scythe2H", "num_inherents": 0 }, "eggis": { + "class": "all", + "item_type": "Shield", "num_inherents": 2 }, "eldruin_sword_of_justice": { + "class": "all", + "item_type": "Sword", "num_inherents": 1 }, "elegy": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "emberfury": { + "class": "sorcerer", + "item_type": "Amulet", "num_inherents": 0 }, "emblem_of_staalbreak": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "endurant_faith": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "esadoras_overflowing_cameo": { + "class": "sorcerer", + "item_type": "Amulet", "num_inherents": 0 }, "esus_heirloom": { + "class": "sorcerer", + "item_type": "Boots", "num_inherents": 0 }, "etnas_lost_dagger": { + "class": "rogue", + "item_type": "Dagger", "num_inherents": 0 }, "eye_of_baal": { + "class": "all", + "item_type": "Focus", "num_inherents": 0 }, "eyes_in_the_dark": { + "class": "rogue", + "item_type": "Legs", "num_inherents": 0 }, "fang_of_the_vipermagi": { + "class": "sorcerer", + "item_type": "Dagger", "num_inherents": 0 }, "fields_of_crimson": { + "class": "barbarian", + "item_type": "Sword2H", "num_inherents": 0 }, "fist_of_the_iron_rose": { + "class": "rogue", + "item_type": "Gloves", "num_inherents": 0 }, "fists_of_fate": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "flamescar": { + "class": "sorcerer", + "item_type": "Wand", "num_inherents": 0 }, "flameweaver": { + "class": "sorcerer", + "item_type": "Gloves", "num_inherents": 0 }, "fleshrender": { + "class": "druid", + "item_type": "Mace", "num_inherents": 0 }, "fleshwrit_carapace": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "flickerstep": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "footfalls_of_the_waning_world": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "fractured_runestone": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "fractured_winterglass": { + "class": "sorcerer", + "item_type": "Amulet", "num_inherents": 0 }, "frostburn": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "fury_of_the_wilds": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "galvanic_azurite": { + "class": "sorcerer", + "item_type": "Ring", "num_inherents": 0 }, "gate_of_the_red_dawn": { + "class": "all", + "item_type": "Shield", "num_inherents": 2 }, "gathlens_birthright": { + "class": "druid", + "item_type": "Helm", "num_inherents": 0 }, "gauntlets_of_sheol": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "gift_of_frost": { + "class": "sorcerer", + "item_type": "Boots", "num_inherents": 0 }, "gladiators_triumph": { + "class": "rogue", + "item_type": "Gloves", "num_inherents": 0 }, "gloves_of_the_illuminator": { + "class": "sorcerer", + "item_type": "Gloves", "num_inherents": 0 }, "godslayer_crown": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "gohrs_devastating_grips": { + "class": "barbarian", + "item_type": "Gloves", "num_inherents": 0 }, "gospel_of_the_devotee": { + "class": "necromancer", + "item_type": "FocusBookOffHand", "num_inherents": 0 }, "grasp_of_shadow": { + "class": "rogue", + "item_type": "Gloves", "num_inherents": 0 }, "gravewalkers_hand": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "greatstaff_of_the_crone": { + "class": "druid", + "item_type": "Staff", "num_inherents": 0 }, "greaves_of_the_empty_tomb": { + "class": "necromancer", + "item_type": "Boots", "num_inherents": 0 }, "greenwalkers_oath": { + "class": "druid", + "item_type": "Boots", "num_inherents": 0 }, "greenwalkers_signet": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "griswolds_opus": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "hail_of_verglas": { + "class": "sorcerer", + "item_type": "Helm", "num_inherents": 0 }, "hand_of_apotheosis": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "hands_of_the_worldbreaker": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "hangmans_hand": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "harlequin_crest": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "harmony_of_ebewaka": { + "class": "spiritborn", + "item_type": "Helm", "num_inherents": 0 }, "heart_of_azgar": { + "class": "druid", + "item_type": "ChestArmor", "num_inherents": 0 }, "hecaton_chasm": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "heir_of_perdition": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "hellbrand_signet": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "hellhammer": { + "class": "barbarian", + "item_type": "Mace2H", "num_inherents": 0 }, "hellhounds_sabatons": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "herald_of_zakarum": { + "class": "all", + "item_type": "Shield", "num_inherents": 3 }, "heralds_morningstar": { + "class": "all", + "item_type": "Mace", "num_inherents": 0 }, "hesha_e_kesungi": { + "class": "spiritborn", + "item_type": "Gloves", "num_inherents": 0 }, "hooves_of_the_mountain_god": { + "class": "barbarian", + "item_type": "Boots", "num_inherents": 0 }, "howl_from_below": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "hunters_zenith": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "iceheart_brais": { + "class": "sorcerer", + "item_type": "Legs", "num_inherents": 0 }, "ifehs_dire_totem": { + "class": "druid", + "item_type": "OffHandTotem", "num_inherents": 0 }, "indiras_memory": { + "class": "necromancer", + "item_type": "Legs", "num_inherents": 0 }, "infernal_homunculus": { + "class": "all", + "item_type": "Focus", "num_inherents": 0 }, "insatiable_fury": { + "class": "druid", + "item_type": "ChestArmor", "num_inherents": 0 }, "jacinth_shell": { + "class": "spiritborn", + "item_type": "ChestArmor", "num_inherents": 0 }, "judgment_of_auriel": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "judicants_glaivehelm": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "kabraxis_will": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "kessimes_legacy": { + "class": "necromancer", + "item_type": "Legs", "num_inherents": 0 }, "khamsin_steppewalkers": { + "class": "druid", + "item_type": "Boots", "num_inherents": 0 }, "kilt_of_blackwing": { + "class": "druid", + "item_type": "Legs", "num_inherents": 0 }, "levin_grasp": { + "class": "sorcerer", + "item_type": "Gloves", "num_inherents": 0 }, "lidless_wall": { + "class": "necromancer", + "item_type": "Shield", "num_inherents": 2 }, "lights_rebuke": { + "class": "all", + "item_type": "Flail", "num_inherents": 0 }, "litany_of_sable": { + "class": "all", + "item_type": "Dagger", "num_inherents": 0 }, "locrans_talisman": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "loyaltys_mantle": { + "class": "spiritborn", + "item_type": "Helm", "num_inherents": 0 }, "lurid_pact": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "mace_of_king_leoric": { + "class": "necromancer", + "item_type": "Mace", "num_inherents": 0 }, "mad_wolfs_glee": { + "class": "druid", + "item_type": "ChestArmor", "num_inherents": 0 }, "malefic_crescent": { + "class": "druid", + "item_type": "Amulet", "num_inherents": 0 }, "mantle_of_mountains_fury": { + "class": "barbarian", + "item_type": "ChestArmor", "num_inherents": 0 }, "mantle_of_the_grey": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "march_of_the_stalwart_soul": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "mark_of_the_old_wolf": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "melted_heart_of_selig": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "might_of_qual-kehk": { + "class": "barbarian", + "item_type": "Gloves", "num_inherents": 0 }, "might_of_the_ursine": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "misericorde": { + "class": "rogue", + "item_type": "Sword", "num_inherents": 0 }, "mjölnic_ryng": { + "class": "druid", + "item_type": "Ring", "num_inherents": 0 }, "molochs_beating_flame": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "molten_band": { + "class": "sorcerer", + "item_type": "Ring", "num_inherents": 0 }, "morlu_fleshward": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "mothers_embrace": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "mutilator_plate": { + "class": "necromancer", + "item_type": "ChestArmor", "num_inherents": 0 }, "nails_of_the_gore-crowned": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "nesekem_the_herald": { + "class": "spiritborn", + "item_type": "Glaive", "num_inherents": 0 }, "night_terror": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "nomads_longing_heart": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "okuns_catalyst": { + "class": "sorcerer", + "item_type": "FocusBookOffHand", "num_inherents": 1 }, "omen_of_pain": { + "class": "necromancer", + "item_type": "Ring", "num_inherents": 0 }, "onyx_soul": { + "class": "sorcerer", + "item_type": "FocusBookOffHand", "num_inherents": 0 }, "ophidian_iris": { + "class": "sorcerer", + "item_type": "Amulet", "num_inherents": 0 }, "orphan_maker": { + "class": "rogue", + "item_type": "Crossbow2H", "num_inherents": 0 }, "orsivane": { + "class": "sorcerer", + "item_type": "Mace", "num_inherents": 0 }, "overkill": { + "class": "barbarian", + "item_type": "Mace2H", "num_inherents": 0 }, "pact_of_bone": { + "class": "necromancer", + "item_type": "Ring", "num_inherents": 0 }, "paingorgers_gauntlets": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "path_of_the_emissary": { + "class": "spiritborn", + "item_type": "Boots", "num_inherents": 0 }, "path_of_tragoul": { + "class": "necromancer", + "item_type": "Boots", "num_inherents": 0 }, "peacemongers_signet": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "penitent_greaves": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "pitfighters_gull": { + "class": "rogue", + "item_type": "Ring", "num_inherents": 0 }, "protean_heart": { + "class": "spiritborn", + "item_type": "Amulet", "num_inherents": 0 }, "protection_of_the_prime": { + "class": "spiritborn", + "item_type": "Legs", "num_inherents": 0 }, "purified_lightbringer": { + "class": "druid", + "item_type": "Mace2H", "num_inherents": 0 }, "rage_of_harrogath": { + "class": "barbarian", + "item_type": "ChestArmor", "num_inherents": 0 }, "raiment_of_the_infinite": { + "class": "sorcerer", + "item_type": "ChestArmor", "num_inherents": 0 }, "raiment_of_the_sea": { + "class": "sorcerer", + "item_type": "ChestArmor", "num_inherents": 0 }, "rakanoths_wake": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 }, "ramaladnis_magnum_opus": { + "class": "barbarian", + "item_type": "Sword", "num_inherents": 0 }, "razorplate": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "red_blessing": { + "class": "necromancer", + "item_type": "Amulet", "num_inherents": 0 }, "red_sermon": { + "class": "all", + "item_type": "Sword2H", "num_inherents": 0 }, "rictus_of_terror": { + "class": "all", + "item_type": "Helm", "num_inherents": 0 }, "rimeblood": { + "class": "sorcerer", + "item_type": "Gloves", "num_inherents": 0 }, "ring_of_mendeln": { + "class": "necromancer", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_red_furor": { + "class": "barbarian", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_starless_skies": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_the_midday_hunt": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_the_midnight_sun": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_the_ravenous": { + "class": "barbarian", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_the_sacrilegious_soul": { + "class": "necromancer", + "item_type": "Ring", "num_inherents": 0 }, "ring_of_writhing_moon": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "rod_of_kepeleke": { + "class": "spiritborn", + "item_type": "Quarterstaff", "num_inherents": 0 }, "rotting_lightbringer": { + "class": "druid", + "item_type": "Mace2H", "num_inherents": 0 }, "rustbitten_dirk": { + "class": "all", + "item_type": "Dagger", "num_inherents": 0 }, "saboteurs_signet": { + "class": "rogue", + "item_type": "Ring", "num_inherents": 0 }, "sabre_of_tsasgal": { + "class": "barbarian", + "item_type": "Sword", "num_inherents": 0 }, "sanctis_of_kethamar": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "sanguivor_blade_of_zir": { + "class": "necromancer", + "item_type": "Sword2H", "num_inherents": 0 }, "sashes_of_the_wretched": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "scepter_of_the_three": { + "class": "all", + "item_type": "Mace2H", "num_inherents": 0 }, "scorn_of_the_earth": { + "class": "spiritborn", + "item_type": "Boots", "num_inherents": 0 }, "scoundrels_kiss": { + "class": "rogue", + "item_type": "Ring", "num_inherents": 0 }, "scoundrels_leathers": { + "class": "rogue", + "item_type": "ChestArmor", "num_inherents": 0 }, "scourge_of_duriel": { + "class": "all", + "item_type": "Flail", "num_inherents": 0 }, "sea_lords_fine_gloves": { + "class": "rogue", + "item_type": "Gloves", "num_inherents": 0 }, "seal_of_the_ophanim": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "seal_of_the_second_trumpet": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "seed_of_horazon": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "sepazontec": { + "class": "spiritborn", + "item_type": "Quarterstaff", "num_inherents": 0 }, "shanars_resonance": { + "class": "sorcerer", + "item_type": "FocusBookOffHand", "num_inherents": 0 }, "shard_of_verathiel": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "shattered_vow": { + "class": "all", + "item_type": "Polearm", "num_inherents": 0 }, "shroud_of_false_death": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "shroud_of_khanduras": { + "class": "rogue", + "item_type": "ChestArmor", "num_inherents": 0 }, "shrouded_gift": { + "class": "rogue", + "item_type": "Legs", "num_inherents": 0 }, "sidhe_bindings": { + "class": "sorcerer", + "item_type": "Gloves", "num_inherents": 0 }, "signet_of_pelghain": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "sire_of_sin": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "skyhunter": { + "class": "rogue", + "item_type": "Bow", "num_inherents": 0 }, "sliver_of_hate": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "soulbrand": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 0 }, "spine_of_tathamet": { + "class": "all", + "item_type": "Mace", "num_inherents": 0 }, "staff_of_endless_rage": { + "class": "sorcerer", + "item_type": "Staff", "num_inherents": 0 }, "staff_of_lam_esen": { + "class": "sorcerer", + "item_type": "Staff", "num_inherents": 0 }, "staff_of_zerae": { + "class": "sorcerer", + "item_type": "Staff", "num_inherents": 0 }, "starfall_coronet": { + "class": "sorcerer", + "item_type": "Helm", "num_inherents": 0 }, "stone_of_vehemen": { + "class": "druid", + "item_type": "OffHandTotem", "num_inherents": 0 }, "storms_companion": { + "class": "druid", + "item_type": "Legs", "num_inherents": 0 }, "strike_of_stormhorn": { + "class": "sorcerer", + "item_type": "FocusBookOffHand", "num_inherents": 0 }, "sunbirds_gorget": { + "class": "spiritborn", + "item_type": "Amulet", "num_inherents": 0 }, "sunbrand": { + "class": "all", + "item_type": "Flail", "num_inherents": 0 }, "sundered_night": { + "class": "all", + "item_type": "Axe2H", "num_inherents": 0 }, "sunstained_war-crozier": { + "class": "spiritborn", + "item_type": "Quarterstaff", "num_inherents": 0 }, "supplication": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "tal_rashas_iridescent_loop": { + "class": "sorcerer", + "item_type": "Ring", "num_inherents": 0 }, "tassets_of_the_dawning_sky": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "temerity": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "tempest_roar": { + "class": "druid", + "item_type": "Helm", "num_inherents": 0 }, "the_basilisk": { + "class": "druid", + "item_type": "Staff", "num_inherents": 0 }, "the_blade_of_sight_aflame": { + "class": "all", + "item_type": "Sword", "num_inherents": 0 }, "the_butchers_cleaver": { + "class": "all", + "item_type": "Axe", "num_inherents": 0 }, "the_eightfold_idol": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "the_fecund_seal": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "the_gloom_ward": { + "class": "necromancer", + "item_type": "Shield", "num_inherents": 2 }, "the_grandfather": { + "class": "all", + "item_type": "Sword2H", "num_inherents": 1 }, "the_hand_of_naz": { + "class": "necromancer", + "item_type": "Gloves", "num_inherents": 0 }, "the_hemat_stone": { + "class": "all", + "item_type": "Amulet", "num_inherents": 0 }, "the_maestro": { + "class": "rogue", + "item_type": "Dagger", "num_inherents": 0 }, "the_mortacrux": { + "class": "necromancer", + "item_type": "Dagger", "num_inherents": 0 }, "the_oculus": { + "class": "sorcerer", + "item_type": "Wand", "num_inherents": 0 }, "the_open_eye_of_gorgorra": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "the_relentless_heart": { + "class": "barbarian", + "item_type": "ChestArmor", "num_inherents": 0 }, "the_third_blade": { + "class": "barbarian", + "item_type": "Sword", "num_inherents": 0 }, "the_umbracrux": { + "class": "rogue", + "item_type": "Dagger", "num_inherents": 0 }, "the_undercrown": { + "class": "necromancer", + "item_type": "Helm", "num_inherents": 0 }, "the_unmaker": { + "class": "necromancer", + "item_type": "Helm", "num_inherents": 0 }, "thousand-eye_reaver": { + "class": "all", + "item_type": "Axe", "num_inherents": 0 }, "thrice-woven_nightmare": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "thundergods_blessing": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "tibaults_will": { + "class": "all", + "item_type": "Legs", "num_inherents": 0 }, "tuskhelm_of_joritz_the_mighty": { + "class": "barbarian", + "item_type": "Helm", "num_inherents": 0 }, "twin_strikes": { + "class": "barbarian", + "item_type": "Gloves", "num_inherents": 0 }, "tyraels_might": { + "class": "all", + "item_type": "ChestArmor", "num_inherents": 1 }, "ugly_bastard_helm": { + "class": "barbarian", + "item_type": "Helm", "num_inherents": 0 }, "unbroken_chain": { + "class": "barbarian", + "item_type": "Amulet", "num_inherents": 0 }, "unsung_ascetics_wraps": { + "class": "druid", + "item_type": "Gloves", "num_inherents": 0 }, "vasilys_prayer": { + "class": "druid", + "item_type": "Helm", "num_inherents": 0 }, "vengeful_sinew": { + "class": "necromancer", + "item_type": "ChestArmor", "num_inherents": 0 }, "vision_of_the_firestorm": { + "class": "sorcerer", + "item_type": "Helm", "num_inherents": 0 }, "vox_omnium": { + "class": "sorcerer", + "item_type": "Staff", "num_inherents": 0 }, "ward_of_the_white_dove": { + "class": "all", + "item_type": "Shield", "num_inherents": 2 }, "waxing_gibbous": { + "class": "druid", + "item_type": "Axe", "num_inherents": 0 }, "wendigo_brand": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "widows_web": { + "class": "spiritborn", + "item_type": "Amulet", "num_inherents": 0 }, "wildheart_hunger": { + "class": "druid", + "item_type": "Boots", "num_inherents": 0 }, "will_of_rathma": { + "class": "necromancer", + "item_type": "Amulet", "num_inherents": 0 }, "will_of_stone": { + "class": "druid", + "item_type": "Helm", "num_inherents": 0 }, "windforce": { + "class": "rogue", + "item_type": "Bow", "num_inherents": 0 }, "word_of_hakan": { + "class": "rogue", + "item_type": "Amulet", "num_inherents": 0 }, "wound_drinker": { + "class": "spiritborn", + "item_type": "Ring", "num_inherents": 0 }, "wreath_of_auric_laurel": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "writhing_band_of_trickery": { + "class": "rogue", + "item_type": "Ring", "num_inherents": 0 }, "wushe_nak_pa": { + "class": "spiritborn", + "item_type": "Glaive", "num_inherents": 0 }, "wyrdskin": { + "class": "all", + "item_type": "Gloves", "num_inherents": 0 }, "xfals_corroded_signet": { + "class": "all", + "item_type": "Ring", "num_inherents": 0 }, "yens_blessing": { + "class": "all", + "item_type": "Boots", "num_inherents": 0 } } diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index ecc5b745..db925289 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -1,6 +1,8 @@ import contextlib import copy +import json import logging +from pathlib import Path from typing import override from PyQt6.QtCore import QSettings, QSignalBlocker, Qt, pyqtSignal @@ -119,6 +121,22 @@ def _item_type_summary(item_types: list[ItemType]) -> str: return ", ".join(item_type.value for item_type in item_types) +def _get_affix_metadata() -> dict: + """Helper to load affix metadata for slot filtering.""" + try: + meta_path = Path("assets/lang/enUS/affix_metadata.json") + if not meta_path.exists(): + LOGGER.warning(f"Affix metadata file not found: {meta_path}") + return {} + with meta_path.open("r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + LOGGER.error(f"Error decoding affix metadata JSON from {meta_path}: {e}") + except OSError as e: + LOGGER.error(f"Error reading affix metadata file {meta_path}: {e}") + return {} + + class ItemTypePicker(QDialog): def __init__(self, parent: QWidget, item_types: list[ItemType], selected_item_types: list[ItemType]): super().__init__(parent) @@ -368,7 +386,13 @@ def _create_column_footer(model: AffixFilterCountModel, on_change_cb: callable) class UniqueAspectDialog(QDialog): - def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): + def __init__( + self, + parent: QWidget, + model: AspectUniqueFilterModel, + character_class: str = "all", + allowed_item_types: list[ItemType] | None = None, + ): super().__init__(parent) self.setWindowTitle("Configure Unique Aspect") self.setMinimumWidth(550) @@ -405,12 +429,39 @@ def __init__(self, parent: QWidget, model: AspectUniqueFilterModel): form = QFormLayout() + unique_dict = Dataloader().aspect_unique_dict + filtered_uniques = [] + + # Normalize class for internal lookup + search_class = character_class.lower() + if "warlock" in search_class: + search_class = "sorcerer" + + for name, data in unique_dict.items(): + # Class Filter: Keep if item is for 'all' or matches the current class + u_class = str(data.get("class", "all")).lower() + if search_class != "all" and u_class not in ("all", search_class): + continue + + # Slot Filter: Keep if item type matches any of the allowed types for this filter + u_type = str(data.get("item_type")) + if allowed_item_types and u_type and not any(u_type in (t.name, t.value) for t in allowed_item_types): + continue + + filtered_uniques.append(name) + + if not filtered_uniques: + filtered_uniques = list(unique_dict.keys()) + self.name_combo = TruncatingComboBox() self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.name_combo.addItems(sorted(Dataloader().aspect_unique_dict.keys())) - self.name_combo.setCurrentText(model.name) + self.name_combo.addItems(sorted(filtered_uniques)) + if model.name in filtered_uniques: + self.name_combo.setCurrentText(model.name) + elif self.name_combo.count() > 0: + self.name_combo.setCurrentIndex(0) form.addRow("Aspect:", self.name_combo) self.mode_combo = IgnoreScrollWheelComboBox() @@ -456,7 +507,7 @@ def save_and_accept(self): class AffixEditDialog(QDialog): - def __init__(self, parent: QWidget, model: AffixFilterModel): + def __init__(self, parent: QWidget, model: AffixFilterModel, allowed_item_types: list[ItemType] | None = None): super().__init__(parent) self.setWindowTitle("Configure Affix") self.setMinimumWidth(550) @@ -493,14 +544,48 @@ def __init__(self, parent: QWidget, model: AffixFilterModel): form = QFormLayout() + affix_dict = Dataloader().affix_dict + affix_metadata = _get_affix_metadata() + + filtered_affixes = [] + if allowed_item_types is None: + # Global rules: show all affixes regardless of metadata + filtered_affixes = sorted(affix_dict.values()) + else: + # Legendary items: smartly use metadata whitelist + if not allowed_item_types: + # If no slots specified, show all affixes present in metadata (the active whitelist) + for affix_id, display_name in affix_dict.items(): + if affix_id in affix_metadata: + filtered_affixes.append(display_name) + else: + # Filter by specific slots defined in the rule + allowed_slot_names = [t.name for t in allowed_item_types] + for affix_id, display_name in affix_dict.items(): + meta = affix_metadata.get(affix_id) + if meta: + slots = meta.get("slots", []) + if any(s in allowed_slot_names for s in slots): + filtered_affixes.append(display_name) + + # Fallback if metadata is missing or filter returned nothing + if not filtered_affixes: + filtered_affixes = sorted(affix_dict.values()) + + filtered_affixes.sort() + + if not filtered_affixes: + filtered_affixes = sorted(affix_dict.values()) + self.name_combo = TruncatingComboBox() self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.name_combo.addItems(sorted(Dataloader().affix_dict.values())) - if model.name in Dataloader().affix_dict: - self.name_combo.setCurrentText(Dataloader().affix_dict[model.name]) + self.name_combo.addItems(filtered_affixes) + if model.name in affix_dict: + current_display = affix_dict[model.name] + self.name_combo.setCurrentText(current_display) form.addRow("Affix:", self.name_combo) options_layout = QHBoxLayout() @@ -561,7 +646,9 @@ def save_and_accept(self): class AffixPoolDialog(QDialog): - def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): + def __init__( + self, parent: QWidget, pool: AffixFilterCountModel, title: str, allowed_item_types: list[ItemType] | None = None + ): super().__init__(parent) self.setWindowTitle(title) self.setMinimumSize(700, 600) @@ -624,13 +711,13 @@ def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): self.rows_layout.setAlignment(Qt.AlignmentFlag.AlignTop) for affix in pool.count: - self.add_affix_row(affix) + self.add_affix_row(affix, allowed_item_types) scroll.setWidget(self.rows_container) layout.addWidget(scroll) add_btn = QPushButton("+ Add Affix to Pool") - add_btn.clicked.connect(self.add_affix) + add_btn.clicked.connect(lambda: self.add_affix(allowed_item_types)) layout.addWidget(add_btn) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) @@ -638,14 +725,39 @@ def __init__(self, parent: QWidget, pool: AffixFilterCountModel, title: str): buttons.rejected.connect(self.reject) layout.addWidget(buttons) - def add_affix_row(self, model: AffixFilterModel): - widget = AffixWidget(model) + def add_affix_row(self, model: AffixFilterModel, allowed_item_types: list[ItemType] | None = None): + widget = AffixWidget(model, allowed_item_types=allowed_item_types) widget.delete_requested.connect(lambda: self.remove_affix_widget(widget)) self.rows_layout.addWidget(widget) - def add_affix(self): - items = sorted(Dataloader().affix_dict.values()) - dialog = SelectionDialog(self, "Select Affix", items) + def add_affix(self, allowed_item_types: list[ItemType] | None = None): + affix_dict = Dataloader().affix_dict + affix_metadata = _get_affix_metadata() + + filtered_affixes = [] + if allowed_item_types is None: + filtered_affixes = sorted(affix_dict.values()) + else: + if not allowed_item_types: + for affix_id, display_name in affix_dict.items(): + if affix_id in affix_metadata: + filtered_affixes.append(display_name) + else: + allowed_slot_names = [t.name for t in allowed_item_types] + for affix_id, display_name in affix_dict.items(): + meta = affix_metadata.get(affix_id) + if meta and any(s in allowed_slot_names for s in meta.get("slots", [])): + filtered_affixes.append(display_name) + + if not filtered_affixes: + filtered_affixes = sorted(affix_dict.values()) + + filtered_affixes.sort() + + if not filtered_affixes: + filtered_affixes = sorted(affix_dict.values()) + + dialog = SelectionDialog(self, "Select Affix", filtered_affixes) if dialog.exec() == QDialog.DialogCode.Accepted: val = dialog.get_value() if val: @@ -653,7 +765,7 @@ def add_affix(self): affix_id = reverse_dict.get(val) new_model = AffixFilterModel(name=affix_id, value=None) self.pool.count.append(new_model) - self.add_affix_row(new_model) + self.add_affix_row(new_model, allowed_item_types) def remove_affix_widget(self, widget: AffixWidget): if widget.affix in self.pool.count: @@ -1148,7 +1260,22 @@ def mousePressEvent(self, event): self.open_config_dialog() def open_config_dialog(self) -> QDialog.DialogCode: - dialog = UniqueAspectDialog(self, self.unique_aspect) + # Gather context by crawling up the widget tree + char_class = "all" + allowed_types = [] + curr = self.parent() + while curr: + if hasattr(curr, "profile_model"): # ProfileEditor + char_class = curr.profile_model.class_name.lower() + # Check for Item Types in AffixGroupEditor (Affixes Tab) + if hasattr(curr, "config") and hasattr(curr.config, "item_type"): + allowed_types = curr.config.item_type + # Check for Item Types in UniqueWidget (Global Uniques Tab) + if hasattr(curr, "unique_model") and hasattr(curr.unique_model, "item_type"): + allowed_types = curr.unique_model.item_type + curr = curr.parent() + + dialog = UniqueAspectDialog(self, self.unique_aspect, char_class, allowed_types) result = dialog.exec() if result == QDialog.DialogCode.Accepted: self.refresh_display() @@ -1271,7 +1398,23 @@ def mousePressEvent(self, event): self.open_config_dialog() def open_config_dialog(self) -> QDialog.DialogCode: - dialog = AffixEditDialog(self, self.model) + # Gather context by crawling up the widget tree + allowed_types = [] + is_global = False + curr = self.parent() + while curr: + # Check for Item Types in AffixGroupEditor (Affixes Tab) + if hasattr(curr, "config") and hasattr(curr.config, "item_type"): + allowed_types = curr.config.item_type + is_global = False + break + # If we hit UniqueWidget, we are in a Global Rule + if hasattr(curr, "unique_model"): + is_global = True + break + curr = curr.parent() + + dialog = AffixEditDialog(self, self.model, None if is_global else allowed_types) result = dialog.exec() if result == QDialog.DialogCode.Accepted: self.refresh_display() @@ -1346,7 +1489,21 @@ def mousePressEvent(self, event): self.open_config_dialog() def open_config_dialog(self): - dialog = AffixPoolDialog(self, self.pool, self.pool_name_label.text()) + # Find allowed types + allowed_types = [] + is_global = False + curr = self.parent() + while curr: + if hasattr(curr, "config") and hasattr(curr.config, "item_type"): + allowed_types = curr.config.item_type + is_global = False + break + if hasattr(curr, "unique_model"): + is_global = True + break + curr = curr.parent() + + dialog = AffixPoolDialog(self, self.pool, self.pool_name_label.text(), None if is_global else allowed_types) if dialog.exec() == QDialog.DialogCode.Accepted: self.refresh_display() self.config_changed.emit() @@ -1363,9 +1520,10 @@ def refresh_display(self): class AffixWidget(QWidget): delete_requested = pyqtSignal() - def __init__(self, affix: AffixFilterModel, parent=None): + def __init__(self, affix: AffixFilterModel, parent=None, allowed_item_types: list[ItemType] | None = None): super().__init__(parent) self.affix = affix + self.allowed_item_types = allowed_item_types self.setStyleSheet("background: transparent; border: none;") self.setup_ui() @@ -1399,15 +1557,36 @@ def setup_ui(self): def create_affix_name_combobox(self): # The previous line `self.name_combo = IgnoreScrollWheelComboBox()` was redundant and overwritten. # The TruncatingComboBox needs to be initialized correctly. + affix_dict = Dataloader().affix_dict + affix_metadata = _get_affix_metadata() + + filtered_affixes = [] + if not self.allowed_item_types: + filtered_affixes = sorted(affix_dict.values()) + else: + allowed_slot_names = [t.name for t in self.allowed_item_types] + for affix_id, display_name in affix_dict.items(): + meta = affix_metadata.get(affix_id) + if meta: + slots = meta.get("slots", []) + if any(s in allowed_slot_names for s in slots): + filtered_affixes.append(display_name) + elif not affix_metadata: + filtered_affixes.append(display_name) + filtered_affixes.sort() + + if not filtered_affixes: + filtered_affixes = sorted(affix_dict.values()) + self.name_combo = TruncatingComboBox(parent=self) self.name_combo.setEditable(True) self.name_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self.name_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.name_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains) - self.name_combo.addItems(sorted(Dataloader().affix_dict.values())) + self.name_combo.addItems(filtered_affixes) self.name_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - if self.affix.name in Dataloader().affix_dict: - self.name_combo.setCurrentText(Dataloader().affix_dict[self.affix.name]) + if self.affix.name in affix_dict: + self.name_combo.setCurrentText(affix_dict[self.affix.name]) self.name_combo.currentTextChanged.connect(self.update_name) def create_required_checkbox(self): diff --git a/src/tools/gen_data.py b/src/tools/gen_data.py index 21a078d3..1ac6dcd8 100644 --- a/src/tools/gen_data.py +++ b/src/tools/gen_data.py @@ -32,6 +32,15 @@ D4LF_BASE_DIR = Path(__file__).parent.parent.parent +SNO_CLASS_MAP = { + 410764: "barbarian", + 410765: "druid", + 521360: "necromancer", + 410766: "rogue", + 410767: "sorcerer", + 550604: "spiritborn", +} + class AffixGenerationContext(TypedDict): attribute_descriptions: dict[str, str] @@ -45,6 +54,119 @@ class AffixGenerationContext(TypedDict): weapon_types_by_sno: dict[int, str] +def _get_slot_lookup() -> dict[str, str]: + lookup = {g.lower(): g for g in GEAR_TYPES} + lookup.update({ + "chest": "ChestArmor", + "body": "ChestArmor", + "pants": "Legs", + "axe_2h": "Axe2H", + "mace_2h": "Mace2H", + "scythe_2h": "Scythe2H", + "sword_2h": "Sword2H", + "crossbow_2h": "Crossbow2H", + "focusbookoffhand": "Focus", + "offhandtotem": "OffHandTotem", + "offhandshield": "Shield", + "offhandtome": "Focus", + }) + return lookup + + +def build_affix_slot_map(d4data_dir: Path) -> dict[int, list[str]]: + lookup = _get_slot_lookup() + pool_to_slots = {} + ity_files = ( + list(d4data_dir.glob("**/*.itt.json")) + or list(d4data_dir.glob("**/*.ity.json")) + or list(d4data_dir.glob("**/ItemType*/*.json")) + or list(d4data_dir.glob("**/ItemType/*.json")) + ) + matched_slots = set() + + def find_pools(d, potential_pools, depth=0): + if isinstance(d, dict): + for k, v in d.items(): + if k.startswith(("sno", "unk_")) or "Pool" in k or "Group" in k or "Affix" in k or k == "__raw__": + sno = v.get("__raw__") if isinstance(v, dict) else v + if isinstance(sno, int) and sno != -1: + potential_pools.append(sno % (2**32)) + elif isinstance(v, int) and v != -1: + v_norm = v % (2**32) + if v_norm > 10000: + potential_pools.append(v_norm) + find_pools(v, potential_pools, depth + 1) + elif isinstance(d, list): + for item in d: + find_pools(item, potential_pools, depth + 1) + + def scan_for_affixes(obj, found_affix_snos, pool_sno): + if isinstance(obj, dict): + for k, v in obj.items(): + if k.startswith(("sno", "unk_")) or "Affix" in k or k == "__raw__": + val = v.get("__raw__") if isinstance(v, dict) else v + if isinstance(val, int): + val_norm = val % (2**32) + if val_norm > 10000 and val_norm != pool_sno: + found_affix_snos.append(val_norm) + scan_for_affixes(v, found_affix_snos, pool_sno) + elif isinstance(obj, list): + for item in obj: + scan_for_affixes(item, found_affix_snos, pool_sno) + + for ity_file in ity_files: + ity_data = load_json_file(ity_file) + ity_type = str(ity_data.get("__type__", "")).lower() + is_metadata = ( + "itemtype" in ity_type or "definition" in ity_type or ".itt" in ity_file.name or ".ity" in ity_file.name + ) + if not is_metadata and ity_type: + continue + raw_stem = ity_file.name.split(".")[0].lower().replace("_", "").replace("-", "") + slot_name = lookup.get(raw_stem) + if not slot_name: + for key, internal_name in lookup.items(): + if key in raw_stem: + slot_name = internal_name + break + if not slot_name: + continue + matched_slots.add(slot_name) + + potential_pools = [] + find_pools(ity_data, potential_pools) + for pool_sno in potential_pools: + if pool_sno not in pool_to_slots: + pool_to_slots[pool_sno] = set() + pool_to_slots[pool_sno].add(slot_name) + + apf_files = ( + list(d4data_dir.glob("**/*.apf.json")) + or list(d4data_dir.glob("**/AffixPool*/**/*.json")) + or list(d4data_dir.glob("**/Affix*/**/*.json")) + or list(d4data_dir.glob("**/AffixPool/*.json")) + ) + affix_to_slots = {} + for apf_file in apf_files: + apf_data = load_json_file(apf_file) + pool_sno_raw = ( + apf_data.get("__snoID__") or apf_data.get("snoID") or apf_data.get("snoId") or apf_data.get("snoID_") + ) + if pool_sno_raw is None: + continue + pool_sno = int(pool_sno_raw) % (2**32) + if pool_sno not in pool_to_slots: + continue + slots = pool_to_slots[pool_sno] + found_affix_snos = [] + scan_for_affixes(apf_data, found_affix_snos, pool_sno) + for affix_sno in set(found_affix_snos): + if affix_sno not in affix_to_slots: + affix_to_slots[affix_sno] = set() + affix_to_slots[affix_sno].update(slots) + return {k: sorted(v) for k, v in affix_to_slots.items()} + + def remove_content_in_braces(input_string) -> str: pattern = r"\{.*?\}" result = re.sub(pattern, "", input_string) @@ -302,7 +424,21 @@ def companion_style_affix_description( description = "" for attribute in attributes: - localisation = context["attribute_descriptions"].get(attribute["id"], "") + attr_id = attribute["id"] + if any( + attr_id.startswith(p) + for p in [ + "Affix_Value_", + "Affix_Flat_Value_", + "Item_Granted_Skill_Tree_Reward", + "Multiplicative_Damage_Percent_Bonus_Per_Skill_Tag", + "DOT_DPS_Reduction_Percent_Per_Damage_Type", + ] + ): + localisation = "#" + else: + localisation = context["attribute_descriptions"].get(attribute["id"]) or "" + if not localisation: if (affix_name, attribute["id"]) not in EXPECTED_MISSING_AFFIX_LOCALISATIONS: print(f"WARNING: ({affix_name}) Localisation id {attribute['id']} not found.") @@ -360,12 +496,26 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = if not context["skill_tags_by_sno"]: context["skill_tags_by_sno"] = {int(key) % (2**32): value for key, value in gbid.get("56", {}).items()} + lookup = _get_slot_lookup() + affix_slot_map = build_affix_slot_map(d4data_dir) affix_dict = {} + affix_metadata = {} + affix_pattern = "json/base/meta/Affix/*.json" affix_files = sorted(d4data_dir.glob(affix_pattern, case_sensitive=False)) for affix_file in affix_files: affix_data = load_json_file(affix_file) affix_name = Path(affix_data["__fileName__"]).stem + sno_raw = ( + affix_data.get("__snoID__") + or affix_data.get("snoID") + or affix_data.get("snoId") + or affix_data.get("snoID_") + ) + if sno_raw is None: + continue + affix_sno = int(sno_raw) % (2**32) + if affix_data.get("eMagicType") != 0: continue if affix_name.startswith("zz"): @@ -382,7 +532,46 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = if normalised is None: continue key, value = normalised - affix_dict[key] = value + affix_dict[key] = value # All affixes go here + + slots = affix_slot_map.get(affix_sno, []) + if not slots: + fn_lower = affix_name.lower().replace("_", "").replace("-", "") + for raw_stem, internal_name in lookup.items(): + if raw_stem in fn_lower: + slots.append(internal_name) + if not slots: + # Generic stats (Life, Attributes, Resistance, Core Power) should be available on all armor/jewelry/shields + if any( + x in fn_lower + for x in [ + "armor", + "life", + "stat", + "resist", + "dex", + "str", + "int", + "will", + "energy", + "essence", + "resource", + "fury", + "spirit", + "mana", + ] + ): + slots = ["Amulet", "Boots", "ChestArmor", "Gloves", "Helm", "Legs", "Ring", "Shield"] + # Offense-related stats should be on all weapons/jewelry + elif any(w in fn_lower for w in ["weapon", "attack", "damage", "crit", "speed"]): + slots = [t for t in GEAR_TYPES if t not in ["Boots", "ChestArmor", "Gloves", "Helm", "Legs"]] + + if slots: + if key not in affix_metadata: + affix_metadata[key] = {"slots": []} + combined_slots = set(affix_metadata[key]["slots"]) + combined_slots.update(slots) + affix_metadata[key]["slots"] = sorted(combined_slots) merge_custom_affixes(affix_dict, language) output_path = output_file or D4LF_BASE_DIR / f"assets/lang/{language}/affixes.json" @@ -390,6 +579,11 @@ def generate_affixes(d4data_dir: Path, language: str, output_file: Path | None = json.dump(affix_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) json_file.write("\n") + metadata_path = D4LF_BASE_DIR / f"assets/lang/{language}/affix_metadata.json" + with metadata_path.open("w", encoding="utf-8") as json_file: + json.dump(affix_metadata, json_file, indent=4, ensure_ascii=False, sort_keys=True) + json_file.write("\n") + def merge_custom_affixes(affix_dict: dict[str, str], language: str): custom_affixes_file = D4LF_BASE_DIR / f"src/tools/data/custom_affixes_{language}.json" @@ -632,14 +826,52 @@ def generate_uniques(d4data_dir, language): if core_unique_file.name.startswith("S10_"): # Chaos uniques really throw off our inherent counts continue + # Get inherent count and item type from this file. Beyond that, we need the file name to find the enUS strings file. + item_type = "" + character_class = "all" num_inherents = 0 + with Path(core_unique_file).open(encoding="utf-8") as unique_item_file: unique_item_data = json.load(unique_item_file) if "arForcedAffixes" not in unique_item_data or not unique_item_data["arForcedAffixes"]: continue item_type = unique_item_data["snoItemType"]["name"] inherent_affixes = unique_item_data["arInherentAffixes"] + class_info = unique_item_data.get("snoCharacterClass") + if class_info: + raw_id = int(class_info.get("__raw__", -1)) % (2**32) + if class_info.get("name"): + name_lower = class_info["name"].lower() + if "warlock" in name_lower or "sorcerer" in name_lower: + character_class = "sorcerer" + elif "barbarian" in name_lower: + character_class = "barbarian" + elif "druid" in name_lower: + character_class = "druid" + elif "necromancer" in name_lower: + character_class = "necromancer" + elif "rogue" in name_lower: + character_class = "rogue" + elif "spiritborn" in name_lower: + character_class = "spiritborn" + if character_class == "all" and raw_id in SNO_CLASS_MAP: + character_class = SNO_CLASS_MAP[raw_id] + + if character_class == "all": + fn_lower = core_unique_file.name.lower() + class_patterns = { + "_barb": "barbarian", + "_dru": "druid", + "_necro": "necromancer", + "_rog": "rogue", + "_sorc": "sorcerer", + "_spirit": "spiritborn", + } + for pattern, c_name in class_patterns.items(): + if pattern in fn_lower: + character_class = c_name + break if item_type not in GEAR_TYPES and item_type != "FocusBookOffHand": continue @@ -669,7 +901,7 @@ def generate_uniques(d4data_dir, language): if name_clean is None or name_clean in items_to_ignore or is_placeholder_or_test_name(name_clean): continue - unique_dict[name_clean] = {"num_inherents": num_inherents} + unique_dict[name_clean] = {"num_inherents": num_inherents, "item_type": item_type, "class": character_class} with Path(D4LF_BASE_DIR / f"assets/lang/{language}/uniques.json").open("w", encoding="utf-8") as json_file: json.dump(unique_dict, json_file, indent=4, ensure_ascii=False, sort_keys=True) From 2598fc07441ff6519044f57bcce1ce82e4632528 Mon Sep 17 00:00:00 2001 From: mrdeadlocked Date: Sat, 6 Jun 2026 15:16:48 -0400 Subject: [PATCH 17/17] revert affixes_tab using affix_meta for now until its validated more --- src/gui/profile_editor/affixes_tab.py | 79 +-------------------------- 1 file changed, 3 insertions(+), 76 deletions(-) diff --git a/src/gui/profile_editor/affixes_tab.py b/src/gui/profile_editor/affixes_tab.py index db925289..b5f201a9 100644 --- a/src/gui/profile_editor/affixes_tab.py +++ b/src/gui/profile_editor/affixes_tab.py @@ -545,37 +545,7 @@ def __init__(self, parent: QWidget, model: AffixFilterModel, allowed_item_types: form = QFormLayout() affix_dict = Dataloader().affix_dict - affix_metadata = _get_affix_metadata() - - filtered_affixes = [] - if allowed_item_types is None: - # Global rules: show all affixes regardless of metadata - filtered_affixes = sorted(affix_dict.values()) - else: - # Legendary items: smartly use metadata whitelist - if not allowed_item_types: - # If no slots specified, show all affixes present in metadata (the active whitelist) - for affix_id, display_name in affix_dict.items(): - if affix_id in affix_metadata: - filtered_affixes.append(display_name) - else: - # Filter by specific slots defined in the rule - allowed_slot_names = [t.name for t in allowed_item_types] - for affix_id, display_name in affix_dict.items(): - meta = affix_metadata.get(affix_id) - if meta: - slots = meta.get("slots", []) - if any(s in allowed_slot_names for s in slots): - filtered_affixes.append(display_name) - - # Fallback if metadata is missing or filter returned nothing - if not filtered_affixes: - filtered_affixes = sorted(affix_dict.values()) - - filtered_affixes.sort() - - if not filtered_affixes: - filtered_affixes = sorted(affix_dict.values()) + filtered_affixes = sorted(affix_dict.values()) self.name_combo = TruncatingComboBox() self.name_combo.setEditable(True) @@ -732,30 +702,7 @@ def add_affix_row(self, model: AffixFilterModel, allowed_item_types: list[ItemTy def add_affix(self, allowed_item_types: list[ItemType] | None = None): affix_dict = Dataloader().affix_dict - affix_metadata = _get_affix_metadata() - - filtered_affixes = [] - if allowed_item_types is None: - filtered_affixes = sorted(affix_dict.values()) - else: - if not allowed_item_types: - for affix_id, display_name in affix_dict.items(): - if affix_id in affix_metadata: - filtered_affixes.append(display_name) - else: - allowed_slot_names = [t.name for t in allowed_item_types] - for affix_id, display_name in affix_dict.items(): - meta = affix_metadata.get(affix_id) - if meta and any(s in allowed_slot_names for s in meta.get("slots", [])): - filtered_affixes.append(display_name) - - if not filtered_affixes: - filtered_affixes = sorted(affix_dict.values()) - - filtered_affixes.sort() - - if not filtered_affixes: - filtered_affixes = sorted(affix_dict.values()) + filtered_affixes = sorted(affix_dict.values()) dialog = SelectionDialog(self, "Select Affix", filtered_affixes) if dialog.exec() == QDialog.DialogCode.Accepted: @@ -1555,28 +1502,8 @@ def setup_ui(self): main_vbox.addLayout(bottom_hbox) def create_affix_name_combobox(self): - # The previous line `self.name_combo = IgnoreScrollWheelComboBox()` was redundant and overwritten. - # The TruncatingComboBox needs to be initialized correctly. affix_dict = Dataloader().affix_dict - affix_metadata = _get_affix_metadata() - - filtered_affixes = [] - if not self.allowed_item_types: - filtered_affixes = sorted(affix_dict.values()) - else: - allowed_slot_names = [t.name for t in self.allowed_item_types] - for affix_id, display_name in affix_dict.items(): - meta = affix_metadata.get(affix_id) - if meta: - slots = meta.get("slots", []) - if any(s in allowed_slot_names for s in slots): - filtered_affixes.append(display_name) - elif not affix_metadata: - filtered_affixes.append(display_name) - filtered_affixes.sort() - - if not filtered_affixes: - filtered_affixes = sorted(affix_dict.values()) + filtered_affixes = sorted(affix_dict.values()) self.name_combo = TruncatingComboBox(parent=self) self.name_combo.setEditable(True)