From 344ae078a8b0352d6cbcf9c2d3bfd675f2fb0dd7 Mon Sep 17 00:00:00 2001 From: Rusfort Date: Tue, 13 May 2025 11:54:22 +0200 Subject: [PATCH] UI Updates - Error with Definitions --- .../04_Configuration_and_Presets.md | 36 +- Documentation/01_User_Guide/05_Usage_GUI.md | 5 +- .../01_User_Guide/09_Output_Structure.md | 6 +- .../04_Configuration_System_and_Presets.md | 58 +- .../02_Developer_Guide/06_GUI_Internals.md | 29 +- config/suppliers.json | 16 +- config/user_settings.json | 0 configuration.py | 147 +++ documentation/definitions_editor_plan.md | 137 +++ gui/config_editor_dialog.py | 787 ++++++++---- gui/definitions_editor_dialog.py | 1068 +++++++++++++++++ gui/main_window.py | 17 + 12 files changed, 2038 insertions(+), 268 deletions(-) delete mode 100644 config/user_settings.json create mode 100644 documentation/definitions_editor_plan.md create mode 100644 gui/definitions_editor_dialog.py diff --git a/Documentation/01_User_Guide/04_Configuration_and_Presets.md b/Documentation/01_User_Guide/04_Configuration_and_Presets.md index 67d4782..0748067 100644 --- a/Documentation/01_User_Guide/04_Configuration_and_Presets.md +++ b/Documentation/01_User_Guide/04_Configuration_and_Presets.md @@ -15,7 +15,7 @@ The `app_settings.json` file is structured into several key sections, including: ### LLM Predictor Settings -For users who wish to utilize the experimental LLM Predictor feature, the following settings are available in `config/app_settings.json`: +For users who wish to utilize the experimental LLM Predictor feature, the following settings are available in `config/llm_settings.json`: * `llm_endpoint_url`: The URL of the LLM API endpoint. For local LLMs like LM Studio or Ollama, this will typically be `http://localhost:/v1`. Consult your LLM server documentation for the exact endpoint. * `llm_api_key`: The API key required to access the LLM endpoint. Some local LLM servers may not require a key, in which case this can be left empty. @@ -23,15 +23,39 @@ For users who wish to utilize the experimental LLM Predictor feature, the follow * `llm_temperature`: Controls the randomness of the LLM's output. Lower values (e.g., 0.1-0.5) make the output more deterministic and focused, while higher values (e.g., 0.6-1.0) make it more creative and varied. For prediction tasks, lower temperatures are generally recommended. * `llm_request_timeout`: The maximum time (in seconds) to wait for a response from the LLM API. Adjust this based on the performance of your LLM server and the complexity of the requests. -Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `app_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them. +Note that the `llm_predictor_prompt` and `llm_predictor_examples` settings are also present in `config/llm_settings.json`. These define the instructions and examples provided to the LLM for prediction. While they can be viewed here, they are primarily intended for developer reference and tuning the LLM's behavior, and most users will not need to modify them directly via the file. These settings are editable via the LLM Editor panel in the main GUI when the LLM interpretation mode is selected. -## GUI Configuration Editor +## Application Preferences (`config/app_settings.json` overrides) -You can modify the `app_settings.json` file using the built-in GUI editor. Access it via the **Edit** -> **Preferences...** menu. +You can modify user-overridable application settings using the built-in GUI editor. These settings are loaded from `config/app_settings.json` and saved as overrides in `config/user_settings.json`. Access it via the **Edit** -> **Preferences...** menu. -This editor provides a tabbed interface (e.g., "General", "Output & Naming") to view and change the core application settings defined in `app_settings.json`. Settings in the editor directly correspond to the structure and values within the JSON file. Note that any changes made through the GUI editor require an application restart to take effect. +This editor provides a tabbed interface to view and change various application behaviors. The tabs include: +* **General:** Basic settings like output base directory and temporary file prefix. +* **Output & Naming:** Settings controlling output directory and filename patterns, and how variants are handled. +* **Image Processing:** Settings related to image resolution definitions, compression levels, and format choices. +* **Map Merging:** Configuration for how multiple input maps are combined into single output maps. +* **Postprocess Scripts:** Paths to default Blender files for post-processing. -*(Ideally, a screenshot of the GUI Configuration Editor would be included here.)* +Note that this editor focuses on user-specific overrides of core application settings. **Asset Type Definitions, File Type Definitions, and Supplier Settings are managed in a separate Definitions Editor.** + +Any changes made through the Preferences editor require an application restart to take effect. + +*(Ideally, a screenshot of the Application Preferences editor would be included here.)* + +## Definitions Editor (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`) + +Core application definitions that are separate from general user preferences are managed in the dedicated Definitions Editor. This includes defining known asset types, file types, and configuring settings specific to different suppliers. Access it via the **Edit** -> **Edit Definitions...** menu. + +The editor is organized into three tabs: +* **Asset Type Definitions:** Define the different categories of assets (e.g., Surface, Model, Decal). For each asset type, you can configure its description, a color for UI representation, and example usage strings. +* **File Type Definitions:** Define the specific types of files the tool recognizes (e.g., MAP_COL, MAP_NRM, MODEL). For each file type, you can configure its description, a color, example keywords/patterns, a standard type alias, bit depth handling rules, whether it's grayscale, and an optional keybind for quick assignment in the GUI. +* **Supplier Settings:** Configure settings that are specific to assets originating from different suppliers. Currently, this includes the "Normal Map Type" (OpenGL or DirectX) used for normal maps from that supplier. + +Each tab presents a list of the defined items on the left (Asset Types, File Types, or Suppliers). Selecting an item in the list displays its configurable details on the right. Buttons are provided to add new definitions or remove existing ones. + +Changes made in the Definitions Editor are saved directly to their respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, and `config/suppliers.json`). Some changes may require an application restart to take full effect in processing logic. + +*(Ideally, screenshots of the Definitions Editor tabs would be included here.)* ## Preset Files (`presets/*.json`) diff --git a/Documentation/01_User_Guide/05_Usage_GUI.md b/Documentation/01_User_Guide/05_Usage_GUI.md index 088e8ff..b63be1a 100644 --- a/Documentation/01_User_Guide/05_Usage_GUI.md +++ b/Documentation/01_User_Guide/05_Usage_GUI.md @@ -12,7 +12,10 @@ python -m gui.main_window ## Interface Overview -* **Menu Bar:** The "Edit" menu contains the "Preferences..." option to open the GUI Configuration Editor. The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview. +* **Menu Bar:** The "Edit" menu contains options to configure application settings and definitions: + * **Preferences...:** Opens the Application Preferences editor for user-overridable settings (saved to `config/user_settings.json`). + * **Edit Definitions...:** Opens the Definitions Editor for managing Asset Type Definitions, File Type Definitions, and Supplier Settings (saved to their respective files). +The "View" menu allows you to toggle the visibility of the Log Console and the Detailed File Preview. * **Preset Editor Panel (Left):** * **Optional Log Console:** Displays application logs (toggle via View menu). * **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button. diff --git a/Documentation/01_User_Guide/09_Output_Structure.md b/Documentation/01_User_Guide/09_Output_Structure.md index 587ce03..a45a4b1 100644 --- a/Documentation/01_User_Guide/09_Output_Structure.md +++ b/Documentation/01_User_Guide/09_Output_Structure.md @@ -2,7 +2,7 @@ This document describes the directory structure and contents of the processed assets generated by the Asset Processor Tool. -Processed assets are saved to a location determined by two global settings defined in `config/app_settings.json`: +Processed assets are saved to a location determined by two global settings, `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, defined in `config/app_settings.json`. These settings can be overridden by the user via `config/user_settings.json`. * `OUTPUT_DIRECTORY_PATTERN`: Defines the directory structure *within* the Base Output Directory. * `OUTPUT_FILENAME_PATTERN`: Defines the naming convention for individual files *within* the directory created by `OUTPUT_DIRECTORY_PATTERN`. @@ -23,7 +23,7 @@ The following tokens can be used in both `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_ * `[Time]`: Current time (`HHMMSS`). * `[Sha5]`: The first 5 characters of the SHA-256 hash of the original input source file (e.g., the source zip archive). * `[ApplicationPath]`: Absolute path to the application directory. -* `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (see `config/app_settings.json`) and may include a variant suffix if applicable. (Primarily for filename pattern) +* `[maptype]`: The standardized map type identifier (e.g., `COL` for Color/Albedo, `NRM` for Normal, `RGH` for Roughness). This is derived from the `standard_type` defined in the application's `FILE_TYPE_DEFINITIONS` (managed in `config/file_type_definitions.json` via the Definitions Editor) and may include a variant suffix if applicable. (Primarily for filename pattern) * `[dimensions]`: Pixel dimensions (e.g., `2048x2048`). * `[bitdepth]`: Output bit depth (e.g., `8bit`, `16bit`). * `[category]`: Asset category determined by preset rules. @@ -51,7 +51,7 @@ The final output path is constructed by combining the Base Output Directory (set * `OUTPUT_FILENAME_PATTERN`: `[maptype].[ext]` * Resulting Path for a Normal map: `Output/Texture/Wood/WoodFloor001/Normal.exr` -The `` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **Output & Naming** tab -> **Base Output Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure. +The `` (the root folder where processing output starts) is configured separately via the GUI (**Edit** -> **Preferences...** -> **General** tab -> **Output Base Directory**) or the `--output` CLI argument. The `OUTPUT_DIRECTORY_PATTERN` defines the structure *within* this base directory, and `OUTPUT_FILENAME_PATTERN` defines the filenames within that structure. ## Contents of Each Asset Directory diff --git a/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md b/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md index bc6a5ff..1c1b4f8 100644 --- a/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md +++ b/Documentation/02_Developer_Guide/04_Configuration_System_and_Presets.md @@ -8,45 +8,61 @@ The tool's configuration is managed by the `configuration.py` module and loaded ### Configuration Files -1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns. +The tool's configuration is loaded from several JSON files, providing a layered approach for defaults, user overrides, definitions, and source-specific presets. + +1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, temporary directory prefix, initial scaling mode, merge dimension mismatch strategy). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns. * *Note:* `ASSET_TYPE_DEFINITIONS` and `FILE_TYPE_DEFINITIONS` are no longer stored here; they have been moved to dedicated files. -2. **User Settings (`config/user_settings.json`):** This optional JSON file allows users to override specific settings defined in `config/app_settings.json`. If this file exists, its values for corresponding keys will take precedence over the base application settings. This file is primarily managed through the GUI's Configuration Editor. +2. **User Settings (`config/user_settings.json`):** This optional JSON file allows users to override specific settings defined in `config/app_settings.json`. If this file exists, its values for corresponding keys will take precedence over the base application settings. This file is primarily managed through the GUI's Application Preferences Editor. -3. **Asset Type Definitions (`config/asset_type_definitions.json`):** This dedicated JSON file contains the definitions for different asset types (e.g., Surface, Model, Decal), including their descriptions, colors, and examples. +3. **Asset Type Definitions (`config/asset_type_definitions.json`):** This dedicated JSON file contains the definitions for different asset types (e.g., Surface, Model, Decal), including their descriptions, colors for UI representation, and example usage strings. -4. **File Type Definitions (`config/file_type_definitions.json`):** This dedicated JSON file contains the definitions for different file types (specifically texture maps and models), including descriptions, colors, examples, standard aliases, bit depth rules, grayscale flags, and GUI keybinds. - * **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`, or standalone like `F2` for asset naming) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table. +4. **File Type Definitions (`config/file_type_definitions.json`):** This dedicated JSON file contains the definitions for different file types (specifically texture maps and models), including descriptions, colors for UI representation, examples of keywords/patterns, a standard alias (`standard_type`), bit depth handling rules (`bit_depth_rule`), a grayscale flag (`is_grayscale`), and an optional GUI keybind (`keybind`). + * **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table. *Example:* ```json "MAP_COL": { "description": "Color/Albedo Map", - "color": [200, 200, 200], - "examples": ["albedo", "col", "basecolor"], + "color": "#ffaa00", + "examples": ["_col.", "_basecolor.", "albedo", "diffuse"], "standard_type": "COL", - "bit_depth_rule": "respect", + "bit_depth_rule": "force_8bit", "is_grayscale": false, "keybind": "C" }, ``` - * **New File Type `MAP_GLOSS`:** A standard file type, `MAP_GLOSS`, is defined here. - *Example:* + Note: The `bit_depth_rule` property in `FILE_TYPE_DEFINITIONS` is the primary source for determining bit depth handling for a given map type. + +5. **Supplier Settings (`config/suppliers.json`):** This JSON file stores settings specific to different asset suppliers. It is now structured as a dictionary where keys are supplier names and values are objects containing supplier-specific configurations. + * **Structure:** ```json - "MAP_GLOSS": { - "description": "Glossiness Map", - "color": [180, 180, 220], - "examples": ["gloss", "gls"], - "standard_type": "GLOSS", - "bit_depth_rule": "respect", - "is_grayscale": true, - "keybind": "R" + { + "SupplierName1": { + "setting_key1": "value", + "setting_key2": "value" + }, + "SupplierName2": { + "setting_key1": "value" + } + } + ``` + * **`normal_map_type` Property:** A key setting within each supplier's object is `normal_map_type`, specifying whether normal maps from this supplier use "OpenGL" or "DirectX" conventions. + *Example:* + ```json + { + "Poliigon": { + "normal_map_type": "DirectX" + }, + "Dimensiva": { + "normal_map_type": "OpenGL" + } } ``` - Note: The `keybind` "R" for `MAP_GLOSS` is often shared with `MAP_ROUGH` to allow toggling between them. -5. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings can be edited through the GUI using the `LLMEditorWidget`. +6. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings are managed through the GUI using the `LLMEditorWidget`. + +7. **Preset Files (`Presets/*.json`):** These JSON files define source-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors. Preset settings override values from `app_settings.json` and `user_settings.json` where applicable. -6. **Preset Files (`Presets/*.json`):** These JSON files define supplier-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors. Preset settings override values from `app_settings.json` and `user_settings.json` where applicable. ### Configuration Loading and Access diff --git a/Documentation/02_Developer_Guide/06_GUI_Internals.md b/Documentation/02_Developer_Guide/06_GUI_Internals.md index 21c36cb..da90018 100644 --- a/Documentation/02_Developer_Guide/06_GUI_Internals.md +++ b/Documentation/02_Developer_Guide/06_GUI_Internals.md @@ -10,13 +10,13 @@ The GUI is built using `PySide6`, which provides Python bindings for the Qt fram The `MainWindow` class acts as the central **coordinator** for the GUI application. It is responsible for: -* Setting up the main application window structure and menu bar. +* Setting up the main application window structure and menu bar, including actions to launch configuration and definition editors. * **Layout:** Arranging the main GUI components using a `QSplitter`. * **Left Pane:** Contains the preset selection controls (from `PresetEditorWidget`) permanently displayed at the top. Below this, a `QStackedWidget` switches between the preset JSON editor (also from `PresetEditorWidget`) and the `LLMEditorWidget`. * **Right Pane:** Contains the `MainPanelWidget`. * Instantiating and managing the major GUI widgets: * `PresetEditorWidget` (`gui/preset_editor_widget.py`): Provides the preset selector and the JSON editor parts. - * `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings. + * `LLMEditorWidget` (`gui/llm_editor_widget.py`): Provides the editor for LLM settings (from `config/llm_settings.json`). * `MainPanelWidget` (`gui/main_panel_widget.py`): Contains the rule hierarchy view and processing controls. * `LogConsoleWidget` (`gui/log_console_widget.py`): Displays application logs. * Instantiating key models and handlers: @@ -198,13 +198,24 @@ The `LogConsoleWidget` displays logs captured by a custom `QtLogHandler` from Py The GUI provides a "Cancel" button. Cancellation logic for the actual processing is now likely handled within the `main.ProcessingTask` or the code that manages it, as the `ProcessingHandler` has been removed. The GUI button would signal this external task manager. -## GUI Configuration Editor (`gui/config_editor_dialog.py`) +## Application Preferences Editor (`gui/config_editor_dialog.py`) -A dedicated dialog for editing `config/app_settings.json`. +A dedicated dialog for editing user-overridable application settings. It loads base settings from `config/app_settings.json` and saves user overrides to `config/user_settings.json`. -* **Functionality:** Loads `config/app_settings.json`, presents in tabs, allows editing basic fields, definitions tables (with color editing), and merge rules list/detail. -* **Limitations:** Editing complex fields like `IMAGE_RESOLUTIONS` or full `MAP_MERGE_RULES` details might still be limited. -* **Integration:** Launched by `MainWindow` ("Edit" -> "Preferences..."). -* **Persistence:** Saves changes to `config/app_settings.json`. Requires application restart for changes to affect processing logic loaded by the `Configuration` class. +* **Functionality:** Provides a tabbed interface to edit various application settings, including general paths, output/naming patterns, image processing options (like resolutions and compression), and map merging rules. It no longer includes editors for Asset Type or File Type Definitions. +* **Integration:** Launched by `MainWindow` via the "Edit" -> "Preferences..." menu. +* **Persistence:** Saves changes to `config/user_settings.json`. Changes require an application restart to take effect in processing logic. -The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`. \ No newline at end of file +The refactored GUI separates concerns into distinct widgets and handlers, coordinated by the `MainWindow`. Background tasks use `QThreadPool` and `QRunnable`. The `UnifiedViewModel` focuses on data presentation and simple edits, delegating complex restructuring to the `AssetRestructureHandler`. + +## Definitions Editor (`gui/definitions_editor_dialog.py`) + +A new dedicated dialog for managing core application definitions that are separate from general user preferences. + +* **Purpose:** Provides a structured UI for editing Asset Type Definitions, File Type Definitions, and Supplier Settings. +* **Structure:** Uses a `QTabWidget` with three tabs: + * **Asset Type Definitions:** Manages definitions from `config/asset_type_definitions.json`. Presents a list of asset types and allows editing their description, color, and examples. + * **File Type Definitions:** Manages definitions from `config/file_type_definitions.json`. Presents a list of file types and allows editing their description, color, examples, standard type, bit depth rule, grayscale status, and keybind. + * **Supplier Settings:** Manages settings from `config/suppliers.json`. Presents a list of suppliers and allows editing supplier-specific settings (e.g., Normal Map Type). +* **Integration:** Launched by `MainWindow` via the "Edit" -> "Edit Definitions..." menu. +* **Persistence:** Saves changes directly to the respective configuration files (`config/asset_type_definitions.json`, `config/file_type_definitions.json`, `config/suppliers.json`). Some changes may require an application restart. \ No newline at end of file diff --git a/config/suppliers.json b/config/suppliers.json index 6b29144..a10affa 100644 --- a/config/suppliers.json +++ b/config/suppliers.json @@ -1,5 +1,11 @@ -[ - "Dimensiva", - "Dinesen", - "Poliigon" -] \ No newline at end of file +{ + "Dimensiva": { + "normal_map_type": "OpenGL" + }, + "Dinesen": { + "normal_map_type": "OpenGL" + }, + "Poliigon": { + "normal_map_type": "OpenGL" + } +} \ No newline at end of file diff --git a/config/user_settings.json b/config/user_settings.json deleted file mode 100644 index e69de29..0000000 diff --git a/configuration.py b/configuration.py index 27dd400..6a4bee1 100644 --- a/configuration.py +++ b/configuration.py @@ -13,6 +13,7 @@ LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json" ASSET_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "asset_type_definitions.json" FILE_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "file_type_definitions.json" USER_SETTINGS_PATH = BASE_DIR / "config" / "user_settings.json" # New path for user settings +SUPPLIERS_CONFIG_PATH = BASE_DIR / "config" / "suppliers.json" PRESETS_DIR = BASE_DIR / "Presets" class ConfigurationError(Exception): @@ -801,3 +802,149 @@ def save_base_config(settings_dict: dict): except Exception as e: log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}") raise ConfigurationError(f"Failed to save configuration: {e}") + +def load_asset_definitions() -> dict: + """ + Reads config/asset_type_definitions.json. + Returns the dictionary under the "ASSET_TYPE_DEFINITIONS" key. + Handles file not found or JSON errors gracefully (e.g., return empty dict, log error). + """ + log.debug(f"Loading asset type definitions from: {ASSET_TYPE_DEFINITIONS_PATH}") + if not ASSET_TYPE_DEFINITIONS_PATH.is_file(): + log.error(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}") + return {} + try: + with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + if "ASSET_TYPE_DEFINITIONS" not in data: + log.error(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}") + return {} + settings = data["ASSET_TYPE_DEFINITIONS"] + if not isinstance(settings, dict): + log.error(f"'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} must be a dictionary.") + return {} + log.debug(f"Asset type definitions loaded successfully.") + return settings + except json.JSONDecodeError as e: + log.error(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") + return {} + except Exception as e: + log.error(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}") + return {} + +def save_asset_definitions(data: dict): + """ + Takes a dictionary (representing the content for the "ASSET_TYPE_DEFINITIONS" key). + Writes it to config/asset_type_definitions.json under the root key "ASSET_TYPE_DEFINITIONS". + Handles potential I/O errors. + """ + log.debug(f"Saving asset type definitions to: {ASSET_TYPE_DEFINITIONS_PATH}") + try: + with open(ASSET_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f: + json.dump({"ASSET_TYPE_DEFINITIONS": data}, f, indent=4) + log.info(f"Asset type definitions saved successfully to {ASSET_TYPE_DEFINITIONS_PATH}") + except Exception as e: + log.error(f"Failed to save asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}") + raise ConfigurationError(f"Failed to save asset type definitions: {e}") + +def load_file_type_definitions() -> dict: + """ + Reads config/file_type_definitions.json. + Returns the dictionary under the "FILE_TYPE_DEFINITIONS" key. + Handles errors gracefully. + """ + log.debug(f"Loading file type definitions from: {FILE_TYPE_DEFINITIONS_PATH}") + if not FILE_TYPE_DEFINITIONS_PATH.is_file(): + log.error(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}") + return {} + try: + with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + if "FILE_TYPE_DEFINITIONS" not in data: + log.error(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}") + return {} + settings = data["FILE_TYPE_DEFINITIONS"] + if not isinstance(settings, dict): + log.error(f"'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} must be a dictionary.") + return {} + log.debug(f"File type definitions loaded successfully.") + return settings + except json.JSONDecodeError as e: + log.error(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") + return {} + except Exception as e: + log.error(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}") + return {} + +def save_file_type_definitions(data: dict): + """ + Takes a dictionary (representing content for "FILE_TYPE_DEFINITIONS" key). + Writes it to config/file_type_definitions.json under the root key "FILE_TYPE_DEFINITIONS". + Handles errors. + """ + log.debug(f"Saving file type definitions to: {FILE_TYPE_DEFINITIONS_PATH}") + try: + with open(FILE_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f: + json.dump({"FILE_TYPE_DEFINITIONS": data}, f, indent=4) + log.info(f"File type definitions saved successfully to {FILE_TYPE_DEFINITIONS_PATH}") + except Exception as e: + log.error(f"Failed to save file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}") + raise ConfigurationError(f"Failed to save file type definitions: {e}") + +def load_supplier_settings() -> dict: + """ + Reads config/suppliers.json. + Returns the entire dictionary. + Handles file not found (return empty dict) or JSON errors. + If the loaded data is a list (old format), convert it in memory to the new + dictionary format, defaulting normal_map_type to "OpenGL" for each supplier. + """ + log.debug(f"Loading supplier settings from: {SUPPLIERS_CONFIG_PATH}") + if not SUPPLIERS_CONFIG_PATH.is_file(): + log.warning(f"Supplier settings file not found: {SUPPLIERS_CONFIG_PATH}. Returning empty dict.") + return {} + try: + with open(SUPPLIERS_CONFIG_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + + if isinstance(data, list): + log.warning(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} is in the old list format. Converting to new dictionary format.") + new_data = {} + for supplier_name in data: + if isinstance(supplier_name, str): + new_data[supplier_name] = {"normal_map_type": "OpenGL"} + else: + log.warning(f"Skipping non-string item '{supplier_name}' during old format conversion of supplier settings.") + log.info(f"Supplier settings converted to new format: {new_data}") + return new_data + + if not isinstance(data, dict): + log.error(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} must be a dictionary. Found {type(data)}. Returning empty dict.") + return {} + + log.debug(f"Supplier settings loaded successfully.") + return data + except json.JSONDecodeError as e: + log.error(f"Failed to parse supplier settings file {SUPPLIERS_CONFIG_PATH}: Invalid JSON - {e}. Returning empty dict.") + return {} + except Exception as e: + log.error(f"Failed to read supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}. Returning empty dict.") + return {} + +def save_supplier_settings(data: dict): + """ + Takes a dictionary (in the new format). + Writes it directly to config/suppliers.json. + Handles errors. + """ + log.debug(f"Saving supplier settings to: {SUPPLIERS_CONFIG_PATH}") + if not isinstance(data, dict): + log.error(f"Data for save_supplier_settings must be a dictionary. Got {type(data)}.") + raise ConfigurationError(f"Invalid data type for saving supplier settings: {type(data)}") + try: + with open(SUPPLIERS_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) # Using indent=2 as per the example for suppliers.json + log.info(f"Supplier settings saved successfully to {SUPPLIERS_CONFIG_PATH}") + except Exception as e: + log.error(f"Failed to save supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}") + raise ConfigurationError(f"Failed to save supplier settings: {e}") diff --git a/documentation/definitions_editor_plan.md b/documentation/definitions_editor_plan.md new file mode 100644 index 0000000..b69ff7a --- /dev/null +++ b/documentation/definitions_editor_plan.md @@ -0,0 +1,137 @@ +# Plan for New Definitions Editor UI + +## 1. Overview + +This document outlines the plan to create a new, dedicated UI for managing "Asset Type Definitions", "File Type Definitions", and "Supplier Settings". This editor will provide a more structured and user-friendly way to manage these core application configurations, which are currently stored in separate JSON files. + +## 2. General Design Principles + +* **Dedicated Dialog:** The editor will be a new `QDialog` (e.g., `DefinitionsEditorDialog`). +* **Access Point:** Launched from the `MainWindow` menu bar (e.g., under a "Definitions" menu or "Edit" -> "Edit Definitions..."). +* **Tabbed Interface:** The dialog will use a `QTabWidget` to separate the management of different definition types. +* **List/Details View:** Each tab will generally follow a two-pane layout: + * **Left Pane:** A `QListWidget` displaying the primary keys or names of the definitions (e.g., asset type names, file type IDs, supplier names). Includes "Add" and "Remove" buttons for managing these primary entries. + * **Right Pane:** A details area (e.g., `QGroupBox` with a `QFormLayout`) that shows the specific settings for the item selected in the left-pane list. +* **Data Persistence:** The dialog will load from and save to the respective JSON configuration files: + * Asset Types: `config/asset_type_definitions.json` + * File Types: `config/file_type_definitions.json` + * Supplier Settings: `config/suppliers.json` (This file will be refactored from a simple list to a dictionary of supplier objects). +* **User Experience:** Standard "Save" and "Cancel" buttons, with a check for unsaved changes. + +## 3. Tab-Specific Plans + +### 3.1. Asset Type Definitions Tab + +* **Manages:** `config/asset_type_definitions.json` +* **UI Sketch:** + ```mermaid + graph LR + subgraph AssetTypeTab [Asset Type Definitions Tab] + direction LR + AssetList[QListWidget (Asset Type Keys e.g., "Surface")] --> AssetDetailsGroup{Details for Selected Asset Type}; + end + + subgraph AssetDetailsGroup + direction TB + Desc[Description: QTextEdit] + Color[Color: QPushButton ("Choose Color...") + Color Swatch Display] + Examples[Examples: QListWidget + Add/Remove Example Buttons] + end + AssetActions["Add Asset Type (Prompt for Name)\nRemove Selected Asset Type"] --> AssetList + ``` +* **Details:** + * **Left Pane:** `QListWidget` for asset type names. "Add Asset Type" (prompts for new key) and "Remove Selected Asset Type" buttons. + * **Right Pane (Details):** + * `description`: `QTextEdit`. + * `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` to display the color swatch. + * `examples`: `QListWidget` with "Add Example" (`QInputDialog.getText`) and "Remove Selected Example" buttons. + +### 3.2. File Type Definitions Tab + +* **Manages:** `config/file_type_definitions.json` +* **UI Sketch:** + ```mermaid + graph LR + subgraph FileTypeTab [File Type Definitions Tab] + direction LR + FileList[QListWidget (File Type Keys e.g., "MAP_COL")] --> FileDetailsGroup{Details for Selected File Type}; + end + + subgraph FileDetailsGroup + direction TB + DescF[Description: QTextEdit] + ColorF[Color: QPushButton ("Choose Color...") + Color Swatch Display] + ExamplesF[Examples: QListWidget + Add/Remove Example Buttons] + StdType[Standard Type: QLineEdit] + BitDepth[Bit Depth Rule: QComboBox ("respect", "force_8bit", "force_16bit")] + IsGrayscale[Is Grayscale: QCheckBox] + Keybind[Keybind: QLineEdit (1 char)] + end + FileActions["Add File Type (Prompt for ID)\nRemove Selected File Type"] --> FileList + ``` +* **Details:** + * **Left Pane:** `QListWidget` for file type IDs. "Add File Type" (prompts for new key) and "Remove Selected File Type" buttons. + * **Right Pane (Details):** + * `description`: `QTextEdit`. + * `color`: `QPushButton` opening `QColorDialog`, with an adjacent `QLabel` for color swatch. + * `examples`: `QListWidget` with "Add Example" and "Remove Selected Example" buttons. + * `standard_type`: `QLineEdit`. + * `bit_depth_rule`: `QComboBox` (options: "respect", "force_8bit", "force_16bit"). + * `is_grayscale`: `QCheckBox`. + * `keybind`: `QLineEdit` (validation for single character recommended). + +### 3.3. Supplier Settings Tab + +* **Manages:** `config/suppliers.json` (This file will be refactored to a dictionary structure, e.g., `{"SupplierName": {"normal_map_type": "OpenGL", ...}}`). +* **UI Sketch:** + ```mermaid + graph LR + subgraph SupplierTab [Supplier Settings Tab] + direction LR + SupplierList[QListWidget (Supplier Names)] --> SupplierDetailsGroup{Details for Selected Supplier}; + end + + subgraph SupplierDetailsGroup + direction TB + NormalMapType[Normal Map Type: QComboBox ("OpenGL", "DirectX")] + %% Future supplier-specific settings can be added here + end + SupplierActions["Add Supplier (Prompt for Name)\nRemove Selected Supplier"] --> SupplierList + ``` +* **Details:** + * **Left Pane:** `QListWidget` for supplier names. "Add Supplier" (prompts for new name) and "Remove Selected Supplier" buttons. + * **Right Pane (Details):** + * `normal_map_type`: `QComboBox` (options: "OpenGL", "DirectX"). Default for new suppliers: "OpenGL". + * *(Space for future supplier-specific settings).* +* **Data Handling Note for `config/suppliers.json`:** + * The editor will load from and save to `config/suppliers.json` using the new dictionary format (supplier name as key, object of settings as value). + * Initial implementation might require `config/suppliers.json` to be manually updated to this new format if it currently exists as a simple list. Alternatively, the editor could attempt an automatic conversion on first load if the old list format is detected, or prompt the user. For the first pass, assuming the editor works with the new format is simpler. + +## 4. Implementation Steps (High-Level) + +1. **(Potentially Manual First Step) Refactor `config/suppliers.json`:** If `config/suppliers.json` exists as a list, manually convert it to the new dictionary structure (e.g., `{"SupplierName": {"normal_map_type": "OpenGL"}}`) before starting UI development for this tab, or plan for the editor to handle this conversion. +2. **Create `DefinitionsEditorDialog` Class:** Inherit from `QDialog`. +3. **Implement UI Structure:** Main `QTabWidget`, and for each tab, the two-pane layout with `QListWidget`, `QGroupBox` for details, and relevant input widgets (`QLineEdit`, `QTextEdit`, `QComboBox`, `QCheckBox`, `QPushButton`). +4. **Implement Loading Logic:** + * For each tab, read data from its corresponding JSON file. + * Populate the left-pane `QListWidget` with the primary keys/names. + * Store the full data structure internally (e.g., in dictionaries within the dialog instance). +5. **Implement Display Logic:** + * When an item is selected in a `QListWidget`, populate the right-pane detail fields with the data for that item. +6. **Implement Editing Logic:** + * Ensure that changes made in the detail fields (text edits, combobox selections, checkbox states, color choices, list example modifications) update the corresponding internal data structure for the currently selected item. +7. **Implement Add/Remove Functionality:** + * For each definition type (Asset Type, File Type, Supplier), implement the "Add" and "Remove" buttons. + * "Add": Prompt for a unique key/name, create a new default entry in the internal data, and add it to the `QListWidget`. + * "Remove": Remove the selected item from the `QListWidget` and the internal data. + * For "examples" lists within Asset and File types, implement their "Add Example" and "Remove Selected Example" buttons. +8. **Implement Saving Logic:** + * When the main "Save" button is clicked: + * Write the (potentially modified) Asset Type definitions data structure to `config/asset_type_definitions.json`. + * Write File Type definitions to `config/file_type_definitions.json`. + * Write Supplier settings (in the new dictionary format) to `config/suppliers.json`. + * Consider creating new dedicated save functions in `configuration.py` for each of these files if they don't already exist or if existing ones are not suitable. +9. **Implement Unsaved Changes Check & Cancel Logic.** +10. **Integrate Dialog Launch:** Add a menu action in `MainWindow.py` to open the `DefinitionsEditorDialog`. + +This plan provides a comprehensive approach to creating a dedicated editor for these crucial application definitions. \ No newline at end of file diff --git a/gui/config_editor_dialog.py b/gui/config_editor_dialog.py index fae8538..fd3ed80 100644 --- a/gui/config_editor_dialog.py +++ b/gui/config_editor_dialog.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import ( QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QPushButton, QFileDialog, QLabel, QTableWidget, QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget, - QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget, + QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget, QInputDialog, QHeaderView, QSizePolicy ) from PySide6.QtGui import QColor, QPainter @@ -19,7 +19,7 @@ try: from configuration import load_base_config, save_user_config, ConfigurationError except ImportError: # Fallback import for testing or different project structure - from ..configuration import load_base_config, save_user_config, ConfigurationError + from ..configuration import load_base_config, save_user_config, ConfigurationError, Configuration # --- Custom Delegate for Color Editing --- @@ -61,6 +61,90 @@ class ColorDelegate(QStyledItemDelegate): # Not strictly needed as setData is called in editorEvent pass +# --- Custom Delegate for ComboBox Editing in Tables --- +class ComboBoxDelegate(QStyledItemDelegate): + def __init__(self, items=None, parent=None): + super().__init__(parent) + self.items = items if items is not None else [] + + def createEditor(self, parent, option, index): + editor = QComboBox(parent) + editor.addItems(self.items) + return editor + + def setEditorData(self, editor, index): + value = index.model().data(index, Qt.EditRole) + if value is not None: + editor.setCurrentText(str(value)) + + def setModelData(self, editor, model, index): + value = editor.currentText() + model.setData(index, value, Qt.EditRole) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + +# --- Custom Delegate for DoubleSpinBox Editing in Tables --- +class DoubleSpinBoxDelegate(QStyledItemDelegate): + def __init__(self, min_val=0.0, max_val=1.0, decimals=2, step=0.01, parent=None): + super().__init__(parent) + self.min_val = min_val + self.max_val = max_val + self.decimals = decimals + self.step = step + + def createEditor(self, parent, option, index): + editor = QDoubleSpinBox(parent) + editor.setMinimum(self.min_val) + editor.setMaximum(self.max_val) + editor.setDecimals(self.decimals) + editor.setSingleStep(self.step) + return editor + + def setEditorData(self, editor, index): + value = index.model().data(index, Qt.EditRole) + try: + editor.setValue(float(value)) + except (TypeError, ValueError): + editor.setValue(self.min_val) # Default if conversion fails + + def setModelData(self, editor, model, index): + editor.interpretText() # Ensure the editor's value is up-to-date + value = editor.value() + model.setData(index, value, Qt.EditRole) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + + +class SpinBoxDelegate(QStyledItemDelegate): + def __init__(self, min_val=1, max_val=32768, step=1, parent=None): + super().__init__(parent) + self.min_val = min_val + self.max_val = max_val + self.step = step + + def createEditor(self, parent, option, index): + editor = QSpinBox(parent) + editor.setMinimum(self.min_val) + editor.setMaximum(self.max_val) + editor.setSingleStep(self.step) + return editor + + def setEditorData(self, editor, index): + value = index.model().data(index, Qt.EditRole) + try: + editor.setValue(int(value)) + except (TypeError, ValueError): + editor.setValue(self.min_val) # Default if conversion fails + + def setModelData(self, editor, model, index): + editor.interpretText() # Ensure the editor's value is up-to-date + value = editor.value() + model.setData(index, value, Qt.EditRole) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) class ConfigEditorDialog(QDialog): def __init__(self, parent=None): @@ -201,6 +285,7 @@ class ConfigEditorDialog(QDialog): self.widgets.pop("OUTPUT_BASE_DIR", None) self.widgets.pop("EXTRA_FILES_SUBDIR", None) self.widgets.pop("METADATA_FILENAME", None) + self.widgets.pop("TEMP_DIR_PREFIX", None) form_layout = QFormLayout() @@ -230,6 +315,13 @@ class ConfigEditorDialog(QDialog): form_layout.addRow(metadata_label, metadata_edit) self.widgets["METADATA_FILENAME"] = metadata_edit + # 4. TEMP_DIR_PREFIX: QLineEdit + temp_dir_label = QLabel("Temporary Directory Prefix:") + temp_dir_edit = QLineEdit() + temp_dir_edit.setToolTip("Prefix for temporary directories created during processing.") + form_layout.addRow(temp_dir_label, temp_dir_edit) + self.widgets["TEMP_DIR_PREFIX"] = temp_dir_edit + layout.addLayout(form_layout) layout.addStretch() # Keep stretch at the end @@ -260,8 +352,11 @@ class ConfigEditorDialog(QDialog): # Clear potentially lingering widget references for this tab self.widgets.pop("TARGET_FILENAME_PATTERN", None) - self.widgets.pop("RESPECT_VARIANT_MAP_TYPES", None) + # self.widgets.pop("RESPECT_VARIANT_MAP_TYPES_LISTWIDGET", None) # This was an intermediate key, ensure it's gone + self.widgets.pop("RESPECT_VARIANT_MAP_TYPES", None) # This is the correct key for the QListWidget self.widgets.pop("ASPECT_RATIO_DECIMALS", None) + self.widgets.pop("OUTPUT_DIRECTORY_PATTERN", None) + self.widgets.pop("OUTPUT_FILENAME_PATTERN", None) main_tab_layout = QVBoxLayout() @@ -277,11 +372,28 @@ class ConfigEditorDialog(QDialog): form_layout.addRow(target_filename_label, target_filename_edit) self.widgets["TARGET_FILENAME_PATTERN"] = target_filename_edit - # 2. RESPECT_VARIANT_MAP_TYPES: QLineEdit - respect_variant_label = QLabel("Map Types Respecting Variants (comma-separated):") - respect_variant_edit = QLineEdit() - form_layout.addRow(respect_variant_label, respect_variant_edit) - self.widgets["RESPECT_VARIANT_MAP_TYPES"] = respect_variant_edit + # 2. RESPECT_VARIANT_MAP_TYPES: QListWidget + Add/Remove Buttons + respect_variant_label = QLabel("Map Types Respecting Variants:") + + self.respect_variant_list_widget = QListWidget() + self.respect_variant_list_widget.setToolTip("List of map types that should respect variant naming.") + self.widgets["RESPECT_VARIANT_MAP_TYPES"] = self.respect_variant_list_widget # Use the actual setting key + + respect_variant_buttons_layout = QHBoxLayout() + add_respect_variant_button = QPushButton("Add") + add_respect_variant_button.clicked.connect(self.add_respect_variant_map_type) + remove_respect_variant_button = QPushButton("Remove") + remove_respect_variant_button.clicked.connect(self.remove_respect_variant_map_type) + respect_variant_buttons_layout.addWidget(add_respect_variant_button) + respect_variant_buttons_layout.addWidget(remove_respect_variant_button) + respect_variant_buttons_layout.addStretch() + + respect_variant_layout = QVBoxLayout() + respect_variant_layout.addWidget(self.respect_variant_list_widget) + respect_variant_layout.addLayout(respect_variant_buttons_layout) + + form_layout.addRow(respect_variant_label, respect_variant_layout) + # self.widgets["RESPECT_VARIANT_MAP_TYPES"] will now refer to the list widget for population/saving logic # 3. ASPECT_RATIO_DECIMALS: QSpinBox aspect_ratio_label = QLabel("Aspect Ratio Precision (Decimals):") @@ -290,6 +402,28 @@ class ConfigEditorDialog(QDialog): form_layout.addRow(aspect_ratio_label, aspect_ratio_spinbox) self.widgets["ASPECT_RATIO_DECIMALS"] = aspect_ratio_spinbox + # 4. OUTPUT_DIRECTORY_PATTERN: QLineEdit + output_dir_pattern_label = QLabel("Output Directory Pattern:") + output_dir_pattern_edit = QLineEdit() + output_dir_pattern_edit.setToolTip( + "Define the output subdirectory structure relative to Output Base Directory.\n" + "Placeholders: {supplier}, {asset_name}, {asset_category}, etc." + ) + form_layout.addRow(output_dir_pattern_label, output_dir_pattern_edit) + self.widgets["OUTPUT_DIRECTORY_PATTERN"] = output_dir_pattern_edit + + # 5. OUTPUT_FILENAME_PATTERN: QLineEdit (Note: app_settings.json has TARGET_FILENAME_PATTERN and OUTPUT_FILENAME_PATTERN) + # Assuming this is the one from app_settings.json line 9 + output_filename_pattern_label = QLabel("Output Filename Pattern (Legacy/Alternative):") + output_filename_pattern_edit = QLineEdit() + output_filename_pattern_edit.setToolTip( + "Alternative output filename structure if different from Target Filename Pattern.\n" + "Placeholders: {assetname}, {maptype}, {resolution}, {ext}, etc." + ) + form_layout.addRow(output_filename_pattern_label, output_filename_pattern_edit) + self.widgets["OUTPUT_FILENAME_PATTERN"] = output_filename_pattern_edit + + main_tab_layout.addLayout(form_layout) layout.addLayout(main_tab_layout) @@ -325,7 +459,9 @@ class ConfigEditorDialog(QDialog): "IMAGE_RESOLUTIONS_TABLE", "CALCULATE_STATS_RESOLUTION", "PNG_COMPRESSION_LEVEL", "JPG_QUALITY", "RESOLUTION_THRESHOLD_FOR_JPG", "OUTPUT_FORMAT_8BIT", - "OUTPUT_FORMAT_16BIT_PRIMARY", "OUTPUT_FORMAT_16BIT_FALLBACK" + "OUTPUT_FORMAT_16BIT_PRIMARY", "OUTPUT_FORMAT_16BIT_FALLBACK", + "general_settings.invert_normal_map_green_channel_globally", + "INITIAL_SCALING_MODE" ] for key in keys_to_clear: self.widgets.pop(key, None) @@ -340,17 +476,30 @@ class ConfigEditorDialog(QDialog): resolutions_table = QTableWidget() resolutions_table.setColumnCount(2) resolutions_table.setHorizontalHeaderLabels(["Name", "Resolution (px)"]) - resolutions_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand vertically - resolutions_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) # Stretch Name column - resolutions_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) # Resize Resolution column to contents - # TODO: Implement custom delegate for "Resolution (px)" column - # TODO: Connect add/remove buttons signals + resolutions_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) # Adjust size policy + resolutions_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + resolutions_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Interactive) # Allow user resize, or ResizeToContents + + # Set SpinBox delegate for "Resolution (px)" column + # Ensure self.resolution_delegate is initialized if not already + if not hasattr(self, 'resolution_delegate'): + self.resolution_delegate = SpinBoxDelegate(min_val=1, max_val=65536, parent=resolutions_table) # Max typical texture size + resolutions_table.setItemDelegateForColumn(1, self.resolution_delegate) + resolutions_layout.addWidget(resolutions_table) self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table resolutions_button_layout = QHBoxLayout() add_res_button = QPushButton("Add Row") remove_res_button = QPushButton("Remove Row") + + # Ensure methods exist before connecting + if hasattr(self, 'add_image_resolution_row') and hasattr(self, 'remove_image_resolution_row'): + add_res_button.clicked.connect(self.add_image_resolution_row) + remove_res_button.clicked.connect(self.remove_image_resolution_row) + else: + print("Warning: add_image_resolution_row or remove_image_resolution_row not found during connect.") + resolutions_button_layout.addWidget(add_res_button) resolutions_button_layout.addWidget(remove_res_button) resolutions_button_layout.addStretch() # Push buttons left @@ -411,6 +560,21 @@ class ConfigEditorDialog(QDialog): self.widgets["OUTPUT_FORMAT_16BIT_FALLBACK"] = format_16bit_fallback_combo main_tab_layout.addLayout(form_layout) + + # Add general_settings.invert_normal_map_green_channel_globally QCheckBox + invert_normal_checkbox = QCheckBox("Invert Normal Map Green Channel Globally") + invert_normal_checkbox.setToolTip("Applies green channel inversion for normal maps project-wide.") + # Add to form_layout or main_tab_layout. Let's add to form_layout for consistency. + form_layout.addRow(invert_normal_checkbox) # Label can be omitted if checkbox text is descriptive + self.widgets["general_settings.invert_normal_map_green_channel_globally"] = invert_normal_checkbox + + # INITIAL_SCALING_MODE: QComboBox + initial_scaling_label = QLabel("Initial Scaling Mode:") + initial_scaling_combo = QComboBox() + initial_scaling_combo.addItems(["POT_DOWNSCALE", "POT_UPSCALE", "NONE", "ASPECT_PRESERVING_DOWNSCALE"]) # Add likely options + initial_scaling_combo.setToolTip("Determines how images are initially scaled if they are not power-of-two.") + form_layout.addRow(initial_scaling_label, initial_scaling_combo) + self.widgets["INITIAL_SCALING_MODE"] = initial_scaling_combo layout.addLayout(main_tab_layout) layout.addStretch() # Keep stretch at the end of the tab's main layout @@ -462,82 +626,9 @@ class ConfigEditorDialog(QDialog): overall_layout.addLayout(default_category_layout) self.widgets["DEFAULT_ASSET_CATEGORY"] = default_category_combo - # --- Bottom Widget: Inner QTabWidget --- - inner_tab_widget = QTabWidget() - inner_tab_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow inner tabs to expand - overall_layout.addWidget(inner_tab_widget) - - # --- Inner Tab 1: Asset Types --- - asset_types_tab = QWidget() - asset_types_layout = QVBoxLayout(asset_types_tab) - inner_tab_widget.addTab(asset_types_tab, "Asset Types") - - # Asset Types Table - asset_types_table = QTableWidget() - asset_types_table.setColumnCount(4) - asset_types_table.setHorizontalHeaderLabels(["Type Name", "Description", "Color", "Examples (comma-sep.)"]) - asset_types_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand - # Set column resize modes - header = asset_types_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Type Name - header.setSectionResizeMode(1, QHeaderView.Stretch) # Description - Stretch - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Color - header.setSectionResizeMode(3, QHeaderView.Stretch) # Examples - Stretch - - # Apply Color Delegate - color_delegate = ColorDelegate(self) - asset_types_table.setItemDelegateForColumn(2, color_delegate) # Column 2 is "Color" - - # TODO: Implement custom delegate for "Examples" later - asset_types_layout.addWidget(asset_types_table) - self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = asset_types_table - - # Asset Types Add/Remove Buttons - asset_types_button_layout = QHBoxLayout() - add_asset_type_button = QPushButton("Add Row") - remove_asset_type_button = QPushButton("Remove Row") - # TODO: Connect button signals later - asset_types_button_layout.addWidget(add_asset_type_button) - asset_types_button_layout.addWidget(remove_asset_type_button) - asset_types_button_layout.addStretch() - asset_types_layout.addLayout(asset_types_button_layout) - - # --- Inner Tab 2: File Types --- - file_types_tab = QWidget() - file_types_layout = QVBoxLayout(file_types_tab) - inner_tab_widget.addTab(file_types_tab, "File Types") - - # File Types Table - file_types_table = QTableWidget() - file_types_table.setColumnCount(6) - file_types_table.setHorizontalHeaderLabels(["Type ID", "Description", "Color", "Examples (comma-sep.)", "Standard Type", "Bit Depth Rule"]) - file_types_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Allow table to expand - # Set column resize modes - header = file_types_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Type ID - header.setSectionResizeMode(1, QHeaderView.Stretch) # Description - Stretch - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Color - header.setSectionResizeMode(3, QHeaderView.Stretch) # Examples - Stretch - header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # Standard Type - header.setSectionResizeMode(5, QHeaderView.ResizeToContents) # Bit Depth Rule - - # Apply Color Delegate (reuse instance or create new) - # color_delegate = ColorDelegate(self) # Reuse if appropriate - file_types_table.setItemDelegateForColumn(2, color_delegate) # Column 2 is "Color" - - # TODO: Implement custom delegates for "Examples", "Standard Type", "Bit Depth Rule" later - file_types_layout.addWidget(file_types_table) - self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = file_types_table - - # File Types Add/Remove Buttons - file_types_button_layout = QHBoxLayout() - add_file_type_button = QPushButton("Add Row") - remove_file_type_button = QPushButton("Remove Row") - # TODO: Connect button signals later - file_types_button_layout.addWidget(add_file_type_button) - file_types_button_layout.addWidget(remove_file_type_button) - file_types_button_layout.addStretch() - file_types_layout.addLayout(file_types_button_layout) + # Inner QTabWidget and its contents (Asset Types and File Types tables) are removed + # as per Phase 1, Item 1 of the refactoring plan. + # The DEFAULT_ASSET_CATEGORY QComboBox remains above, part of overall_layout. layout.addLayout(overall_layout) layout.addStretch() # Keep stretch at the end of the tab's main layout @@ -570,6 +661,7 @@ class ConfigEditorDialog(QDialog): # Clear potentially lingering widget references for this tab self.widgets.pop("MAP_MERGE_RULES_DATA", None) + self.widgets.pop("MERGE_DIMENSION_MISMATCH_STRATEGY", None) # Clear references to the list and details group if they exist if hasattr(self, 'merge_rules_list'): del self.merge_rules_list @@ -581,7 +673,19 @@ class ConfigEditorDialog(QDialog): del self.merge_rule_widgets - # Layout: QHBoxLayout. + top_form_layout = QFormLayout() + + # MERGE_DIMENSION_MISMATCH_STRATEGY: QComboBox + merge_strategy_label = QLabel("Merge Dimension Mismatch Strategy:") + merge_strategy_combo = QComboBox() + merge_strategy_combo.addItems(["USE_LARGEST", "USE_SMALLEST", "ERROR_OUT"]) # Add likely options + merge_strategy_combo.setToolTip("How to handle merging maps of different dimensions.") + top_form_layout.addRow(merge_strategy_label, merge_strategy_combo) + self.widgets["MERGE_DIMENSION_MISMATCH_STRATEGY"] = merge_strategy_combo + + layout.addLayout(top_form_layout) # Add this form layout to the main tab layout + + # Layout: QHBoxLayout for rules list and details. h_layout = QHBoxLayout() layout.addLayout(h_layout) @@ -596,7 +700,8 @@ class ConfigEditorDialog(QDialog): button_layout = QHBoxLayout() add_button = QPushButton("Add Rule") remove_button = QPushButton("Remove Rule") - # TODO: Connect add/remove buttons + add_button.clicked.connect(self.add_merge_rule) + remove_button.clicked.connect(self.remove_merge_rule) button_layout.addWidget(add_button) button_layout.addWidget(remove_button) left_layout.addLayout(button_layout) @@ -612,8 +717,13 @@ class ConfigEditorDialog(QDialog): self.merge_rule_widgets = {} # Widgets for the currently displayed rule if "MAP_MERGE_RULES" in self.settings: - self.populate_merge_rules_list(self.settings["MAP_MERGE_RULES"]) - self.widgets["MAP_MERGE_RULES_DATA"] = self.settings["MAP_MERGE_RULES"] # Store original data reference + # Make a deep copy for local modification if needed, or manage through QListWidgetItems directly + self.current_map_merge_rules = copy.deepcopy(self.settings.get("MAP_MERGE_RULES", [])) + self.populate_merge_rules_list(self.current_map_merge_rules) + # self.widgets["MAP_MERGE_RULES_DATA"] = self.current_map_merge_rules # This will be the list of dicts + else: + self.current_map_merge_rules = [] + self.populate_merge_rules_list([]) # Populate with empty list layout.addStretch() @@ -792,9 +902,22 @@ class ConfigEditorDialog(QDialog): # output_map_type: QLineEdit. Label: "Output Map Type Name". if "output_map_type" in rule_data: label = QLabel("Output Map Type Name:") - widget = QLineEdit(rule_data["output_map_type"]) - self.merge_rule_details_layout.addRow(label, widget) - self.merge_rule_widgets["output_map_type"] = widget + combo_output_map_type = QComboBox() + file_type_keys = list(self.settings.get("FILE_TYPE_DEFINITIONS", {}).keys()) + if not file_type_keys: # Fallback if no keys found + file_type_keys = ["NEW_RULE", rule_data["output_map_type"]] # Add current value as an option + + # Ensure current value is in list, add if not (e.g. for "NEW_RULE") + if rule_data["output_map_type"] not in file_type_keys: + file_type_keys.insert(0, rule_data["output_map_type"]) + + combo_output_map_type.addItems(file_type_keys) + combo_output_map_type.setCurrentText(rule_data["output_map_type"]) + combo_output_map_type.currentIndexChanged.connect( + lambda index, cb=combo_output_map_type: self.update_rule_output_map_type(cb.currentText()) + ) + self.merge_rule_details_layout.addRow(label, combo_output_map_type) + self.merge_rule_widgets["output_map_type"] = combo_output_map_type # inputs: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Input Map Type"). Label: "Channel Inputs". if "inputs" in rule_data and isinstance(rule_data["inputs"], dict): @@ -803,15 +926,22 @@ class ConfigEditorDialog(QDialog): input_table = QTableWidget(4, 2) # R, G, B, A rows, 2 columns input_table.setHorizontalHeaderLabels(["Channel", "Input Map Type"]) input_table.setVerticalHeaderLabels(["R", "G", "B", "A"]) + + file_type_keys_with_none = [""] + list(self.settings.get("FILE_TYPE_DEFINITIONS", {}).keys()) + inputs_delegate = ComboBoxDelegate(items=file_type_keys_with_none, parent=input_table) + input_table.setItemDelegateForColumn(1, inputs_delegate) - # Populate table with current input data channels = ["R", "G", "B", "A"] - for i, channel in enumerate(channels): - input_map_type = rule_data["inputs"].get(channel, "") - input_table.setItem(i, 0, QTableWidgetItem(channel)) - # TODO: Implement custom delegate for "Input Map Type" column (QComboBox) - input_table.setItem(i, 1, QTableWidgetItem(input_map_type)) # Placeholder - + for i, channel_key in enumerate(channels): + input_map_type = rule_data["inputs"].get(channel_key, "") + channel_item = QTableWidgetItem(channel_key) + channel_item.setFlags(channel_item.flags() & ~Qt.ItemIsEditable) # Make channel name not editable + input_table.setItem(i, 0, channel_item) + + map_type_item = QTableWidgetItem(input_map_type) + input_table.setItem(i, 1, map_type_item) + + input_table.itemChanged.connect(lambda item, table=input_table, data_key="inputs": self.update_rule_data_from_table(item, table, data_key)) group_layout.addWidget(input_table) self.merge_rule_details_layout.addRow(group) self.merge_rule_widgets["inputs_table"] = input_table @@ -825,14 +955,20 @@ class ConfigEditorDialog(QDialog): defaults_table.setHorizontalHeaderLabels(["Channel", "Default Value"]) defaults_table.setVerticalHeaderLabels(["R", "G", "B", "A"]) - # Populate table with current default data - channels = ["R", "G", "B", "A"] - for i, channel in enumerate(channels): - default_value = rule_data["defaults"].get(channel, 0.0) - defaults_table.setItem(i, 0, QTableWidgetItem(channel)) - # TODO: Implement custom delegate for "Default Value" column (QDoubleSpinBox) - defaults_table.setItem(i, 1, QTableWidgetItem(str(default_value))) # Placeholder + defaults_delegate = DoubleSpinBoxDelegate(min_val=0.0, max_val=1.0, decimals=3, step=0.01, parent=defaults_table) # Example range + defaults_table.setItemDelegateForColumn(1, defaults_delegate) + channels = ["R", "G", "B", "A"] + for i, channel_key in enumerate(channels): + default_value = rule_data["defaults"].get(channel_key, 0.0 if channel_key != "A" else 1.0) # A defaults to 1.0 + channel_item = QTableWidgetItem(channel_key) + channel_item.setFlags(channel_item.flags() & ~Qt.ItemIsEditable) # Make channel name not editable + defaults_table.setItem(i, 0, channel_item) + + value_item = QTableWidgetItem(str(default_value)) + defaults_table.setItem(i, 1, value_item) + + defaults_table.itemChanged.connect(lambda item, table=defaults_table, data_key="defaults": self.update_rule_data_from_table(item, table, data_key)) group_layout.addWidget(defaults_table) self.merge_rule_details_layout.addRow(group) self.merge_rule_widgets["defaults_table"] = defaults_table @@ -853,6 +989,74 @@ class ConfigEditorDialog(QDialog): self.merge_rule_details_layout.addStretch() + # Connect output_bit_depth QComboBox to update rule data + if "output_bit_depth" in self.merge_rule_widgets and isinstance(self.merge_rule_widgets["output_bit_depth"], QComboBox): + self.merge_rule_widgets["output_bit_depth"].currentTextChanged.connect( + lambda text, key="output_bit_depth": self.update_rule_data_simple_field(text, key) + ) + + + def update_rule_output_map_type(self, new_text): + """Updates the output_map_type in the rule data and QListWidgetItem text.""" + current_list_item = self.merge_rules_list.currentItem() + if current_list_item: + rule_data = current_list_item.data(Qt.UserRole) + if rule_data and isinstance(rule_data, dict): + rule_data["output_map_type"] = new_text + current_list_item.setData(Qt.UserRole, rule_data) # Update the stored data + current_list_item.setText(new_text) # Update the display text in the list + + def update_rule_data_from_table(self, item: QTableWidgetItem, table_widget: QTableWidget, data_key: str): + """Updates the rule data when a table item changes (for inputs or defaults).""" + current_list_item = self.merge_rules_list.currentItem() + if not current_list_item: + return + + rule_data = current_list_item.data(Qt.UserRole) + if not rule_data or not isinstance(rule_data, dict): + return + + row = item.row() + col = item.column() + + if col == 1: # Only update for the value column (Input Map Type or Default Value) + channel_key_item = table_widget.verticalHeaderItem(row) + if not channel_key_item: # Should have vertical headers R,G,B,A + channel_key_item = table_widget.item(row, 0) # Fallback if no vertical header + + if channel_key_item: + channel_key = channel_key_item.text() + new_value = item.text() + + if data_key == "inputs": + if "inputs" not in rule_data or not isinstance(rule_data["inputs"], dict): + rule_data["inputs"] = {} + rule_data["inputs"][channel_key] = new_value + elif data_key == "defaults": + if "defaults" not in rule_data or not isinstance(rule_data["defaults"], dict): + rule_data["defaults"] = {} + try: + rule_data["defaults"][channel_key] = float(new_value) + except ValueError: + # Handle error or revert, for now, just print + print(f"Invalid float value for default: {new_value}") + # Optionally revert item text: item.setText(str(rule_data["defaults"].get(channel_key, 0.0))) + return + + current_list_item.setData(Qt.UserRole, rule_data) # Update the stored data + # print(f"Updated rule data for {channel_key} in {data_key}: {new_value}") # Debug + + def update_rule_data_simple_field(self, new_value, field_key): + """Updates a simple field in the rule data (e.g., output_bit_depth).""" + current_list_item = self.merge_rules_list.currentItem() + if current_list_item: + rule_data = current_list_item.data(Qt.UserRole) + if rule_data and isinstance(rule_data, dict): + rule_data[field_key] = new_value + current_list_item.setData(Qt.UserRole, rule_data) # Update the stored data + # print(f"Updated rule field {field_key} to: {new_value}") # Debug + + def browse_path(self, widget, key, is_dir=False): """Opens a file or directory dialog based on the setting key and is_dir flag.""" if is_dir: @@ -865,6 +1069,159 @@ class ConfigEditorDialog(QDialog): if path: widget.setText(path) + def add_respect_variant_map_type(self): + """Adds a map type to the RESPECT_VARIANT_MAP_TYPES list.""" + # Ensure configuration and file_type_definitions are loaded + if not hasattr(self, 'settings') or "FILE_TYPE_DEFINITIONS" not in self.settings: + QMessageBox.warning(self, "Configuration Error", "File type definitions are not loaded.") + return + + file_type_definitions = self.settings.get("FILE_TYPE_DEFINITIONS", {}) + map_type_keys = list(file_type_definitions.keys()) + if not map_type_keys: + QMessageBox.warning(self, "No Map Types", "No map types available to add.") + return + + item, ok = QInputDialog.getItem(self, "Add Map Type", + "Select map type to add:", map_type_keys, 0, False) + if ok and item: + # Check if item already exists + for i in range(self.respect_variant_list_widget.count()): + if self.respect_variant_list_widget.item(i).text() == item: + QMessageBox.information(self, "Duplicate", f"Map type '{item}' is already in the list.") + return + self.respect_variant_list_widget.addItem(item) + + def remove_respect_variant_map_type(self): + """Removes the selected map type from the RESPECT_VARIANT_MAP_TYPES list.""" + selected_items = self.respect_variant_list_widget.selectedItems() + if not selected_items: + QMessageBox.warning(self, "No Selection", "Please select a map type to remove.") + return + for item in selected_items: + self.respect_variant_list_widget.takeItem(self.respect_variant_list_widget.row(item)) + + def add_merge_rule(self): + """Adds a new default map merge rule.""" + new_rule = { + "output_map_type": "NEW_RULE", + "inputs": {"R": "", "G": "", "B": "", "A": ""}, + "defaults": {"R": 0.0, "G": 0.0, "B": 0.0, "A": 1.0}, + "output_bit_depth": "respect_inputs" + } + + # Add to the internal list that backs the UI + # self.current_map_merge_rules.append(new_rule) # This list is now managed by QListWidgetItems + + item_text = new_rule.get("output_map_type", "Unnamed Rule") + item = QListWidgetItem(item_text) + item.setData(Qt.UserRole, copy.deepcopy(new_rule)) # Store a mutable copy for this item + self.merge_rules_list.addItem(item) + self.merge_rules_list.setCurrentItem(item) # Select the new item to display its details + + def remove_merge_rule(self): + """Removes the currently selected map merge rule.""" + current_item = self.merge_rules_list.currentItem() + if not current_item: + QMessageBox.warning(self, "No Selection", "Please select a rule to remove.") + return + + # No need to manage self.current_map_merge_rules separately if Qt.UserRole is the source of truth + # rule_to_remove = current_item.data(Qt.UserRole) + # if rule_to_remove in self.current_map_merge_rules: + # self.current_map_merge_rules.remove(rule_to_remove) + + row = self.merge_rules_list.row(current_item) + self.merge_rules_list.takeItem(row) + + # Clear details panel or select next/previous + if self.merge_rules_list.count() > 0: + self.merge_rules_list.setCurrentRow(max(0, row -1)) # Select previous or first + else: + self.display_merge_rule_details(None, None) # Clear details if list is empty + + # Ensure this method is defined within the class ConfigEditorDialog + def add_image_resolution_row(self): + """Adds a new row to the IMAGE_RESOLUTIONS table after prompting the user.""" + table = self.widgets.get("IMAGE_RESOLUTIONS_TABLE") + if not table: + return + + name, ok_name = QInputDialog.getText(self, "Add Resolution", "Enter Name (e.g., 16K):") + if not ok_name or not name.strip(): + if ok_name and not name.strip(): # User pressed OK but entered empty name + QMessageBox.warning(self, "Invalid Input", "Name cannot be empty.") + return # User cancelled or entered empty name + + # Check for duplicate name + for r in range(table.rowCount()): + if table.item(r, 0) and table.item(r, 0).text() == name: + QMessageBox.warning(self, "Duplicate Name", f"The resolution name '{name}' already exists.") + return + + resolution, ok_res = QInputDialog.getInt(self, "Add Resolution", "Enter Resolution (px):", 1024, 1, 65536, 1) + if not ok_res: + return # User cancelled + + row_position = table.rowCount() + table.insertRow(row_position) + table.setItem(row_position, 0, QTableWidgetItem(name)) + table.setItem(row_position, 1, QTableWidgetItem(str(resolution))) + + self._update_resolution_dependent_combos() + + def remove_image_resolution_row(self): + """Removes the selected row(s) from the IMAGE_RESOLUTIONS table.""" + table = self.widgets.get("IMAGE_RESOLUTIONS_TABLE") + if not table: + return + + selected_rows = sorted(list(set(index.row() for index in table.selectedIndexes())), reverse=True) + if not selected_rows: + QMessageBox.warning(self, "No Selection", "Please select row(s) to remove.") + return + + for row in selected_rows: + table.removeRow(row) + + self._update_resolution_dependent_combos() + + def _update_resolution_dependent_combos(self): + """Updates ComboBoxes that depend on IMAGE_RESOLUTIONS.""" + table = self.widgets.get("IMAGE_RESOLUTIONS_TABLE") + stats_combo = self.widgets.get("CALCULATE_STATS_RESOLUTION") + jpg_threshold_combo = self.widgets.get("RESOLUTION_THRESHOLD_FOR_JPG") + + if not table or (not stats_combo and not jpg_threshold_combo): + return + + current_stats_selection = stats_combo.currentText() if stats_combo else None + current_jpg_threshold_selection = jpg_threshold_combo.currentText() if jpg_threshold_combo else None + + resolution_names = [] + for r in range(table.rowCount()): + name_item = table.item(r, 0) + if name_item and name_item.text(): + resolution_names.append(name_item.text()) + + if stats_combo: + stats_combo.clear() + stats_combo.addItems(resolution_names) + if current_stats_selection in resolution_names: + stats_combo.setCurrentText(current_stats_selection) + elif resolution_names: # Select first item if previous selection is gone + stats_combo.setCurrentIndex(0) + + if jpg_threshold_combo: + jpg_threshold_combo.clear() + jpg_threshold_options = ["Never", "Always"] + resolution_names + jpg_threshold_combo.addItems(jpg_threshold_options) + if current_jpg_threshold_selection in jpg_threshold_options: + jpg_threshold_combo.setCurrentText(current_jpg_threshold_selection) + elif jpg_threshold_options: # Select first item if previous selection is gone + jpg_threshold_combo.setCurrentIndex(0) + + def pick_color(self, widget): """Opens a color dialog and sets the selected color in the widget.""" color = QColorDialog.getColor(QColor(widget.text())) @@ -878,9 +1235,6 @@ class ConfigEditorDialog(QDialog): other existing user settings. """ # 1a. Load Current Target File (user_settings.json) - # Assuming configuration.py and this file are structured such that - # 'config/user_settings.json' is the correct relative path from the workspace root. - # TODO: Ideally, get this path from the configuration module if it provides a constant. user_settings_path = os.path.join("config", "user_settings.json") target_file_content = {} @@ -900,134 +1254,96 @@ class ConfigEditorDialog(QDialog): target_file_content = {} # 1b. Get current settings from UI by populating a full settings dictionary - # This `full_ui_state` will mirror the structure of `self.settings` but with current UI values. full_ui_state = copy.deepcopy(self.settings) # Start with the loaded settings structure - # --- Populate full_ui_state from ALL widgets (adapted from original save_settings logic) --- - # This loop iterates through all widgets and updates `full_ui_state` with their current values. - # It handles simple widgets and complex ones like tables (though table data for definitions - # won't end up in user_settings.json). + # --- Populate full_ui_state from ALL widgets --- for widget_config_key, widget_obj in self.widgets.items(): - # `widget_config_key` is the key used in `self.widgets` (e.g., "OUTPUT_BASE_DIR", "IMAGE_RESOLUTIONS_TABLE") - # `keys_path` is for navigating the `full_ui_state` dictionary if `widget_config_key` implies a path. - # For most simple widgets, `widget_config_key` is a direct top-level key. keys_path = widget_config_key.split('.') current_level_dict = full_ui_state - - # Navigate to the correct dictionary level if widget_config_key is a path like "general.foo" - # For simple keys like "OUTPUT_BASE_DIR", this loop runs once for the key itself. for i, part_of_key in enumerate(keys_path): - if i == len(keys_path) - 1: # Last part of the key, time to set the value - # Handle simple widgets + if i == len(keys_path) - 1: if isinstance(widget_obj, QLineEdit): - if widget_config_key == "RESPECT_VARIANT_MAP_TYPES": # Special list handling - current_level_dict[part_of_key] = [item.strip() for item in widget_obj.text().split(',') if item.strip()] - else: - current_level_dict[part_of_key] = widget_obj.text() + current_level_dict[part_of_key] = widget_obj.text() elif isinstance(widget_obj, QSpinBox): current_level_dict[part_of_key] = widget_obj.value() elif isinstance(widget_obj, QDoubleSpinBox): current_level_dict[part_of_key] = widget_obj.value() elif isinstance(widget_obj, QCheckBox): - current_level_dict[part_of_key] = widget_obj.isChecked() + if widget_config_key == "general_settings.invert_normal_map_green_channel_globally": + if 'general_settings' not in full_ui_state: + full_ui_state['general_settings'] = {} + full_ui_state['general_settings']['invert_normal_map_green_channel_globally'] = widget_obj.isChecked() + else: + current_level_dict[part_of_key] = widget_obj.isChecked() + elif isinstance(widget_obj, QListWidget) and widget_config_key == "RESPECT_VARIANT_MAP_TYPES": + items = [widget_obj.item(i_item).text() for i_item in range(widget_obj.count())] + current_level_dict[part_of_key] = items elif isinstance(widget_obj, QComboBox): if widget_config_key == "RESOLUTION_THRESHOLD_FOR_JPG": selected_text = widget_obj.currentText() - # Use image_resolutions from the potentially modified full_ui_state image_resolutions_data = full_ui_state.get('IMAGE_RESOLUTIONS', {}) if selected_text == "Never": current_level_dict[part_of_key] = 999999 elif selected_text == "Always": current_level_dict[part_of_key] = 1 - elif isinstance(image_resolutions_data, list): # Check if it's the list of [name, val] - found_res = next((res[1] for res in image_resolutions_data if res[0] == selected_text), None) - if found_res is not None: current_level_dict[part_of_key] = found_res - else: current_level_dict[part_of_key] = selected_text # Fallback - elif isinstance(image_resolutions_data, dict) and selected_text in image_resolutions_data: # Original format + elif isinstance(image_resolutions_data, dict) and selected_text in image_resolutions_data: current_level_dict[part_of_key] = image_resolutions_data[selected_text] else: current_level_dict[part_of_key] = selected_text # Fallback else: current_level_dict[part_of_key] = widget_obj.currentText() - - # Handle TableWidgets - only for those relevant to user_settings.json - # ASSET_TYPE_DEFINITIONS and FILE_TYPE_DEFINITIONS are handled by their own files. - # IMAGE_RESOLUTIONS and MAP_MERGE_RULES might be in user_settings.json. elif widget_config_key == "IMAGE_RESOLUTIONS_TABLE" and isinstance(widget_obj, QTableWidget): table = widget_obj - resolutions_list = [] + resolutions_dict = {} for row in range(table.rowCount()): name_item = table.item(row, 0) res_item = table.item(row, 1) if name_item and name_item.text() and res_item and res_item.text(): + name = name_item.text() try: - # Assuming resolution value might be int or string like "1024" or "1k" - # For simplicity, store as string if not easily int. - # The config system should handle parsing later. - res_val_str = res_item.text() - try: - res_value = int(res_val_str) - except ValueError: - res_value = res_val_str # Keep as string if not simple int - resolutions_list.append([name_item.text(), res_value]) - except Exception as e: - print(f"Skipping resolution row {row} due to error: {e}") - full_ui_state['IMAGE_RESOLUTIONS'] = resolutions_list # Key in settings is IMAGE_RESOLUTIONS - - # MAP_MERGE_RULES are complex; current save logic is a pass-through. - # For granular save, if MAP_MERGE_RULES are edited, the full updated list should be in full_ui_state. - # The original code had a placeholder for MAP_MERGE_RULES_DATA. - # Assuming if MAP_MERGE_RULES are part of user_settings, their full structure from UI - # would be placed into full_ui_state['MAP_MERGE_RULES']. - # The current implementation of populate_map_merging_tab and display_merge_rule_details - # would need to ensure that `full_ui_state['MAP_MERGE_RULES']` is correctly updated - # if changes are made via the UI. The original save logic for this was: - # `elif key == "MAP_MERGE_RULES_DATA": pass` - # This means `full_ui_state['MAP_MERGE_RULES']` would retain its original loaded value unless - # other UI interactions (like Add/Remove Rule buttons, if implemented and connected) modify it. - # For now, we assume `full_ui_state['MAP_MERGE_RULES']` reflects the intended UI state. - - else: # Navigate deeper + resolutions_dict[name] = int(res_item.text()) + except ValueError: + print(f"Warning: Resolution value '{res_item.text()}' for '{name}' is not an integer. Skipping.") + full_ui_state['IMAGE_RESOLUTIONS'] = resolutions_dict + else: if part_of_key not in current_level_dict or not isinstance(current_level_dict[part_of_key], dict): - # This case should ideally not happen if widget keys match settings structure - # or if the base `full_ui_state` (from `self.settings`) had the correct structure. - # If a path implies a dict that doesn't exist, create it. current_level_dict[part_of_key] = {} current_level_dict = current_level_dict[part_of_key] + + # Special handling for MAP_MERGE_RULES - build from QListWidget items + if hasattr(self, 'merge_rules_list'): + updated_merge_rules = [] + for i in range(self.merge_rules_list.count()): + item = self.merge_rules_list.item(i) + rule_data = item.data(Qt.UserRole) + if rule_data: + updated_merge_rules.append(copy.deepcopy(rule_data)) # Add a copy to avoid issues if UserRole is reused + full_ui_state['MAP_MERGE_RULES'] = updated_merge_rules + # --- End of populating full_ui_state --- # 2. Identify Changes by comparing with self.original_user_configurable_settings changed_settings_count = 0 for key_to_check, original_value in self.original_user_configurable_settings.items(): - # `key_to_check` is a top-level key from the user-configurable settings - # (e.g., "OUTPUT_BASE_DIR", "PNG_COMPRESSION_LEVEL", "IMAGE_RESOLUTIONS"). current_value_from_ui = full_ui_state.get(key_to_check) - - # Perform comparison. Python's default `!=` works for deep comparison - # of basic types, lists, and dicts if order doesn't matter for lists - # where it shouldn't (e.g. list of strings) or if dicts are canonical. if current_value_from_ui != original_value: - # This setting has changed. Update it in target_file_content. - # This replaces the whole value for `key_to_check` in user_settings. target_file_content[key_to_check] = copy.deepcopy(current_value_from_ui) changed_settings_count += 1 print(f"Setting '{key_to_check}' changed. Old: {original_value}, New: {current_value_from_ui}") - # 3. Save Updated Content to user_settings.json if changed_settings_count > 0 or not os.path.exists(user_settings_path): - # Save if there are changes or if the file didn't exist (to create it with defaults if any were set) try: - save_user_config(target_file_content) # save_user_config is imported + save_user_config(target_file_content) QMessageBox.information(self, "Settings Saved", f"User settings saved successfully to {user_settings_path}.\n" f"{changed_settings_count} setting(s) updated. " "Some changes may require an application restart.") - self.accept() # Close the dialog + self.accept() except ConfigurationError as e: QMessageBox.critical(self, "Saving Error", f"Failed to save user configuration: {e}") except Exception as e: QMessageBox.critical(self, "Saving Error", f"An unexpected error occurred while saving: {e}") else: QMessageBox.information(self, "No Changes", "No changes were made to user-configurable settings.") - self.accept() # Close the dialog, or self.reject() if no changes means cancel + self.accept() def populate_widgets_from_settings(self): """Populates the created widgets with loaded settings.""" @@ -1036,13 +1352,24 @@ class ConfigEditorDialog(QDialog): for key, value in self.settings.items(): # Handle simple settings directly if they have a corresponding widget - if key in self.widgets and isinstance(self.widgets[key], (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox)): + if key == "general_settings": # Handle nested dictionary + if isinstance(value, dict): + for sub_key, sub_value in value.items(): + widget_full_key = f"{key}.{sub_key}" + if widget_full_key in self.widgets: + widget = self.widgets[widget_full_key] + if isinstance(widget, QCheckBox) and isinstance(sub_value, bool): + widget.setChecked(sub_value) + elif key in self.widgets and isinstance(self.widgets[key], (QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox, QListWidget)): # Added QListWidget widget = self.widgets[key] - if isinstance(widget, QLineEdit): - # Handle simple lists displayed as comma-separated strings - if key == "RESPECT_VARIANT_MAP_TYPES" and isinstance(value, list): - widget.setText(", ".join(map(str, value))) - elif isinstance(value, (str, int, float, bool)): # Also handle cases where simple types might be in QLineEdit + if key == "RESPECT_VARIANT_MAP_TYPES" and isinstance(widget, QListWidget): + widget.clear() + if isinstance(value, list): + for item_text in value: # value is the list of strings from settings + widget.addItem(str(item_text)) + elif isinstance(widget, QLineEdit): + # This case should not be hit for RESPECT_VARIANT_MAP_TYPES anymore + if isinstance(value, (str, int, float, bool)): widget.setText(str(value)) elif isinstance(widget, QSpinBox) and isinstance(value, int): widget.setValue(value) @@ -1062,17 +1389,33 @@ class ConfigEditorDialog(QDialog): self.populate_file_type_definitions_table(self.widgets["FILE_TYPE_DEFINITIONS_TABLE"], value) elif key == "IMAGE_RESOLUTIONS" and "IMAGE_RESOLUTIONS_TABLE" in self.widgets: self.populate_image_resolutions_table(self.widgets["IMAGE_RESOLUTIONS_TABLE"], value) - # Populate ComboBoxes that depend on Image Resolutions - resolution_names = [self.widgets["IMAGE_RESOLUTIONS_TABLE"].item(i, 0).text() for i in range(self.widgets["IMAGE_RESOLUTIONS_TABLE"].rowCount())] - if "CALCULATE_STATS_RESOLUTION" in self.widgets: - self.widgets["CALCULATE_STATS_RESOLUTION"].addItems(resolution_names) - if key in self.settings and self.settings["CALCULATE_STATS_RESOLUTION"] in resolution_names: - self.widgets["CALCULATE_STATS_RESOLUTION"].setCurrentText(self.settings["CALCULATE_STATS_RESOLUTION"]) - if "RESOLUTION_THRESHOLD_FOR_JPG" in self.widgets: - jpg_threshold_options = ["Never", "Always"] + resolution_names - self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].addItems(jpg_threshold_options) - if key in self.settings and self.settings["RESOLUTION_THRESHOLD_FOR_JPG"] in jpg_threshold_options: - self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].setCurrentText(self.settings["RESOLUTION_THRESHOLD_FOR_JPG"]) + # Populate ComboBoxes that depend on Image Resolutions - now handled by _update_resolution_dependent_combos + # Call it here to ensure initial population is correct after table is filled. + self._update_resolution_dependent_combos() + # Restore original selection if possible + if "CALCULATE_STATS_RESOLUTION" in self.settings and self.widgets.get("CALCULATE_STATS_RESOLUTION"): + if self.settings["CALCULATE_STATS_RESOLUTION"] in [self.widgets["CALCULATE_STATS_RESOLUTION"].itemText(i) for i in range(self.widgets["CALCULATE_STATS_RESOLUTION"].count())]: + self.widgets["CALCULATE_STATS_RESOLUTION"].setCurrentText(self.settings["CALCULATE_STATS_RESOLUTION"]) + + if "RESOLUTION_THRESHOLD_FOR_JPG" in self.settings and self.widgets.get("RESOLUTION_THRESHOLD_FOR_JPG"): + # Map stored integer value back to text for selection + stored_jpg_threshold_val = self.settings["RESOLUTION_THRESHOLD_FOR_JPG"] + current_text_selection = None + if isinstance(stored_jpg_threshold_val, int): + if stored_jpg_threshold_val == 999999: current_text_selection = "Never" + elif stored_jpg_threshold_val == 1: current_text_selection = "Always" + else: # Try to find by value in the resolutions + res_table = self.widgets["IMAGE_RESOLUTIONS_TABLE"] + for r_idx in range(res_table.rowCount()): + if res_table.item(r_idx, 1) and int(res_table.item(r_idx, 1).text()) == stored_jpg_threshold_val: + current_text_selection = res_table.item(r_idx, 0).text() + break + elif isinstance(stored_jpg_threshold_val, str): # If it was already a name + current_text_selection = stored_jpg_threshold_val + + if current_text_selection and current_text_selection in [self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].itemText(i) for i in range(self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].count())]: + self.widgets["RESOLUTION_THRESHOLD_FOR_JPG"].setCurrentText(current_text_selection) + elif key == "MAP_BIT_DEPTH_RULES" and "MAP_BIT_DEPTH_RULES_TABLE" in self.widgets: self.populate_map_bit_depth_rules_table(self.widgets["MAP_BIT_DEPTH_RULES_TABLE"], value) @@ -1158,30 +1501,28 @@ class ConfigEditorDialog(QDialog): row += 1 - def populate_image_resolutions_table(self, table: QTableWidget, resolutions_data: list): - """Populates the image resolutions table.""" + def populate_image_resolutions_table(self, table: QTableWidget, resolutions_data: dict): + """Populates the image resolutions table from a dictionary.""" + table.setRowCount(0) # Clear existing rows before populating table.setRowCount(len(resolutions_data)) - for row, resolution in enumerate(resolutions_data): - # Assuming resolution is a list/tuple like [name, resolution_string] + row = 0 + for name, resolution_value in resolutions_data.items(): try: - if isinstance(resolution, (list, tuple)) and len(resolution) == 2: - name = str(resolution[0]) - # Attempt to convert resolution value to string, handle potential errors - try: - res_value = str(resolution[1]) - except Exception: - res_value = "Error: Invalid Value" - table.setItem(row, 0, QTableWidgetItem(name)) - table.setItem(row, 1, QTableWidgetItem(res_value)) - else: - # Handle unexpected format more clearly - table.setItem(row, 0, QTableWidgetItem(str(resolution))) - table.setItem(row, 1, QTableWidgetItem("Error: Invalid Format")) + name_item = QTableWidgetItem(str(name)) + res_item = QTableWidgetItem(str(resolution_value)) + + # Make items editable for Phase 1 (actual editing will be improved in Phase 3) + name_item.setFlags(name_item.flags() | Qt.ItemIsEditable) + res_item.setFlags(res_item.flags() | Qt.ItemIsEditable) + + table.setItem(row, 0, name_item) + table.setItem(row, 1, res_item) except Exception as e: - # Catch any other unexpected errors during processing - print(f"Error populating resolution row {row}: {e}") - table.setItem(row, 0, QTableWidgetItem("Error")) + print(f"Error populating resolution row for '{name}': {e}") + # Optionally add a row indicating error + table.setItem(row, 0, QTableWidgetItem(str(name))) table.setItem(row, 1, QTableWidgetItem(f"Error: {e}")) + row += 1 def populate_map_bit_depth_rules_table(self, table: QTableWidget, rules_data: dict): diff --git a/gui/definitions_editor_dialog.py b/gui/definitions_editor_dialog.py new file mode 100644 index 0000000..56c146d --- /dev/null +++ b/gui/definitions_editor_dialog.py @@ -0,0 +1,1068 @@ +import logging +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QTabWidget, QWidget, QListWidget, QPushButton, + QHBoxLayout, QLabel, QGroupBox, QDialogButtonBox, QFormLayout, + QTextEdit, QColorDialog, QInputDialog, QMessageBox, QFrame, QComboBox, + QLineEdit, QCheckBox +) +from PySide6.QtGui import QColor, QPalette +from PySide6.QtCore import Qt + +# Assuming load_asset_definitions, load_file_type_definitions, load_supplier_settings +# are in configuration.py at the root level. +# Adjust the import path if configuration.py is located elsewhere relative to this file. +# For example, if configuration.py is in the parent directory: +# from ..configuration import load_asset_definitions, load_file_type_definitions, load_supplier_settings +# Or if it's in the same directory (less likely for a root config file): +# from .configuration import ... +# Given the project structure, configuration.py is at the root. +import sys +import os +# Add project root to sys.path to allow direct import of configuration +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +try: + from configuration import ( + load_asset_definitions, save_asset_definitions, + load_file_type_definitions, save_file_type_definitions, + load_supplier_settings, save_supplier_settings + ) +except ImportError as e: + logging.error(f"Failed to import configuration functions: {e}. Ensure configuration.py is in the project root and accessible.") + # Provide dummy functions if import fails, so the UI can still be tested somewhat + def load_asset_definitions(): return {} + def save_asset_definitions(data): pass + def load_file_type_definitions(): return {} + def save_file_type_definitions(data): pass + def load_supplier_settings(): return {} + # def save_supplier_settings(data): pass + +logger = logging.getLogger(__name__) + +class DefinitionsEditorDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Definitions Editor") + self.setGeometry(200, 200, 800, 600) # x, y, width, height + + self.asset_type_data = {} + self.file_type_data = {} + self.supplier_data = {} + self.unsaved_changes = False # For unsaved changes tracking + + self._load_all_definitions() + + main_layout = QVBoxLayout(self) + + self.tab_widget = QTabWidget() + main_layout.addWidget(self.tab_widget) + + self._create_ui() # Creates and adds tabs to self.tab_widget + + self.button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + self.button_box.accepted.connect(self.save_definitions) + self.button_box.rejected.connect(self.reject) + main_layout.addWidget(self.button_box) + + self.setLayout(main_layout) + + def _load_all_definitions(self): + logger.info("Loading all definitions...") + try: + self.asset_type_data = load_asset_definitions() + logger.info(f"Loaded {len(self.asset_type_data)} asset type definitions.") + except Exception as e: + logger.error(f"Failed to load asset type definitions: {e}") + self.asset_type_data = {} # Ensure it's an empty dict on failure + + try: + self.file_type_data = load_file_type_definitions() + logger.info(f"Loaded {len(self.file_type_data)} file type definitions.") + except Exception as e: + logger.error(f"Failed to load file type definitions: {e}") + self.file_type_data = {} + + try: + self.supplier_data = load_supplier_settings() + logger.info(f"Loaded {len(self.supplier_data)} supplier settings.") + except Exception as e: + logger.error(f"Failed to load supplier settings: {e}") + self.supplier_data = {} + logger.info("Finished loading definitions.") + + + def _create_ui(self): + self.tab_widget.addTab(self._create_asset_types_tab(), "Asset Type Definitions") + self.tab_widget.addTab(self._create_file_types_tab(), "File Type Definitions") + self.tab_widget.addTab(self._create_suppliers_tab(), "Supplier Settings") + + def _create_tab_pane(self, title_singular, data_dict, list_widget_name): + tab_page = QWidget() + tab_layout = QHBoxLayout(tab_page) + + # Left Pane + left_pane_layout = QVBoxLayout() + + lbl_list_title = QLabel(f"{title_singular}s:") + left_pane_layout.addWidget(lbl_list_title) + + list_widget = QListWidget() + setattr(self, list_widget_name, list_widget) # e.g., self.asset_type_list_widget = list_widget + if isinstance(data_dict, dict): + for key, value_dict in data_dict.items(): # Iterate over items for UserRole data + item = QListWidgetItem(key) + item.setData(Qt.UserRole, value_dict) # Store the whole dict + list_widget.addItem(item) + else: + logger.warning(f"Data for {title_singular} is not a dictionary, cannot populate list.") + + left_pane_layout.addWidget(list_widget) + + buttons_layout = QHBoxLayout() + btn_add = QPushButton(f"Add {title_singular}") + btn_remove = QPushButton(f"Remove Selected {title_singular}") + + # Connections for these buttons will be specific to each tab type + if list_widget_name == "asset_type_list_widget": + btn_add.clicked.connect(self._add_asset_type) + btn_remove.clicked.connect(self._remove_asset_type) + list_widget.currentItemChanged.connect(self._display_asset_type_details) + elif list_widget_name == "file_type_list_widget": + btn_add.clicked.connect(self._add_file_type) + btn_remove.clicked.connect(self._remove_file_type) + list_widget.currentItemChanged.connect(self._display_file_type_details) + elif list_widget_name == "supplier_list_widget": # Connections for Supplier tab + btn_add.clicked.connect(self._add_supplier) + btn_remove.clicked.connect(self._remove_supplier) + list_widget.currentItemChanged.connect(self._display_supplier_details) + + buttons_layout.addWidget(btn_add) + buttons_layout.addWidget(btn_remove) + left_pane_layout.addLayout(buttons_layout) + + tab_layout.addLayout(left_pane_layout, 1) # 1 part for left pane + + # Right Pane - This will be customized by specific tab creation methods + right_pane_widget = QWidget() # Create a generic widget to be returned + tab_layout.addWidget(right_pane_widget, 2) # 2 parts for right pane + + tab_page.setLayout(tab_layout) + return tab_page, right_pane_widget # Return the pane for customization + + def _create_asset_types_tab(self): + tab_page, right_pane_container = self._create_tab_pane("Asset Type", self.asset_type_data, "asset_type_list_widget") + + # Customize the right pane for Asset Types + right_pane_groupbox = QGroupBox("Details for Selected Asset Type") + details_layout = QFormLayout(right_pane_groupbox) + + # Description + self.asset_description_edit = QTextEdit() + details_layout.addRow("Description:", self.asset_description_edit) + + # Color + color_layout = QHBoxLayout() + self.asset_color_swatch_label = QLabel() + self.asset_color_swatch_label.setFixedSize(20, 20) + self.asset_color_swatch_label.setAutoFillBackground(True) + self._update_color_swatch("#ffffff") # Default color + + btn_choose_color = QPushButton("Choose Color...") + btn_choose_color.clicked.connect(self._choose_asset_color) + color_layout.addWidget(self.asset_color_swatch_label) + color_layout.addWidget(btn_choose_color) + color_layout.addStretch() + details_layout.addRow("Color:", color_layout) + + # Examples + examples_group = QGroupBox("Examples") + examples_layout = QVBoxLayout(examples_group) + + self.asset_examples_list_widget = QListWidget() + examples_layout.addWidget(self.asset_examples_list_widget) + + example_buttons_layout = QHBoxLayout() + btn_add_example = QPushButton("Add Example") + btn_remove_example = QPushButton("Remove Selected Example") + btn_add_example.clicked.connect(self._add_asset_example) + btn_remove_example.clicked.connect(self._remove_asset_example) + example_buttons_layout.addWidget(btn_add_example) + example_buttons_layout.addWidget(btn_remove_example) + examples_layout.addLayout(example_buttons_layout) + + details_layout.addRow(examples_group) + + # Replace the generic right_pane_widget with our specific groupbox + # To do this, we need to find the layout of right_pane_container's parent (which is tab_layout) + # and replace the widget. + parent_layout = right_pane_container.parentWidget().layout() + if parent_layout: + parent_layout.replaceWidget(right_pane_container, right_pane_groupbox) + right_pane_container.deleteLater() # Remove the placeholder + + # Connect signals for editing + self.asset_description_edit.textChanged.connect(self._on_asset_detail_changed) + + # Initial population of list widget (if not already done by _create_tab_pane) + # and display details for the first item if any. + self._populate_asset_type_list() # Ensure data is loaded with UserRole + if self.asset_type_list_widget.count() > 0: + self.asset_type_list_widget.setCurrentRow(0) + # self._display_asset_type_details(self.asset_type_list_widget.currentItem()) # Already connected + + return tab_page + + def _populate_asset_type_list(self): + self.asset_type_list_widget.clear() + for key, asset_data_item in self.asset_type_data.items(): + item = QListWidgetItem(key) + # Ensure asset_data_item is a dictionary, if not, create a default one + if not isinstance(asset_data_item, dict): + logger.warning(f"Asset data for '{key}' is not a dict: {asset_data_item}. Using default.") + asset_data_item = {"description": str(asset_data_item), "color": "#ffffff", "examples": []} + + # Ensure essential keys exist + asset_data_item.setdefault('description', '') + asset_data_item.setdefault('color', '#ffffff') + asset_data_item.setdefault('examples', []) + + item.setData(Qt.UserRole, asset_data_item) + self.asset_type_list_widget.addItem(item) + + def _display_asset_type_details(self, current_item, previous_item=None): + # Disconnect signals temporarily to prevent feedback loops during population + if hasattr(self, 'asset_description_edit'): + try: + self.asset_description_edit.textChanged.disconnect(self._on_asset_detail_changed) + except TypeError: # Signal not connected + pass + + if current_item: + asset_data = current_item.data(Qt.UserRole) + if not isinstance(asset_data, dict): # Should not happen if _populate is correct + logger.error(f"Invalid data for item {current_item.text()}. Expected dict, got {type(asset_data)}") + asset_data = {"description": "Error: Invalid data", "color": "#ff0000", "examples": []} + + self.asset_description_edit.setText(asset_data.get('description', '')) + + color_hex = asset_data.get('color', '#ffffff') + self._update_color_swatch(color_hex) + + self.asset_examples_list_widget.clear() + for example in asset_data.get('examples', []): + self.asset_examples_list_widget.addItem(example) + else: + # Clear details if no item is selected + self.asset_description_edit.clear() + self._update_color_swatch("#ffffff") + self.asset_examples_list_widget.clear() + + # Reconnect signals + if hasattr(self, 'asset_description_edit'): + self.asset_description_edit.textChanged.connect(self._on_asset_detail_changed) + + def _update_color_swatch(self, color_hex): + if hasattr(self, 'asset_color_swatch_label'): + palette = self.asset_color_swatch_label.palette() + palette.setColor(QPalette.Background, QColor(color_hex)) + self.asset_color_swatch_label.setPalette(palette) + + def _choose_asset_color(self): + current_item = self.asset_type_list_widget.currentItem() + if not current_item: + return + + asset_data = current_item.data(Qt.UserRole) + initial_color = QColor(asset_data.get('color', '#ffffff')) + + color = QColorDialog.getColor(initial_color, self, "Choose Asset Type Color") + if color.isValid(): + color_hex = color.name() + self._update_color_swatch(color_hex) + asset_data['color'] = color_hex + current_item.setData(Qt.UserRole, asset_data) # Update data in item + self.unsaved_changes = True + # No need to call _on_asset_detail_changed explicitly for color, direct update is fine + + def _on_asset_detail_changed(self): + current_item = self.asset_type_list_widget.currentItem() + if not current_item: + return + + asset_data = current_item.data(Qt.UserRole) + if not isinstance(asset_data, dict): return # Should not happen + + # Update description + asset_data['description'] = self.asset_description_edit.toPlainText() + + # Examples are handled by their own add/remove buttons + # Color is handled by _choose_asset_color + + current_item.setData(Qt.UserRole, asset_data) # Save changes back to the item's data + self.unsaved_changes = True + + def _add_asset_type(self): + new_name, ok = QInputDialog.getText(self, "Add Asset Type", "Enter name for the new asset type:") + if ok and new_name: + if new_name in self.asset_type_data: + QMessageBox.warning(self, "Name Exists", f"An asset type named '{new_name}' already exists.") + return + + default_asset_type = { + "description": "", + "color": "#ffffff", + "examples": [] + } + self.asset_type_data[new_name] = default_asset_type + + item = QListWidgetItem(new_name) + item.setData(Qt.UserRole, default_asset_type) # Store a copy + self.asset_type_list_widget.addItem(item) + self.asset_type_list_widget.setCurrentItem(item) # Triggers _display_asset_type_details + logger.info(f"Added new asset type: {new_name}") + self.unsaved_changes = True + elif ok and not new_name: + QMessageBox.warning(self, "Invalid Name", "Asset type name cannot be empty.") + + def _remove_asset_type(self): + current_item = self.asset_type_list_widget.currentItem() + if not current_item: + QMessageBox.information(self, "No Selection", "Please select an asset type to remove.") + return + + asset_name = current_item.text() + reply = QMessageBox.question(self, "Confirm Removal", + f"Are you sure you want to remove the asset type '{asset_name}'?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + if asset_name in self.asset_type_data: + del self.asset_type_data[asset_name] + + row = self.asset_type_list_widget.row(current_item) + self.asset_type_list_widget.takeItem(row) + logger.info(f"Removed asset type: {asset_name}") + self.unsaved_changes = True + + if self.asset_type_list_widget.count() > 0: + new_row_to_select = max(0, row - 1) if row > 0 else 0 + if self.asset_type_list_widget.count() > new_row_to_select: # Ensure new_row_to_select is valid + self.asset_type_list_widget.setCurrentRow(new_row_to_select) + else: # if list becomes empty or selection is out of bounds + self._display_asset_type_details(None, None) + else: + self._display_asset_type_details(None, None) # Clear details if list is empty + + def _add_asset_example(self): + current_asset_item = self.asset_type_list_widget.currentItem() + if not current_asset_item: + QMessageBox.information(self, "No Asset Type Selected", "Please select an asset type first.") + return + + new_example, ok = QInputDialog.getText(self, "Add Example", "Enter new example string:") + if ok and new_example: + asset_data = current_asset_item.data(Qt.UserRole) + if not isinstance(asset_data, dict) or 'examples' not in asset_data: + logger.error("Asset data is not a dict or 'examples' key is missing.") + QMessageBox.critical(self, "Error", "Internal data error for selected asset type.") + return + + if not isinstance(asset_data['examples'], list): # Ensure 'examples' is a list + asset_data['examples'] = [] + + asset_data['examples'].append(new_example) + current_asset_item.setData(Qt.UserRole, asset_data) # Update data in item + + self.asset_examples_list_widget.addItem(new_example) + logger.info(f"Added example '{new_example}' to asset type '{current_asset_item.text()}'") + self.unsaved_changes = True + elif ok and not new_example: + QMessageBox.warning(self, "Invalid Example", "Example string cannot be empty.") + + def _remove_asset_example(self): + current_asset_item = self.asset_type_list_widget.currentItem() + if not current_asset_item: + QMessageBox.information(self, "No Asset Type Selected", "Please select an asset type first.") + return + + current_example_item = self.asset_examples_list_widget.currentItem() + if not current_example_item: + QMessageBox.information(self, "No Example Selected", "Please select an example to remove.") + return + + example_text = current_example_item.text() + + # No confirmation needed as per typical list item removal, but can be added if desired. + # reply = QMessageBox.question(self, "Confirm Removal", + # f"Are you sure you want to remove the example '{example_text}'?", + # QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + # if reply == QMessageBox.No: + # return + + asset_data = current_asset_item.data(Qt.UserRole) + if not isinstance(asset_data, dict) or 'examples' not in asset_data or not isinstance(asset_data['examples'], list): + logger.error("Asset data issue during example removal.") + QMessageBox.critical(self, "Error", "Internal data error for selected asset type.") + return + + try: + asset_data['examples'].remove(example_text) + current_asset_item.setData(Qt.UserRole, asset_data) # Update data in item + + row = self.asset_examples_list_widget.row(current_example_item) + self.asset_examples_list_widget.takeItem(row) + logger.info(f"Removed example '{example_text}' from asset type '{current_asset_item.text()}'") + self.unsaved_changes = True + except ValueError: + logger.warning(f"Example '{example_text}' not found in internal list for asset '{current_asset_item.text()}'. UI might be out of sync.") + # Still remove from UI if it was there + row = self.asset_examples_list_widget.row(current_example_item) + if row >=0: self.asset_examples_list_widget.takeItem(row) + + + def _update_file_type_color_swatch(self, color_hex, swatch_label): + if hasattr(self, swatch_label_name): # Check if the specific swatch label exists + palette = swatch_label.palette() + palette.setColor(QPalette.Background, QColor(color_hex)) + swatch_label.setPalette(palette) + + def _create_file_types_tab(self): + tab_page, right_pane_container = self._create_tab_pane("File Type", self.file_type_data, "file_type_list_widget") + + right_pane_groupbox = QGroupBox("Details for Selected File Type") + details_layout = QFormLayout(right_pane_groupbox) + + # Description + self.ft_description_edit = QTextEdit() + details_layout.addRow("Description:", self.ft_description_edit) + + # Color + ft_color_layout = QHBoxLayout() + self.ft_color_swatch_label = QLabel() + self.ft_color_swatch_label.setFixedSize(20, 20) + self.ft_color_swatch_label.setAutoFillBackground(True) + self._update_color_swatch_generic(self.ft_color_swatch_label, "#ffffff") # Default + + btn_ft_choose_color = QPushButton("Choose Color...") + btn_ft_choose_color.clicked.connect(self._choose_file_type_color) + ft_color_layout.addWidget(self.ft_color_swatch_label) + ft_color_layout.addWidget(btn_ft_choose_color) + ft_color_layout.addStretch() + details_layout.addRow("Color:", ft_color_layout) + + # Examples + ft_examples_group = QGroupBox("Examples") + ft_examples_layout = QVBoxLayout(ft_examples_group) + self.ft_examples_list_widget = QListWidget() + ft_examples_layout.addWidget(self.ft_examples_list_widget) + ft_example_buttons_layout = QHBoxLayout() + btn_ft_add_example = QPushButton("Add Example") + btn_ft_remove_example = QPushButton("Remove Selected Example") + btn_ft_add_example.clicked.connect(self._add_file_type_example) + btn_ft_remove_example.clicked.connect(self._remove_file_type_example) + ft_example_buttons_layout.addWidget(btn_ft_add_example) + ft_example_buttons_layout.addWidget(btn_ft_remove_example) + ft_examples_layout.addLayout(ft_example_buttons_layout) + details_layout.addRow(ft_examples_group) + + # Standard Type + self.ft_standard_type_edit = QLineEdit() + details_layout.addRow("Standard Type:", self.ft_standard_type_edit) + + # Bit Depth Rule + self.ft_bit_depth_combo = QComboBox() + self.ft_bit_depth_combo.addItems(["respect", "force_8bit", "force_16bit"]) + details_layout.addRow("Bit Depth Rule:", self.ft_bit_depth_combo) + + # Is Grayscale + self.ft_is_grayscale_check = QCheckBox("Is Grayscale") + details_layout.addRow(self.ft_is_grayscale_check) # No label for checkbox itself + + # Keybind + self.ft_keybind_edit = QLineEdit() + self.ft_keybind_edit.setMaxLength(1) # Basic validation + details_layout.addRow("Keybind:", self.ft_keybind_edit) + + parent_layout = right_pane_container.parentWidget().layout() + if parent_layout: + parent_layout.replaceWidget(right_pane_container, right_pane_groupbox) + right_pane_container.deleteLater() + + # Connect signals for editing + self.ft_description_edit.textChanged.connect(self._on_file_type_detail_changed) + self.ft_standard_type_edit.textChanged.connect(self._on_file_type_detail_changed) + self.ft_bit_depth_combo.currentIndexChanged.connect(self._on_file_type_detail_changed) + self.ft_is_grayscale_check.stateChanged.connect(self._on_file_type_detail_changed) + self.ft_keybind_edit.textChanged.connect(self._on_file_type_detail_changed) + + self._populate_file_type_list() + if self.file_type_list_widget.count() > 0: + self.file_type_list_widget.setCurrentRow(0) + # _display_file_type_details is connected to currentItemChanged + + return tab_page + + def _populate_file_type_list(self): + self.file_type_list_widget.clear() + for key, ft_data_item in self.file_type_data.items(): + item = QListWidgetItem(key) + if not isinstance(ft_data_item, dict): + logger.warning(f"File type data for '{key}' is not a dict: {ft_data_item}. Using default.") + ft_data_item = { + "description": str(ft_data_item), "color": "#ffffff", "examples": [], + "standard_type": "", "bit_depth_rule": "respect", + "is_grayscale": False, "keybind": "" + } + + # Ensure all essential keys exist with defaults + ft_data_item.setdefault('description', '') + ft_data_item.setdefault('color', '#ffffff') + ft_data_item.setdefault('examples', []) + ft_data_item.setdefault('standard_type', '') + ft_data_item.setdefault('bit_depth_rule', 'respect') + ft_data_item.setdefault('is_grayscale', False) + ft_data_item.setdefault('keybind', '') + + item.setData(Qt.UserRole, ft_data_item) + self.file_type_list_widget.addItem(item) + + def _display_file_type_details(self, current_item, previous_item=None): + # Disconnect signals temporarily + try: self.ft_description_edit.textChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + try: self.ft_standard_type_edit.textChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + try: self.ft_bit_depth_combo.currentIndexChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + try: self.ft_is_grayscale_check.stateChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + try: self.ft_keybind_edit.textChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + # Color and examples are handled by their own buttons/actions, not direct textChanged etc. + + if current_item: + ft_data = current_item.data(Qt.UserRole) + if not isinstance(ft_data, dict): + logger.error(f"Invalid data for file type item {current_item.text()}. Expected dict, got {type(ft_data)}") + # Use placeholder data to avoid crashing UI + ft_data = { + "description": "Error: Invalid data", "color": "#ff0000", "examples": [], + "standard_type": "error", "bit_depth_rule": "respect", + "is_grayscale": False, "keybind": "X" + } + + self.ft_description_edit.setText(ft_data.get('description', '')) + self._update_color_swatch_generic(self.ft_color_swatch_label, ft_data.get('color', '#ffffff')) + + self.ft_examples_list_widget.clear() + for example in ft_data.get('examples', []): + self.ft_examples_list_widget.addItem(example) + + self.ft_standard_type_edit.setText(ft_data.get('standard_type', '')) + + bdr_index = self.ft_bit_depth_combo.findText(ft_data.get('bit_depth_rule', 'respect')) + if bdr_index != -1: + self.ft_bit_depth_combo.setCurrentIndex(bdr_index) + else: + self.ft_bit_depth_combo.setCurrentIndex(0) # Default to 'respect' + + self.ft_is_grayscale_check.setChecked(ft_data.get('is_grayscale', False)) + self.ft_keybind_edit.setText(ft_data.get('keybind', '')) + else: + # Clear details if no item is selected + self.ft_description_edit.clear() + self._update_color_swatch_generic(self.ft_color_swatch_label, "#ffffff") + self.ft_examples_list_widget.clear() + self.ft_standard_type_edit.clear() + self.ft_bit_depth_combo.setCurrentIndex(0) + self.ft_is_grayscale_check.setChecked(False) + self.ft_keybind_edit.clear() + + # Reconnect signals + self.ft_description_edit.textChanged.connect(self._on_file_type_detail_changed) + self.ft_standard_type_edit.textChanged.connect(self._on_file_type_detail_changed) + self.ft_bit_depth_combo.currentIndexChanged.connect(self._on_file_type_detail_changed) + self.ft_is_grayscale_check.stateChanged.connect(self._on_file_type_detail_changed) + self.ft_keybind_edit.textChanged.connect(self._on_file_type_detail_changed) + + def _update_color_swatch_generic(self, swatch_label, color_hex): + """Generic color swatch update for any QLabel.""" + if swatch_label: # Check if the swatch label exists and is passed correctly + palette = swatch_label.palette() + palette.setColor(QPalette.Background, QColor(color_hex)) + swatch_label.setPalette(palette) + swatch_label.update() # Ensure the label repaints + + # --- File Type action methods --- + def _add_file_type(self): + new_id, ok = QInputDialog.getText(self, "Add File Type", "Enter ID for the new file type (e.g., MAP_ALB):") + if ok and new_id: + new_id = new_id.strip() # Remove leading/trailing whitespace + if not new_id: # Check if empty after strip + QMessageBox.warning(self, "Invalid ID", "File type ID cannot be empty.") + return + if new_id in self.file_type_data: + QMessageBox.warning(self, "ID Exists", f"A file type with ID '{new_id}' already exists.") + return + + default_file_type = { + "description": "", + "color": "#ffffff", + "examples": [], + "standard_type": "", + "bit_depth_rule": "respect", + "is_grayscale": False, + "keybind": "" + } + self.file_type_data[new_id] = default_file_type + + item = QListWidgetItem(new_id) + item.setData(Qt.UserRole, default_file_type.copy()) # Store a copy for the item + self.file_type_list_widget.addItem(item) + self.file_type_list_widget.setCurrentItem(item) # Triggers _display_file_type_details + logger.info(f"Added new file type: {new_id}") + self.unsaved_changes = True + elif ok and not new_id.strip(): # Also catch if user entered only spaces and pressed OK + QMessageBox.warning(self, "Invalid ID", "File type ID cannot be empty.") + + def _remove_file_type(self): + current_item = self.file_type_list_widget.currentItem() + if not current_item: + QMessageBox.information(self, "No Selection", "Please select a file type to remove.") + return + + file_type_id = current_item.text() + reply = QMessageBox.question(self, "Confirm Removal", + f"Are you sure you want to remove the file type '{file_type_id}'?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + if file_type_id in self.file_type_data: + del self.file_type_data[file_type_id] + + row = self.file_type_list_widget.row(current_item) + self.file_type_list_widget.takeItem(row) + logger.info(f"Removed file type: {file_type_id}") + self.unsaved_changes = True + + if self.file_type_list_widget.count() > 0: + new_row_to_select = max(0, row - 1) if row > 0 else 0 + if self.file_type_list_widget.count() > new_row_to_select: + self.file_type_list_widget.setCurrentRow(new_row_to_select) + else: # if list becomes empty or selection is out of bounds + self._display_file_type_details(None, None) # Clear details + else: + self._display_file_type_details(None, None) # Clear details if list is empty + + def _choose_file_type_color(self): + current_item = self.file_type_list_widget.currentItem() + if not current_item: + return + + ft_data = current_item.data(Qt.UserRole) + if not isinstance(ft_data, dict): # Should not happen + logger.error("File type item data is not a dict in _choose_file_type_color.") + return + + initial_color = QColor(ft_data.get('color', '#ffffff')) + + color = QColorDialog.getColor(initial_color, self, "Choose File Type Color") + if color.isValid(): + color_hex = color.name() + self._update_color_swatch_generic(self.ft_color_swatch_label, color_hex) + ft_data['color'] = color_hex + current_item.setData(Qt.UserRole, ft_data) # Update data in item + self.unsaved_changes = True + + def _add_file_type_example(self): + current_ft_item = self.file_type_list_widget.currentItem() + if not current_ft_item: + QMessageBox.information(self, "No File Type Selected", "Please select a file type first.") + return + + new_example, ok = QInputDialog.getText(self, "Add File Type Example", "Enter new example string (e.g., _alb.png, .exr):") + if ok and new_example: + new_example = new_example.strip() + if not new_example: + QMessageBox.warning(self, "Invalid Example", "Example string cannot be empty.") + return + + ft_data = current_ft_item.data(Qt.UserRole) + if not isinstance(ft_data, dict) or 'examples' not in ft_data: + logger.error("File type data is not a dict or 'examples' key is missing.") + QMessageBox.critical(self, "Error", "Internal data error for selected file type.") + return + + if not isinstance(ft_data['examples'], list): # Ensure 'examples' is a list + ft_data['examples'] = [] + + if new_example in ft_data['examples']: + QMessageBox.information(self, "Example Exists", f"The example '{new_example}' already exists for this file type.") + return + + ft_data['examples'].append(new_example) + current_ft_item.setData(Qt.UserRole, ft_data) # Update data in item + + self.ft_examples_list_widget.addItem(new_example) + logger.info(f"Added example '{new_example}' to file type '{current_ft_item.text()}'") + self.unsaved_changes = True + elif ok and not new_example.strip(): + QMessageBox.warning(self, "Invalid Example", "Example string cannot be empty.") + + def _remove_file_type_example(self): + current_ft_item = self.file_type_list_widget.currentItem() + if not current_ft_item: + QMessageBox.information(self, "No File Type Selected", "Please select a file type first.") + return + + current_example_item = self.ft_examples_list_widget.currentItem() + if not current_example_item: + QMessageBox.information(self, "No Example Selected", "Please select an example to remove.") + return + + example_text = current_example_item.text() + + ft_data = current_ft_item.data(Qt.UserRole) + if not isinstance(ft_data, dict) or 'examples' not in ft_data or not isinstance(ft_data['examples'], list): + logger.error("File type data issue during example removal.") + QMessageBox.critical(self, "Error", "Internal data error for selected file type.") + return + + try: + ft_data['examples'].remove(example_text) + current_ft_item.setData(Qt.UserRole, ft_data) # Update data in item + + row = self.ft_examples_list_widget.row(current_example_item) + self.ft_examples_list_widget.takeItem(row) + logger.info(f"Removed example '{example_text}' from file type '{current_ft_item.text()}'") + self.unsaved_changes = True + except ValueError: + logger.warning(f"Example '{example_text}' not found in internal list for file type '{current_ft_item.text()}'. UI might be out of sync.") + row = self.ft_examples_list_widget.row(current_example_item) + if row >=0: self.ft_examples_list_widget.takeItem(row) + + def _on_file_type_detail_changed(self): + current_item = self.file_type_list_widget.currentItem() + if not current_item: + return + + ft_data = current_item.data(Qt.UserRole) + if not isinstance(ft_data, dict): + logger.error("File type item data is not a dict in _on_file_type_detail_changed.") + return + + # Update based on which widget triggered (or update all) + ft_data['description'] = self.ft_description_edit.toPlainText() + ft_data['standard_type'] = self.ft_standard_type_edit.text() + ft_data['bit_depth_rule'] = self.ft_bit_depth_combo.currentText() + ft_data['is_grayscale'] = self.ft_is_grayscale_check.isChecked() + + # Keybind validation (force uppercase) + keybind_text = self.ft_keybind_edit.text() + if keybind_text: # MaxLength(1) is already set + # Disconnect to prevent recursive call during setText + try: self.ft_keybind_edit.textChanged.disconnect(self._on_file_type_detail_changed) + except TypeError: pass + self.ft_keybind_edit.setText(keybind_text.upper()) + # Reconnect + self.ft_keybind_edit.textChanged.connect(self._on_file_type_detail_changed) + ft_data['keybind'] = keybind_text.upper() + else: + ft_data['keybind'] = '' + + current_item.setData(Qt.UserRole, ft_data) + logger.debug(f"File type '{current_item.text()}' data updated: {ft_data}") + self.unsaved_changes = True + # --- End Placeholder methods --- + + def _create_suppliers_tab(self): + tab_page, right_pane_container = self._create_tab_pane("Supplier", self.supplier_data, "supplier_list_widget") + + right_pane_groupbox = QGroupBox("Details for Selected Supplier") + details_layout = QFormLayout(right_pane_groupbox) + + # Normal Map Type + self.supplier_normal_map_type_combo = QComboBox() + self.supplier_normal_map_type_combo.addItems(["OpenGL", "DirectX"]) + details_layout.addRow("Normal Map Type:", self.supplier_normal_map_type_combo) + + # Replace the generic right_pane_widget + parent_layout = right_pane_container.parentWidget().layout() + if parent_layout: + parent_layout.replaceWidget(right_pane_container, right_pane_groupbox) + right_pane_container.deleteLater() + + # Connect signals for editing + self.supplier_normal_map_type_combo.currentIndexChanged.connect(self._on_supplier_detail_changed) + + # Initial population and display + self._populate_supplier_list() + if self.supplier_list_widget.count() > 0: + self.supplier_list_widget.setCurrentRow(0) + # _display_supplier_details is connected to currentItemChanged + + return tab_page + + def _populate_supplier_list(self): + self.supplier_list_widget.clear() + for key, sup_data_item in self.supplier_data.items(): + item = QListWidgetItem(key) + if not isinstance(sup_data_item, dict): + logger.warning(f"Supplier data for '{key}' is not a dict: {sup_data_item}. Using default.") + sup_data_item = {"normal_map_type": "OpenGL"} + sup_data_item.setdefault('normal_map_type', 'OpenGL') # Ensure key exists + item.setData(Qt.UserRole, sup_data_item) + self.supplier_list_widget.addItem(item) + + def _display_supplier_details(self, current_item, previous_item=None): + # Disconnect signals temporarily + if hasattr(self, 'supplier_normal_map_type_combo'): + try: self.supplier_normal_map_type_combo.currentIndexChanged.disconnect(self._on_supplier_detail_changed) + except TypeError: pass + + if current_item: + supplier_name = current_item.text() + # Prefer getting data directly from self.supplier_data to ensure it's the master copy + # The UserRole data on the item should be a reflection or copy. + supplier_data = self.supplier_data.get(supplier_name) + + if not isinstance(supplier_data, dict): + logger.error(f"Invalid data for supplier item {supplier_name}. Expected dict, got {type(supplier_data)}") + # Fallback if data is somehow corrupted or missing from self.supplier_data + # This might happen if an item is in the list but not in self.supplier_data + item_data_role = current_item.data(Qt.UserRole) + if isinstance(item_data_role, dict): + supplier_data = item_data_role + else: + supplier_data = {"normal_map_type": "OpenGL"} # Absolute fallback + + normal_map_type = supplier_data.get('normal_map_type', 'OpenGL') + nmt_index = self.supplier_normal_map_type_combo.findText(normal_map_type) + if nmt_index != -1: + self.supplier_normal_map_type_combo.setCurrentIndex(nmt_index) + else: + self.supplier_normal_map_type_combo.setCurrentIndex(0) # Default to OpenGL + else: + # Clear details if no item is selected + if hasattr(self, 'supplier_normal_map_type_combo'): + self.supplier_normal_map_type_combo.setCurrentIndex(0) # Default to OpenGL + + # Reconnect signals + if hasattr(self, 'supplier_normal_map_type_combo'): + self.supplier_normal_map_type_combo.currentIndexChanged.connect(self._on_supplier_detail_changed) + + def _on_supplier_detail_changed(self): + current_item = self.supplier_list_widget.currentItem() + if not current_item: + return + + supplier_name = current_item.text() + if supplier_name not in self.supplier_data: + logger.error(f"Supplier '{supplier_name}' not found in self.supplier_data during detail change.") + return # Or create it, but that might be unexpected here + + # Ensure the entry in self.supplier_data is a dictionary + if not isinstance(self.supplier_data[supplier_name], dict): + self.supplier_data[supplier_name] = {} # Initialize if it's not a dict + + new_normal_map_type = self.supplier_normal_map_type_combo.currentText() + self.supplier_data[supplier_name]['normal_map_type'] = new_normal_map_type + + # Update the item's UserRole data as well to keep it in sync + current_item.setData(Qt.UserRole, self.supplier_data[supplier_name].copy()) + + logger.debug(f"Supplier '{supplier_name}' normal_map_type updated to: {new_normal_map_type}") + self.unsaved_changes = True + + def _add_supplier(self): + new_name, ok = QInputDialog.getText(self, "Add Supplier", "Enter name for the new supplier:") + if ok and new_name: + new_name = new_name.strip() + if not new_name: + QMessageBox.warning(self, "Invalid Name", "Supplier name cannot be empty.") + return + if new_name in self.supplier_data: + QMessageBox.warning(self, "Name Exists", f"A supplier named '{new_name}' already exists.") + return + + default_supplier_settings = {"normal_map_type": "OpenGL"} + self.supplier_data[new_name] = default_supplier_settings + + item = QListWidgetItem(new_name) + item.setData(Qt.UserRole, default_supplier_settings.copy()) # Store a copy + self.supplier_list_widget.addItem(item) + self.supplier_list_widget.setCurrentItem(item) # Triggers display + logger.info(f"Added new supplier: {new_name}") + self.unsaved_changes = True + elif ok and not new_name.strip(): + QMessageBox.warning(self, "Invalid Name", "Supplier name cannot be empty.") + + def _remove_supplier(self): + current_item = self.supplier_list_widget.currentItem() + if not current_item: + QMessageBox.information(self, "No Selection", "Please select a supplier to remove.") + return + + supplier_name = current_item.text() + reply = QMessageBox.question(self, "Confirm Removal", + f"Are you sure you want to remove the supplier '{supplier_name}'?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + if supplier_name in self.supplier_data: + del self.supplier_data[supplier_name] + + row = self.supplier_list_widget.row(current_item) + self.supplier_list_widget.takeItem(row) + logger.info(f"Removed supplier: {supplier_name}") + self.unsaved_changes = True + + # Select another item or clear details + if self.supplier_list_widget.count() > 0: + new_row_to_select = max(0, row - 1) if row > 0 else 0 + if self.supplier_list_widget.count() > new_row_to_select: + self.supplier_list_widget.setCurrentRow(new_row_to_select) + else: + self._display_supplier_details(None, None) + else: + self._display_supplier_details(None, None) # Clear details if list is empty + + def save_definitions(self): + logger.info("Attempting to save definitions...") + try: + # --- Asset Type Definitions --- + # Ensure self.asset_type_data is consistent with the QListWidget items. + # All edits should have updated the item's UserRole data. + # Add/Remove operations update self.asset_type_data directly. + # This loop ensures any in-place modifications to item data (like description, color) + # are reflected in the self.asset_type_data before saving. + + current_keys_in_list = set() + if hasattr(self, 'asset_type_list_widget'): # Check if the widget exists + for i in range(self.asset_type_list_widget.count()): + item = self.asset_type_list_widget.item(i) + key = item.text() + current_keys_in_list.add(key) + # Update self.asset_type_data with the (potentially modified) UserRole data + item_data = item.data(Qt.UserRole) + if isinstance(item_data, dict): + self.asset_type_data[key] = item_data + else: + logger.warning(f"Item '{key}' in asset_type_list_widget has non-dict UserRole data: {type(item_data)}. Skipping update for this item in self.asset_type_data.") + + # Remove any keys from self.asset_type_data that are no longer in the list + # (should be handled by _remove_asset_type, but this is a safeguard) + keys_to_remove_from_dict = set(self.asset_type_data.keys()) - current_keys_in_list + for key in keys_to_remove_from_dict: + logger.info(f"Removing orphaned key '{key}' from self.asset_type_data before saving.") + del self.asset_type_data[key] + + save_asset_definitions(self.asset_type_data) + logger.info("Asset Type definitions saved successfully.") + + # --- File Type Definitions --- + if hasattr(self, 'file_type_data') and hasattr(self, 'file_type_list_widget'): + current_ft_keys_in_list = set() + for i in range(self.file_type_list_widget.count()): + item = self.file_type_list_widget.item(i) + key = item.text() + current_ft_keys_in_list.add(key) + item_data = item.data(Qt.UserRole) + if isinstance(item_data, dict): + self.file_type_data[key] = item_data + else: + logger.warning(f"Item '{key}' in file_type_list_widget has non-dict UserRole data: {type(item_data)}. Skipping.") + + keys_to_remove_ft = set(self.file_type_data.keys()) - current_ft_keys_in_list + for key in keys_to_remove_ft: + logger.info(f"Removing orphaned key '{key}' from self.file_type_data before saving.") + del self.file_type_data[key] + + save_file_type_definitions(self.file_type_data) + logger.info("File Type definitions saved successfully.") + else: + logger.info("File type data or list widget not found, skipping save for file types.") + + # --- Supplier Settings --- + if hasattr(self, 'supplier_data') and hasattr(self, 'supplier_list_widget'): + current_s_keys_in_list = set() + for i in range(self.supplier_list_widget.count()): + item = self.supplier_list_widget.item(i) + key = item.text() + current_s_keys_in_list.add(key) + item_data = item.data(Qt.UserRole) + if isinstance(item_data, dict): + self.supplier_data[key] = item_data # Ensure self.supplier_data is up-to-date + else: + logger.warning(f"Item '{key}' in supplier_list_widget has non-dict UserRole data: {type(item_data)}. Skipping update for this item in self.supplier_data.") + + keys_to_remove_s = set(self.supplier_data.keys()) - current_s_keys_in_list + for key in keys_to_remove_s: + logger.info(f"Removing orphaned key '{key}' from self.supplier_data before saving.") + del self.supplier_data[key] + + save_supplier_settings(self.supplier_data) + logger.info("Supplier settings saved successfully.") + else: + logger.info("Supplier data or list widget not found, skipping save for suppliers.") + + + QMessageBox.information(self, "Save Successful", "Definitions saved successfully.") + self.unsaved_changes = False # Reset flag + self.accept() # Close dialog on successful save + + except Exception as e: + logger.error(f"Failed to save definitions: {e}", exc_info=True) + QMessageBox.critical(self, "Save Error", f"Could not save definitions: {e}") + # Optionally, do not close the dialog on error by removing self.accept() or calling self.reject() + + def reject(self): + if self.unsaved_changes: + reply = QMessageBox.question(self, "Unsaved Changes", + "You have unsaved changes. Are you sure you want to cancel?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: + return # Do not close + super().reject() # Proceed with closing + + def closeEvent(self, event): + if self.unsaved_changes: + reply = QMessageBox.question(self, "Unsaved Changes", + "You have unsaved changes. Are you sure you want to close?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.Yes: + event.accept() + else: + event.ignore() + else: + event.accept() + +if __name__ == '__main__': + # This is for testing the dialog independently + from PyQt5.QtWidgets import QApplication + import sys + + # Setup basic logging for testing + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s') + + # Create dummy config files if they don't exist for testing + config_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'config')) + os.makedirs(config_dir, exist_ok=True) + + asset_types_path = os.path.join(config_dir, 'asset_type_definitions.json') + file_types_path = os.path.join(config_dir, 'file_type_definitions.json') + suppliers_path = os.path.join(config_dir, 'suppliers.json') + + if not os.path.exists(asset_types_path): + with open(asset_types_path, 'w') as f: + f.write('{"GenericModel": {"description": "A generic 3D model"}, "TextureSet": {"description": "A set of PBR textures"}}') + if not os.path.exists(file_types_path): + with open(file_types_path, 'w') as f: + f.write('{".fbx": {"description": "Filmbox format"}, ".png": {"description": "Portable Network Graphics"}}') + if not os.path.exists(suppliers_path): + with open(suppliers_path, 'w') as f: + f.write('{"Poliigon": {"api_key": "dummy_key"}, "Local": {}}') + + app = QApplication(sys.argv) + dialog = DefinitionsEditorDialog() + dialog.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py index 6cccc5b..257ec21 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -27,6 +27,7 @@ from .llm_editor_widget import LLMEditorWidget from .log_console_widget import LogConsoleWidget from .main_panel_widget import MainPanelWidget +from .definitions_editor_dialog import DefinitionsEditorDialog # --- Backend Imports for Data Structures --- from rule_structure import SourceRule, AssetRule, FileRule @@ -861,6 +862,11 @@ class MainWindow(QMainWindow): self.preferences_action = QAction("&Preferences...", self) self.preferences_action.triggered.connect(self._open_config_editor) edit_menu.addAction(self.preferences_action) + edit_menu.addSeparator() + + self.definitions_editor_action = QAction("Edit Definitions...", self) + self.definitions_editor_action.triggered.connect(self._open_definitions_editor) + edit_menu.addAction(self.definitions_editor_action) view_menu = self.menu_bar.addMenu("&View") @@ -904,6 +910,17 @@ class MainWindow(QMainWindow): log.exception(f"Error opening configuration editor dialog: {e}") QMessageBox.critical(self, "Error", f"An error occurred while opening the configuration editor:\n{e}") + @Slot() # PySide6.QtCore.Slot + def _open_definitions_editor(self): + log.debug("Opening Definitions Editor dialog.") + try: + # DefinitionsEditorDialog is imported at the top of the file + dialog = DefinitionsEditorDialog(self) + dialog.exec_() # Use exec_() for modal dialog + log.debug("Definitions Editor dialog closed.") + except Exception as e: + log.exception(f"Error opening Definitions Editor dialog: {e}") + QMessageBox.critical(self, "Error", f"An error occurred while opening the Definitions Editor:\n{e}") @Slot(bool) def _toggle_log_console_visibility(self, checked):