diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ae660c00 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +Guidelines for AI agents working on the Setuav Studio repository. + +## Commands + +- **Development / Environment:** + - Run application: `uv run python -m setuav_studio` + - Linting: `uv run ruff check .` + - Code formatting: `uv run ruff format .` + - Type checking: `uv run pyright` + +- **Test Execution Policy:** + - **During Active Iteration:** Do NOT run the full test suite (`python -m tests.suites all`) for routine edits or focused debugging. Run only specific, affected test files (e.g. `uv run pytest tests/core/test_status_bar_problems.py`). + - **Pre-Push / Pre-PR Only:** Run full verification (`uv run python -m tests.suites all` and `uv run python scripts/sdk_contract_tests.py`) as a final check before committing or pushing changes. + +## Architectural Guidelines + +- **Core & Plugin Decoupling:** + - Core studio engine (`src/setuav_studio/`) and plugins (`src/plugins/`) must remain decoupled. + - Domain assets and data (e.g., `src/plugins/geometry/data/airfoils`) and icons (`src/plugins//assets/icons/`) must reside within their respective plugin directories. + +- **Style and Icon Management:** + - Use `setuav_studio.ui.style` (`icons` and `theme`) for styling and icon resolution. + - Plugin icon assets are registered dynamically via `register_plugin_icons(...)`. Do not pollute core asset manifests. + +- **SDK Contracts & Thread Safety:** + - Preserve `setuav_studio_sdk` API compatibility. + - Do not invoke blocking calls (`time.sleep`, synchronous locks) on the main Qt UI event loop thread. + +## Code Quality Standards + +- **No Symptom Masking:** + - Do not swallow exceptions silently, remove failing tests, or return dummy fallback values. Identify and fix underlying root causes. +- **Verification:** + - Code must pass `ruff check`, `ruff format`, and `pyright` without errors or warnings. diff --git a/pyproject.toml b/pyproject.toml index 18d9812a..159ce94d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,13 +75,14 @@ fail-on = "GNU Affero General Public License v3;GNU Affero General Public Licens [tool.ruff] target-version = "py311" line-length = 100 +src = ["src", "tests", "packages/setuav-studio-sdk/src"] [tool.ruff.lint] allowed-confusables = ["α", "γ", "ρ", "×", "–", "ℹ"] select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "RUF", "SIM", "UP"] [tool.ruff.lint.isort] -known-first-party = ["setuav_studio_sdk"] +known-first-party = ["setuav_studio", "plugins", "setuav_studio_sdk"] [tool.ruff.lint.mccabe] max-complexity = 15 diff --git a/scripts/package_smoke_test.py b/scripts/package_smoke_test.py index 9af52f5a..bca6f97e 100644 --- a/scripts/package_smoke_test.py +++ b/scripts/package_smoke_test.py @@ -19,7 +19,7 @@ "setuav_studio/assets/icons/studio.svg", "setuav_studio/assets/fonts/Inter/Inter-VariableFont_opsz,wght.ttf", "setuav_studio/assets/fonts/Inter/OFL.txt", - "setuav_studio/data/airfoils/clarky.dat", + "plugins/geometry/data/airfoils/clarky.dat", ) diff --git a/src/plugins/aerodynamics/charts_dock.py b/src/plugins/aerodynamics/charts_dock.py index 7be0545c..61f8b690 100644 --- a/src/plugins/aerodynamics/charts_dock.py +++ b/src/plugins/aerodynamics/charts_dock.py @@ -81,7 +81,7 @@ def plot_single( series = QLineSeries() series.setName(name) series.setProperty("themeColorRole", color_role) - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color pen = QPen(QColor(chart_color(color_role)), 2.5) series.setPen(pen) @@ -121,7 +121,7 @@ def plot_multi( all_x: list[float] = [] all_y: list[float] = [] - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color for x_vals, y_vals, name, color_role in curves: if not x_vals or not y_vals: diff --git a/src/plugins/aerodynamics/controls_dock.py b/src/plugins/aerodynamics/controls_dock.py index 11e61528..7d1ee1fe 100644 --- a/src/plugins/aerodynamics/controls_dock.py +++ b/src/plugins/aerodynamics/controls_dock.py @@ -22,8 +22,8 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon -from setuav_studio.ui.theme import tokens +from setuav_studio.ui.style.icons import set_label_icon +from setuav_studio.ui.style.theme import tokens from setuav_studio.ui.widget.button import refresh_button_role, set_button_role, set_native_button from setuav_studio.ui.widget.spinbox import NumericSpinBox from setuav_studio.ui.widget.table import PropertyTableMixin diff --git a/src/plugins/aerodynamics/results_dock.py b/src/plugins/aerodynamics/results_dock.py index 2ac7388a..815bec16 100644 --- a/src/plugins/aerodynamics/results_dock.py +++ b/src/plugins/aerodynamics/results_dock.py @@ -21,7 +21,7 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio.ui.widget.button import refresh_button_role, set_native_button from setuav_studio.ui.widget.table import ContentFitTableWidget, PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents diff --git a/src/plugins/electrical_propulsion/catalog_dialog.py b/src/plugins/electrical_propulsion/catalog_dialog.py index 0b69ff7f..fd912081 100644 --- a/src/plugins/electrical_propulsion/catalog_dialog.py +++ b/src/plugins/electrical_propulsion/catalog_dialog.py @@ -31,7 +31,7 @@ get_motor_database, get_propeller_database, ) -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio.ui.widget.button import set_button_role _INVALID_MODEL_INDEX = QModelIndex() diff --git a/src/plugins/electrical_propulsion/charts_dock.py b/src/plugins/electrical_propulsion/charts_dock.py index 5ca84473..9d3f9b64 100644 --- a/src/plugins/electrical_propulsion/charts_dock.py +++ b/src/plugins/electrical_propulsion/charts_dock.py @@ -62,7 +62,7 @@ def plot_data( series_thrust = QLineSeries() series_thrust.setName("Thrust (N)") series_thrust.setProperty("themeColorRole", "blue") - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series_thrust.setPen(QPen(QColor(chart_color("blue")), 2.5)) @@ -149,7 +149,7 @@ def plot_data( series_current = QLineSeries() series_current.setName("Current (A)") series_current.setProperty("themeColorRole", "red") - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series_current.setPen(QPen(QColor(chart_color("red")), 2.5)) @@ -237,7 +237,7 @@ def plot_data( series_eta_tot = QLineSeries() series_eta_tot.setName("Total η") series_eta_tot.setProperty("themeColorRole", "green") - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series_eta_tot.setPen(QPen(QColor(chart_color("green")), 2.5)) @@ -321,7 +321,7 @@ def plot_data( series_pl = QLineSeries() series_pl.setName("g/W") series_pl.setProperty("themeColorRole", "orange") - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series_pl.setPen(QPen(QColor(chart_color("orange")), 2.5)) diff --git a/src/plugins/electrical_propulsion/controls_dock.py b/src/plugins/electrical_propulsion/controls_dock.py index acecb713..072d420b 100644 --- a/src/plugins/electrical_propulsion/controls_dock.py +++ b/src/plugins/electrical_propulsion/controls_dock.py @@ -24,7 +24,7 @@ from pythrust.propulsion.models.motor import MotorSpec from pythrust.propulsion.models.propeller import PropellerSpec -from setuav_studio.ui.icons import get_icon, set_label_icon +from setuav_studio.ui.style.icons import get_icon, set_label_icon from setuav_studio.ui.widget.button import refresh_button_role, set_button_role, set_native_button from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents @@ -283,7 +283,7 @@ def _create_alert_box(self) -> None: self._content_layout.addWidget(self.alert_box) def show_alert(self, severity: str, title: str, message: str) -> None: - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color self._alert_severity = severity if severity in ("warning", "danger", "error"): diff --git a/src/plugins/electrical_propulsion/editors/assembly.py b/src/plugins/electrical_propulsion/editors/assembly.py index bc568141..13385e47 100644 --- a/src/plugins/electrical_propulsion/editors/assembly.py +++ b/src/plugins/electrical_propulsion/editors/assembly.py @@ -13,7 +13,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/plugins/electrical_propulsion/editors/battery.py b/src/plugins/electrical_propulsion/editors/battery.py index 90e04d8b..516cb16b 100644 --- a/src/plugins/electrical_propulsion/editors/battery.py +++ b/src/plugins/electrical_propulsion/editors/battery.py @@ -13,7 +13,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/plugins/electrical_propulsion/results_dock.py b/src/plugins/electrical_propulsion/results_dock.py index 7166fd05..26c7fe80 100644 --- a/src/plugins/electrical_propulsion/results_dock.py +++ b/src/plugins/electrical_propulsion/results_dock.py @@ -17,8 +17,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import tokens +from setuav_studio.ui.style.icons import get_icon +from setuav_studio.ui.style.theme import tokens from setuav_studio.ui.widget.button import refresh_button_role, set_native_button from setuav_studio.ui.widget.table import ContentFitTableWidget, PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents @@ -298,7 +298,7 @@ def _detail_values(row: dict[str, Any], is_best: bool, is_overcurrent: bool) -> @staticmethod def _detail_colors() -> tuple[QColor, QColor, QColor]: - from setuav_studio.ui.theme import is_light_theme + from setuav_studio.ui.style.theme import is_light_theme if is_light_theme(): return QColor("#cf222e"), QColor("#0e8a5b"), QColor("#1a7f37") diff --git a/src/plugins/flight_performance/charts_dock.py b/src/plugins/flight_performance/charts_dock.py index 13b64860..dd94cc2f 100644 --- a/src/plugins/flight_performance/charts_dock.py +++ b/src/plugins/flight_performance/charts_dock.py @@ -238,7 +238,7 @@ def _add_line_series( axis_x: QValueAxis, axis_y: QValueAxis, ) -> QLineSeries: - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series = QLineSeries() series.setName(label) diff --git a/src/plugins/flight_performance/controls_dock.py b/src/plugins/flight_performance/controls_dock.py index b053accf..1093b949 100644 --- a/src/plugins/flight_performance/controls_dock.py +++ b/src/plugins/flight_performance/controls_dock.py @@ -24,7 +24,7 @@ from plugins.electrical_propulsion.database import get_propeller_database from plugins.electrical_propulsion.engine.solver import PropulsionSolverEngine from plugins.weight_balance.engine.solver import WeightBalanceSolver -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.button import refresh_button_role, set_button_role from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents diff --git a/src/plugins/flight_performance/results_dock.py b/src/plugins/flight_performance/results_dock.py index 8f58cca3..9288f544 100644 --- a/src/plugins/flight_performance/results_dock.py +++ b/src/plugins/flight_performance/results_dock.py @@ -19,8 +19,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import status_color, tokens +from setuav_studio.ui.style.icons import get_icon +from setuav_studio.ui.style.theme import status_color, tokens from setuav_studio.ui.widget.button import set_native_button from setuav_studio.ui.widget.table import ContentFitTableWidget, PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents diff --git a/src/setuav_studio/assets/icons/envelope.svg b/src/plugins/geometry/assets/icons/envelope.svg similarity index 100% rename from src/setuav_studio/assets/icons/envelope.svg rename to src/plugins/geometry/assets/icons/envelope.svg diff --git a/src/setuav_studio/assets/icons/geometry-add-control-surface.png b/src/plugins/geometry/assets/icons/geometry-add-control-surface.png similarity index 100% rename from src/setuav_studio/assets/icons/geometry-add-control-surface.png rename to src/plugins/geometry/assets/icons/geometry-add-control-surface.png diff --git a/src/setuav_studio/assets/icons/geometry-add-fuselage.png b/src/plugins/geometry/assets/icons/geometry-add-fuselage.png similarity index 100% rename from src/setuav_studio/assets/icons/geometry-add-fuselage.png rename to src/plugins/geometry/assets/icons/geometry-add-fuselage.png diff --git a/src/setuav_studio/assets/icons/geometry-add-lifting-surface.png b/src/plugins/geometry/assets/icons/geometry-add-lifting-surface.png similarity index 100% rename from src/setuav_studio/assets/icons/geometry-add-lifting-surface.png rename to src/plugins/geometry/assets/icons/geometry-add-lifting-surface.png diff --git a/src/plugins/geometry/assets/icons/manifest.toml b/src/plugins/geometry/assets/icons/manifest.toml new file mode 100644 index 00000000..bce9afb8 --- /dev/null +++ b/src/plugins/geometry/assets/icons/manifest.toml @@ -0,0 +1,34 @@ +[icons] +# Geometry Creation toolbar +geometry_add_fuselage = "geometry-add-fuselage.png" +geometry_add_lifting_surface = "geometry-add-lifting-surface.png" +geometry_add_control_surface = "geometry-add-control-surface.png" + +# Transform & Envelope +transform = "transform.svg" +coordinate_system = "transform.svg" +envelope = "envelope.svg" +physical_envelope = "envelope.svg" + +# View menu 3D panel +viewer_3d = "view-isometric.png" + +# 3D viewport display modes, camera presets, and overlays +view_colored = "view-colored.svg" +view_monochrome = "view-monochrome.svg" +view_palette = "view-palette.svg" +palette = "view-palette.svg" +view_solid = "view-solid.png" +view_wireframe = "view-wireframe.png" +view_transparent = "view-transparent.png" +view_grid = "view-grid.svg" +view_isometric = "view-isometric.svg" +view_perspective = "view-perspective.svg" +camera_perspective = "view-perspective.svg" +camera_iso = "view-isometric.png" +camera_top = "view-top.png" +camera_bottom = "view-bottom.png" +camera_front = "view-front.png" +camera_side = "view-side.png" +view_fit = "view-fit.png" +screenshot = "screenshot.svg" diff --git a/src/setuav_studio/assets/icons/screenshot.svg b/src/plugins/geometry/assets/icons/screenshot.svg similarity index 100% rename from src/setuav_studio/assets/icons/screenshot.svg rename to src/plugins/geometry/assets/icons/screenshot.svg diff --git a/src/setuav_studio/assets/icons/transform.svg b/src/plugins/geometry/assets/icons/transform.svg similarity index 100% rename from src/setuav_studio/assets/icons/transform.svg rename to src/plugins/geometry/assets/icons/transform.svg diff --git a/src/setuav_studio/assets/icons/view-bottom.png b/src/plugins/geometry/assets/icons/view-bottom.png similarity index 100% rename from src/setuav_studio/assets/icons/view-bottom.png rename to src/plugins/geometry/assets/icons/view-bottom.png diff --git a/src/setuav_studio/assets/icons/view-colored.svg b/src/plugins/geometry/assets/icons/view-colored.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-colored.svg rename to src/plugins/geometry/assets/icons/view-colored.svg diff --git a/src/setuav_studio/assets/icons/view-fit.png b/src/plugins/geometry/assets/icons/view-fit.png similarity index 100% rename from src/setuav_studio/assets/icons/view-fit.png rename to src/plugins/geometry/assets/icons/view-fit.png diff --git a/src/setuav_studio/assets/icons/view-front.png b/src/plugins/geometry/assets/icons/view-front.png similarity index 100% rename from src/setuav_studio/assets/icons/view-front.png rename to src/plugins/geometry/assets/icons/view-front.png diff --git a/src/setuav_studio/assets/icons/view-grid.svg b/src/plugins/geometry/assets/icons/view-grid.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-grid.svg rename to src/plugins/geometry/assets/icons/view-grid.svg diff --git a/src/setuav_studio/assets/icons/view-isometric.png b/src/plugins/geometry/assets/icons/view-isometric.png similarity index 100% rename from src/setuav_studio/assets/icons/view-isometric.png rename to src/plugins/geometry/assets/icons/view-isometric.png diff --git a/src/setuav_studio/assets/icons/view-isometric.svg b/src/plugins/geometry/assets/icons/view-isometric.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-isometric.svg rename to src/plugins/geometry/assets/icons/view-isometric.svg diff --git a/src/setuav_studio/assets/icons/view-monochrome.png b/src/plugins/geometry/assets/icons/view-monochrome.png similarity index 100% rename from src/setuav_studio/assets/icons/view-monochrome.png rename to src/plugins/geometry/assets/icons/view-monochrome.png diff --git a/src/setuav_studio/assets/icons/view-monochrome.svg b/src/plugins/geometry/assets/icons/view-monochrome.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-monochrome.svg rename to src/plugins/geometry/assets/icons/view-monochrome.svg diff --git a/src/setuav_studio/assets/icons/view-palette.svg b/src/plugins/geometry/assets/icons/view-palette.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-palette.svg rename to src/plugins/geometry/assets/icons/view-palette.svg diff --git a/src/setuav_studio/assets/icons/view-perspective.svg b/src/plugins/geometry/assets/icons/view-perspective.svg similarity index 100% rename from src/setuav_studio/assets/icons/view-perspective.svg rename to src/plugins/geometry/assets/icons/view-perspective.svg diff --git a/src/setuav_studio/assets/icons/view-side.png b/src/plugins/geometry/assets/icons/view-side.png similarity index 100% rename from src/setuav_studio/assets/icons/view-side.png rename to src/plugins/geometry/assets/icons/view-side.png diff --git a/src/setuav_studio/assets/icons/view-solid.png b/src/plugins/geometry/assets/icons/view-solid.png similarity index 100% rename from src/setuav_studio/assets/icons/view-solid.png rename to src/plugins/geometry/assets/icons/view-solid.png diff --git a/src/setuav_studio/assets/icons/view-top.png b/src/plugins/geometry/assets/icons/view-top.png similarity index 100% rename from src/setuav_studio/assets/icons/view-top.png rename to src/plugins/geometry/assets/icons/view-top.png diff --git a/src/setuav_studio/assets/icons/view-transparent.png b/src/plugins/geometry/assets/icons/view-transparent.png similarity index 100% rename from src/setuav_studio/assets/icons/view-transparent.png rename to src/plugins/geometry/assets/icons/view-transparent.png diff --git a/src/setuav_studio/assets/icons/view-wireframe.png b/src/plugins/geometry/assets/icons/view-wireframe.png similarity index 100% rename from src/setuav_studio/assets/icons/view-wireframe.png rename to src/plugins/geometry/assets/icons/view-wireframe.png diff --git a/src/setuav_studio/data/airfoils/ag24.dat b/src/plugins/geometry/data/airfoils/ag24.dat similarity index 100% rename from src/setuav_studio/data/airfoils/ag24.dat rename to src/plugins/geometry/data/airfoils/ag24.dat diff --git a/src/setuav_studio/data/airfoils/clarky.dat b/src/plugins/geometry/data/airfoils/clarky.dat similarity index 100% rename from src/setuav_studio/data/airfoils/clarky.dat rename to src/plugins/geometry/data/airfoils/clarky.dat diff --git a/src/setuav_studio/data/airfoils/e423.dat b/src/plugins/geometry/data/airfoils/e423.dat similarity index 100% rename from src/setuav_studio/data/airfoils/e423.dat rename to src/plugins/geometry/data/airfoils/e423.dat diff --git a/src/setuav_studio/data/airfoils/fx63137.dat b/src/plugins/geometry/data/airfoils/fx63137.dat similarity index 100% rename from src/setuav_studio/data/airfoils/fx63137.dat rename to src/plugins/geometry/data/airfoils/fx63137.dat diff --git a/src/setuav_studio/data/airfoils/mh45.dat b/src/plugins/geometry/data/airfoils/mh45.dat similarity index 100% rename from src/setuav_studio/data/airfoils/mh45.dat rename to src/plugins/geometry/data/airfoils/mh45.dat diff --git a/src/setuav_studio/data/airfoils/mh60.dat b/src/plugins/geometry/data/airfoils/mh60.dat similarity index 100% rename from src/setuav_studio/data/airfoils/mh60.dat rename to src/plugins/geometry/data/airfoils/mh60.dat diff --git a/src/setuav_studio/data/airfoils/s1223.dat b/src/plugins/geometry/data/airfoils/s1223.dat similarity index 100% rename from src/setuav_studio/data/airfoils/s1223.dat rename to src/plugins/geometry/data/airfoils/s1223.dat diff --git a/src/setuav_studio/data/airfoils/s8036.dat b/src/plugins/geometry/data/airfoils/s8036.dat similarity index 100% rename from src/setuav_studio/data/airfoils/s8036.dat rename to src/plugins/geometry/data/airfoils/s8036.dat diff --git a/src/setuav_studio/data/airfoils/s9027.dat b/src/plugins/geometry/data/airfoils/s9027.dat similarity index 100% rename from src/setuav_studio/data/airfoils/s9027.dat rename to src/plugins/geometry/data/airfoils/s9027.dat diff --git a/src/plugins/geometry/editors/airfoil_dialog.py b/src/plugins/geometry/editors/airfoil_dialog.py index 9d80a3d5..c4199ade 100644 --- a/src/plugins/geometry/editors/airfoil_dialog.py +++ b/src/plugins/geometry/editors/airfoil_dialog.py @@ -37,7 +37,7 @@ QWidget, ) -from setuav_studio.ui.theme import tokens +from setuav_studio.ui.style.theme import tokens from setuav_studio.ui.widget.button import set_button_role, set_native_button from ..engine.airfoil import ( @@ -68,7 +68,7 @@ def set_airfoil(self, name: str, points: tuple[tuple[float, float], ...]) -> Non self.update() def paintEvent(self, _event: Any) -> None: - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -130,7 +130,7 @@ def paintEvent(self, _event: Any) -> None: path.closeSubpath() # Fill & Stroke Airfoil - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series_color = QColor(chart_color("blue")) fill_color = QColor(series_color) diff --git a/src/plugins/geometry/editors/control_surface.py b/src/plugins/geometry/editors/control_surface.py index af999a3b..fc3a2c78 100644 --- a/src/plugins/geometry/editors/control_surface.py +++ b/src/plugins/geometry/editors/control_surface.py @@ -17,7 +17,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/plugins/geometry/editors/fuselage.py b/src/plugins/geometry/editors/fuselage.py index d2c38ac1..77007e46 100644 --- a/src/plugins/geometry/editors/fuselage.py +++ b/src/plugins/geometry/editors/fuselage.py @@ -20,7 +20,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.button import set_native_button from setuav_studio.ui.widget.spinbox import ( NoWheelComboBox, diff --git a/src/plugins/geometry/editors/fuselage_canvas.py b/src/plugins/geometry/editors/fuselage_canvas.py index 27321f3d..6109eaf5 100644 --- a/src/plugins/geometry/editors/fuselage_canvas.py +++ b/src/plugins/geometry/editors/fuselage_canvas.py @@ -23,7 +23,7 @@ QWidget, ) -from setuav_studio.ui.theme import accent_color, tokens +from setuav_studio.ui.style.theme import accent_color, tokens from ..engine.fuselage_geometry import compute_section_metrics, sample_profile @@ -432,7 +432,7 @@ def paintEvent(self, _event: Any) -> None: height = self.height() # Canvas background - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -534,7 +534,7 @@ def _draw_active_profile(self, painter: QPainter, world_to_screen: Any) -> None: self._draw_polygon_handles(painter, world_to_screen) def _draw_polygon_handles(self, painter: QPainter, to_screen: Any) -> None: - from setuav_studio.ui.theme import tokens + from setuav_studio.ui.style.theme import tokens tok = tokens() text_color = QColor(tok["text"]) @@ -620,7 +620,7 @@ def _draw_grid( ox: float, oy: float, ) -> None: - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -667,7 +667,7 @@ def _draw_axes( ox: float, oy: float, ) -> None: - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -702,7 +702,7 @@ def _draw_dimension_lines( w_mm = max_y - min_y h_mm = max_z - min_z - from setuav_studio.ui.theme import tokens + from setuav_studio.ui.style.theme import tokens dim_pen = QPen(QColor(tokens()["text_dim"]), 1.0, Qt.PenStyle.SolidLine) painter.setPen(dim_pen) @@ -735,7 +735,7 @@ def _draw_dimension_lines( ) def _draw_hud(self, painter: QPainter, width: int, _height: int) -> None: - from setuav_studio.ui.theme import chart_color, tokens + from setuav_studio.ui.style.theme import chart_color, tokens tok = tokens() painter.setPen(QColor(tok["text"])) diff --git a/src/plugins/geometry/editors/fuselage_section_dialog.py b/src/plugins/geometry/editors/fuselage_section_dialog.py index 275c2bbf..7ea448fc 100644 --- a/src/plugins/geometry/editors/fuselage_section_dialog.py +++ b/src/plugins/geometry/editors/fuselage_section_dialog.py @@ -30,7 +30,7 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio.ui.widget.button import set_button_role, set_native_button from setuav_studio.ui.widget.spinbox import ( NoWheelComboBox, diff --git a/src/plugins/geometry/editors/lifting_surface.py b/src/plugins/geometry/editors/lifting_surface.py index da71edb8..9a800ba7 100644 --- a/src/plugins/geometry/editors/lifting_surface.py +++ b/src/plugins/geometry/editors/lifting_surface.py @@ -18,7 +18,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.button import set_native_button from setuav_studio.ui.widget.spinbox import ( NoWheelComboBox, diff --git a/src/plugins/geometry/editors/structural_system.py b/src/plugins/geometry/editors/structural_system.py index 854be0a9..09800edb 100644 --- a/src/plugins/geometry/editors/structural_system.py +++ b/src/plugins/geometry/editors/structural_system.py @@ -17,7 +17,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/plugins/geometry/engine/airfoil.py b/src/plugins/geometry/engine/airfoil.py index 5cc0ecd3..25882724 100644 --- a/src/plugins/geometry/engine/airfoil.py +++ b/src/plugins/geometry/engine/airfoil.py @@ -12,9 +12,7 @@ AIRFOIL_SAMPLES = 64 -AIRFOILS_DATA_DIR = ( - Path(__file__).resolve().parent.parent.parent.parent / "setuav_studio" / "data" / "airfoils" -) +AIRFOILS_DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "airfoils" def naca4(code: str, samples: int = AIRFOIL_SAMPLES) -> tuple[tuple[float, float], ...]: diff --git a/src/plugins/geometry/plugin.py b/src/plugins/geometry/plugin.py index debb410f..04b855f1 100644 --- a/src/plugins/geometry/plugin.py +++ b/src/plugins/geometry/plugin.py @@ -1,7 +1,9 @@ +from pathlib import Path from typing import Any, ClassVar from PySide6.QtWidgets import QWidget +from setuav_studio.ui.style.icons import register_plugin_icons from setuav_studio_sdk import ( PanelContribution, SettingsPageContribution, @@ -46,6 +48,9 @@ def __init__(self) -> None: self._providers: dict[str, Any] = {} def activate(self, api: StudioAPI) -> None: + register_plugin_icons( + "org.setuav.studio.geometry", Path(__file__).parent / "assets" / "icons" + ) self._api = api self._creation_controller = GeometryCreationController(api) for contribution in self._creation_controller.contributions(): diff --git a/src/plugins/geometry/viewport/widget.py b/src/plugins/geometry/viewport/widget.py index 56bffb92..0de11206 100644 --- a/src/plugins/geometry/viewport/widget.py +++ b/src/plugins/geometry/viewport/widget.py @@ -294,7 +294,7 @@ def paintGL(self) -> None: # Keep the data dirty so a later, valid paint context can # retry instead of leaving the viewer permanently empty. logger.exception("Geometry OpenGL mesh upload failed") - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -719,7 +719,7 @@ def _update_gpu_meshes(self) -> None: def _upload_meshes(self) -> None: from PySide6.QtGui import QColor - from setuav_studio.ui.theme import chart_color, tokens + from setuav_studio.ui.style.theme import chart_color, tokens def rgb(color: str) -> tuple[float, float, float]: value = QColor(color) @@ -770,7 +770,7 @@ def rgb(color: str) -> tuple[float, float, float]: rgb(chart_color("orange")), wire_mode=self._wire_mode, ) - from setuav_studio.ui.theme import is_light_theme + from setuav_studio.ui.style.theme import is_light_theme is_light = is_light_theme() envelope_color = (0.05, 0.85, 0.15) if is_light else (0.15, 1.0, 0.25) @@ -839,7 +839,7 @@ def _setup_buffer(vao, vbo, program, stride, attributes) -> None: @staticmethod def _reference_grid_vertices() -> list[float]: - from setuav_studio.ui.theme import is_light_theme + from setuav_studio.ui.style.theme import is_light_theme is_light = is_light_theme() major_col = (0.78, 0.78, 0.78) if is_light else (0.24, 0.24, 0.24) @@ -964,7 +964,7 @@ def capture_screenshot( else: from PySide6.QtGui import QColor - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() diff --git a/src/plugins/geometry/workspace.py b/src/plugins/geometry/workspace.py index 42bfedf0..0fd4a69c 100644 --- a/src/plugins/geometry/workspace.py +++ b/src/plugins/geometry/workspace.py @@ -17,7 +17,7 @@ ) from setuav_studio.project import ProjectDocument -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio_sdk import StudioAPI, StudioEvents, VisualPrimitive from .settings import ( diff --git a/src/plugins/weight_balance/assets/icons/manifest.toml b/src/plugins/weight_balance/assets/icons/manifest.toml new file mode 100644 index 00000000..ee0d4210 --- /dev/null +++ b/src/plugins/weight_balance/assets/icons/manifest.toml @@ -0,0 +1,4 @@ +[icons] +mass = "mass.svg" +point_mass = "mass.svg" +component_point_mass = "mass.svg" diff --git a/src/setuav_studio/assets/icons/mass.svg b/src/plugins/weight_balance/assets/icons/mass.svg similarity index 100% rename from src/setuav_studio/assets/icons/mass.svg rename to src/plugins/weight_balance/assets/icons/mass.svg diff --git a/src/plugins/weight_balance/balance_view_dock.py b/src/plugins/weight_balance/balance_view_dock.py index 26a53f14..23acd876 100644 --- a/src/plugins/weight_balance/balance_view_dock.py +++ b/src/plugins/weight_balance/balance_view_dock.py @@ -8,7 +8,7 @@ from PySide6.QtGui import QCloseEvent from PySide6.QtWidgets import QDockWidget, QHBoxLayout, QLabel, QMainWindow, QStatusBar, QWidget -from setuav_studio.ui.theme import chart_color, tokens +from setuav_studio.ui.style.theme import chart_color, tokens from setuav_studio_sdk import StudioAPI, StudioEvents from .canvas import View2DCanvas diff --git a/src/plugins/weight_balance/canvas.py b/src/plugins/weight_balance/canvas.py index 0e67a2c7..3515cb64 100644 --- a/src/plugins/weight_balance/canvas.py +++ b/src/plugins/weight_balance/canvas.py @@ -10,7 +10,7 @@ from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QToolTip, QWidget -from setuav_studio.ui.theme import is_light_theme +from setuav_studio.ui.style.theme import is_light_theme from .scene import ColorValue, View2DMarker, View2DPath, View2DScene diff --git a/src/plugins/weight_balance/plugin.py b/src/plugins/weight_balance/plugin.py index 1746d552..3a37b025 100644 --- a/src/plugins/weight_balance/plugin.py +++ b/src/plugins/weight_balance/plugin.py @@ -2,10 +2,12 @@ from __future__ import annotations +from pathlib import Path from typing import ClassVar from PySide6.QtCore import Qt +from setuav_studio.ui.style.icons import register_plugin_icons from setuav_studio_sdk import ( PanelContribution, StudioAPI, @@ -52,6 +54,9 @@ def __init__(self) -> None: self._solver = WeightBalanceSolver() def activate(self, api: StudioAPI) -> None: + register_plugin_icons( + "org.setuav.studio.weight_balance", Path(__file__).parent / "assets" / "icons" + ) self._api = api api.add_toolbar_item( ToolbarContribution( diff --git a/src/plugins/weight_balance/point_mass_editor.py b/src/plugins/weight_balance/point_mass_editor.py index 65cf412c..dcac5247 100644 --- a/src/plugins/weight_balance/point_mass_editor.py +++ b/src/plugins/weight_balance/point_mass_editor.py @@ -18,7 +18,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.spinbox import set_table_spinbox from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/plugins/weight_balance/results_dock.py b/src/plugins/weight_balance/results_dock.py index 21a25fad..9e4f6553 100644 --- a/src/plugins/weight_balance/results_dock.py +++ b/src/plugins/weight_balance/results_dock.py @@ -16,8 +16,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon, set_label_icon -from setuav_studio.ui.theme import status_color +from setuav_studio.ui.style.icons import get_icon, set_label_icon +from setuav_studio.ui.style.theme import status_color from setuav_studio.ui.widget.table import ContentFitTableWidget, PropertyTableMixin from setuav_studio_sdk import StudioAPI, StudioEvents diff --git a/src/setuav_studio/__main__.py b/src/setuav_studio/__main__.py index 903e35d2..6d3b43a9 100644 --- a/src/setuav_studio/__main__.py +++ b/src/setuav_studio/__main__.py @@ -78,10 +78,10 @@ def main() -> int: from setuav_studio.api import PluginManager, StudioAPI from setuav_studio.ui.dialog.log import install_log_buffer - from setuav_studio.ui.icons import application_icon from setuav_studio.ui.settings.settings_pages import StudioSettings from setuav_studio.ui.shell import MainWindow - from setuav_studio.ui.theme import apply_theme + from setuav_studio.ui.style.icons import application_icon + from setuav_studio.ui.style.theme import apply_theme _configure_logging(arguments.verbose) install_log_buffer(logging.DEBUG if arguments.verbose else logging.INFO) diff --git a/src/setuav_studio/api/api.py b/src/setuav_studio/api/api.py index 84326f2e..6be0e5e4 100644 --- a/src/setuav_studio/api/api.py +++ b/src/setuav_studio/api/api.py @@ -14,7 +14,7 @@ from setuav_studio.api.hooks import HookRegistry from setuav_studio.project import ProjectDocument from setuav_studio.task.manager import TaskManager -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio_sdk.api import ( ComponentTreeProvider, ProjectTreeProvider, diff --git a/src/setuav_studio/assets/icons/manifest.toml b/src/setuav_studio/assets/icons/manifest.toml index ee5f89a3..bbf7355e 100644 --- a/src/setuav_studio/assets/icons/manifest.toml +++ b/src/setuav_studio/assets/icons/manifest.toml @@ -1,6 +1,7 @@ [icons] error = "error.svg" warning = "warning.svg" + # File menu file_open = "project-open-file.png" file_new = "project-open-file.png" @@ -13,42 +14,3 @@ exit = "application-exit.png" # Edit menu undo = "edit-undo.png" redo = "edit-redo.png" - -# Geometry Creation toolbar -geometry_add_fuselage = "geometry-add-fuselage.png" -geometry_add_lifting_surface = "geometry-add-lifting-surface.png" -geometry_add_control_surface = "geometry-add-control-surface.png" - -# Transform & Envelope -transform = "transform.svg" -coordinate_system = "transform.svg" -envelope = "envelope.svg" -physical_envelope = "envelope.svg" - -# Weight & Balance / Mass -mass = "mass.svg" -point_mass = "mass.svg" -component_point_mass = "mass.svg" - -# View menu 3D panel -viewer_3d = "view-isometric.png" - -# 3D viewport display modes, camera presets, and overlays -view_colored = "view-colored.svg" -view_monochrome = "view-monochrome.svg" -view_palette = "view-palette.svg" -palette = "view-palette.svg" -view_solid = "view-solid.png" -view_wireframe = "view-wireframe.png" -view_transparent = "view-transparent.png" -view_grid = "view-grid.svg" -view_isometric = "view-isometric.svg" -view_perspective = "view-perspective.svg" -camera_perspective = "view-perspective.svg" -camera_iso = "view-isometric.png" -camera_top = "view-top.png" -camera_bottom = "view-bottom.png" -camera_front = "view-front.png" -camera_side = "view-side.png" -view_fit = "view-fit.png" -screenshot = "screenshot.svg" diff --git a/src/setuav_studio/ui/dialog/about.py b/src/setuav_studio/ui/dialog/about.py index cdb55bca..4d7cd439 100644 --- a/src/setuav_studio/ui/dialog/about.py +++ b/src/setuav_studio/ui/dialog/about.py @@ -4,7 +4,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget -from setuav_studio.ui.icons import application_icon +from setuav_studio.ui.style.icons import application_icon def application_version() -> str: diff --git a/src/setuav_studio/ui/dialog/expression_dialog.py b/src/setuav_studio/ui/dialog/expression_dialog.py index 3ec5deee..3cdff864 100644 --- a/src/setuav_studio/ui/dialog/expression_dialog.py +++ b/src/setuav_studio/ui/dialog/expression_dialog.py @@ -32,7 +32,7 @@ from setuav_studio.model.symbol import ( get_available_symbols_metadata, ) -from setuav_studio.ui.theme import status_color +from setuav_studio.ui.style.theme import status_color if TYPE_CHECKING: from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/dialog/log.py b/src/setuav_studio/ui/dialog/log.py index 4ac8a006..42a63bfc 100644 --- a/src/setuav_studio/ui/dialog/log.py +++ b/src/setuav_studio/ui/dialog/log.py @@ -18,7 +18,7 @@ QVBoxLayout, ) -from setuav_studio.ui.theme import status_color, tokens +from setuav_studio.ui.style.theme import status_color, tokens MAX_BUFFER_SIZE = 2000 diff --git a/src/setuav_studio/ui/dialog/problems.py b/src/setuav_studio/ui/dialog/problems.py index e2e99e45..b4957aa5 100644 --- a/src/setuav_studio/ui/dialog/problems.py +++ b/src/setuav_studio/ui/dialog/problems.py @@ -17,7 +17,7 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon if TYPE_CHECKING: from setuav_studio.api.api import StudioAPI diff --git a/src/setuav_studio/ui/dialog/task_monitor.py b/src/setuav_studio/ui/dialog/task_monitor.py index 1f18a4b1..636b1ae6 100644 --- a/src/setuav_studio/ui/dialog/task_monitor.py +++ b/src/setuav_studio/ui/dialog/task_monitor.py @@ -24,8 +24,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import status_color +from setuav_studio.ui.style.icons import get_icon +from setuav_studio.ui.style.theme import status_color if TYPE_CHECKING: from setuav_studio.api import StudioAPI diff --git a/src/setuav_studio/ui/editor/component.py b/src/setuav_studio/ui/editor/component.py index 421ec04a..517500ec 100644 --- a/src/setuav_studio/ui/editor/component.py +++ b/src/setuav_studio/ui/editor/component.py @@ -15,7 +15,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import ExpressionPropertyCell, PropertyTableMixin from setuav_studio_sdk import ParameterField diff --git a/src/setuav_studio/ui/editor/constraint.py b/src/setuav_studio/ui/editor/constraint.py index 1ad8b17c..7508eeff 100644 --- a/src/setuav_studio/ui/editor/constraint.py +++ b/src/setuav_studio/ui/editor/constraint.py @@ -14,7 +14,7 @@ ) from setuav_studio.model.constraint import ConstraintChecker -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin if TYPE_CHECKING: diff --git a/src/setuav_studio/ui/editor/envelope.py b/src/setuav_studio/ui/editor/envelope.py index e1ba69da..d2730f26 100644 --- a/src/setuav_studio/ui/editor/envelope.py +++ b/src/setuav_studio/ui/editor/envelope.py @@ -18,7 +18,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.spinbox import NumericSpinBox, set_table_spinbox from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/editor/mass.py b/src/setuav_studio/ui/editor/mass.py index 5b00b50d..b0519e16 100644 --- a/src/setuav_studio/ui/editor/mass.py +++ b/src/setuav_studio/ui/editor/mass.py @@ -19,7 +19,7 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon, set_label_icon +from setuav_studio.ui.style.icons import get_icon, set_label_icon from setuav_studio.ui.widget.spinbox import NumericSpinBox, set_table_spinbox from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/editor/parameter.py b/src/setuav_studio/ui/editor/parameter.py index 7955ff9d..94d1fd73 100644 --- a/src/setuav_studio/ui/editor/parameter.py +++ b/src/setuav_studio/ui/editor/parameter.py @@ -15,7 +15,7 @@ ) from setuav_studio.model.parameter import ParameterResolver -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.table import PropertyTableMixin if TYPE_CHECKING: diff --git a/src/setuav_studio/ui/editor/transform.py b/src/setuav_studio/ui/editor/transform.py index 1c9cd265..049a8b97 100644 --- a/src/setuav_studio/ui/editor/transform.py +++ b/src/setuav_studio/ui/editor/transform.py @@ -17,7 +17,7 @@ QWidget, ) -from setuav_studio.ui.icons import set_label_icon +from setuav_studio.ui.style.icons import set_label_icon from setuav_studio.ui.widget.spinbox import NumericSpinBox, set_table_spinbox from setuav_studio.ui.widget.table import PropertyTableMixin from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/icons.py b/src/setuav_studio/ui/icons.py index d42e3a3c..f95818b2 100644 --- a/src/setuav_studio/ui/icons.py +++ b/src/setuav_studio/ui/icons.py @@ -1,268 +1,23 @@ -import logging -import tomllib -from functools import lru_cache -from pathlib import Path -from typing import Any - -import qtawesome as qta -from PySide6.QtCore import QRect, QSize, Qt -from PySide6.QtGui import QIcon, QIconEngine, QPainter, QPalette, QPixmap -from PySide6.QtWidgets import QApplication, QLabel - -logger = logging.getLogger(__name__) - -_ASSET_ROOT = Path(__file__).resolve().parent.parent / "assets" / "icons" -_ICON_MANIFEST = _ASSET_ROOT / "manifest.toml" -# Use the librsvg-rendered fallback at runtime. Qt's SVG renderer does not -# preserve the logo's clip paths, while this PNG is generated from the same -# canonical SVG and retains the complete typography artwork. -_APPLICATION_ICON = _ASSET_ROOT / "studio.png" - -_ICON_MAP = { - # File / Project actions - "file_new": "fa6s.file", - "file_open": "fa6s.file-code", - "folder_open": "fa6s.folder-open", - "project_folder": "fa6s.folder", - "save": "fa6s.floppy-disk", - "save_as": "fa6s.floppy-disk", - "exit": "fa6s.right-from-bracket", - # Edit actions - "undo": "fa6s.rotate-left", - "redo": "fa6s.rotate-right", - "settings": "fa6s.gear", - # Panels & Workspaces - "project_explorer": "fa6s.folder-tree", - "properties": "fa6s.sliders", - "viewer_3d": "fa6s.cube", - "dock_float": "fa6s.up-right-and-down-left-from-center", - "dock_close": "fa6s.xmark", - # Toolbar & General actions - "add": "fa6s.plus", - "remove": "fa6s.trash-can", - "delete": "fa6s.trash-can", - "edit": "fa6s.pen-to-square", - "pencil": "mdi6.pencil", - "pen": "fa6s.pen", - "split": "mdi6.call-split", - "call_split": "mdi6.call-split", - "fit": "fa6s.expand", - "log": "mdi6.message-text-outline", - "export_csv": "fa6s.file-export", - "success": "fa6s.check", - "check": "fa6s.check", - "warning": "fa6s.triangle-exclamation", - "error": "fa6s.circle-xmark", - "constant": "mdi6.pi", - "constraint": "mdi6.link-variant", - "equation": "fa6s.code", - "package": "fa6s.box-open", - "plugin": "fa6s.box-open", - # QtAwesome controls intentionally used inside 3D viewers - "view_colored": "fa6s.palette", - "view_grid": "mdi6.grid", - "view_palette": "fa6s.palette", - "view_fit": "fa6s.expand", - "screenshot": "fa6s.camera", - # Default Component Types - "component": "fa6s.cube", - "instance": "fa6s.clone", - "assembly_generic": "fa6s.cubes", - "component_fuselage": "fa6s.shuttle-space", - "component_lifting_surface": "fa6s.plane", - "component_control_surface": "fa6s.sliders", - "component_propulsion_system": "fa6s.bolt", - "component_structural_system": "fa6s.plane-up", - "component_point_mass": "fa6s.weight-hanging", - "point_mass": "fa6s.weight-hanging", -} - -_LABEL_ICON_SOURCE = "setuavThemeIconSource" -_LABEL_ICON_SIZE = "setuavThemeIconSize" - - -def application_icon() -> QIcon: - """Return the Setuav Studio application icon.""" - return QIcon(str(_APPLICATION_ICON)) - - -@lru_cache(maxsize=1) -def _asset_icon_map() -> dict[str, str]: - """Load logical icon names from the vendored asset manifest.""" - try: - with _ICON_MANIFEST.open("rb") as stream: - values = tomllib.load(stream).get("icons", {}) - except (OSError, tomllib.TOMLDecodeError) as exc: - logger.warning("Could not load icon manifest %s: %s", _ICON_MANIFEST, exc) - return {} - return { - str(name): str(relative_path) - for name, relative_path in values.items() - if isinstance(relative_path, str) - } - - -def _asset_icon(icon_source: str) -> QIcon | None: - relative_path = _asset_icon_map().get(icon_source) - if relative_path is None: - return None - icon_path = _ASSET_ROOT / relative_path - if not icon_path.is_file(): - logger.warning("Mapped icon asset is missing: %s", icon_path) - return None - return QIcon(str(icon_path)) - - -class _ThemeIconEngine(QIconEngine): - """Render a QtAwesome glyph from the palette in effect at paint time.""" - - def __init__( - self, - specifier: str, - color: str | None = None, - color_disabled: str | None = None, - options: dict[str, Any] | None = None, - ) -> None: - super().__init__() - self._specifier = specifier - self._color = color - self._color_disabled = color_disabled - self._options = dict(options or {}) - - def clone(self) -> QIconEngine: - return _ThemeIconEngine( - self._specifier, - self._color, - self._color_disabled, - self._options, - ) - - def key(self) -> str: - return "SetuavThemeIconEngine" - - def pixmap( - self, - size: QSize, - mode: QIcon.Mode, - state: QIcon.State, - ) -> QPixmap: - palette = QApplication.palette() - normal = ( - self._color - or palette.color( - QPalette.ColorGroup.Active, - QPalette.ColorRole.ButtonText, - ).name() - ) - disabled = ( - self._color_disabled - or palette.color( - QPalette.ColorGroup.Disabled, - QPalette.ColorRole.ButtonText, - ).name() - ) - selected = palette.color( - QPalette.ColorGroup.Active, - QPalette.ColorRole.HighlightedText, - ).name() - try: - icon = qta.icon( - self._specifier, - color=normal, - color_active=normal, - color_disabled=disabled, - color_selected=selected, - **self._options, - ) - return icon.pixmap(size, mode, state) - except Exception: - pixmap = QPixmap(size) - pixmap.fill(Qt.GlobalColor.transparent) - return pixmap - - def paint( - self, - painter: QPainter, - rect: QRect, - mode: QIcon.Mode, - state: QIcon.State, - ) -> None: - painter.drawPixmap(rect, self.pixmap(rect.size(), mode, state)) - - -def get_icon( - icon_source: str | Path | QIcon, - color: str | None = None, - color_disabled: str | None = None, - **kwargs: Any, -) -> QIcon: - """Return a theme-aware QIcon for a logical name, QtAwesome specifier, or file path.""" - if isinstance(icon_source, QIcon): - return icon_source - - if isinstance(icon_source, Path) or ( - isinstance(icon_source, str) - and ( - icon_source.endswith(".svg") - or icon_source.endswith(".png") - or Path(icon_source).is_file() - ) - ): - return QIcon(str(icon_source)) - - if isinstance(icon_source, str): - asset_icon = _asset_icon(icon_source) - if asset_icon is not None: - return asset_icon - specifier = _ICON_MAP.get(icon_source, icon_source) - try: - # A custom icon engine avoids storing the current theme color in the - # QIcon. Existing actions and buttons therefore repaint correctly - # after an application palette change without rebuilding each icon. - return QIcon( - _ThemeIconEngine( - specifier, - color=color, - color_disabled=color_disabled, - options=kwargs, - ) - ) - except Exception as exc: - logger.warning("Failed to load icon %r: %s", specifier, exc) - return QIcon() - - return QIcon() - - -def set_label_icon(label: QLabel, icon_source: str, size: int = 14) -> None: - """Set and remember a palette-aware icon rendered into a QLabel pixmap.""" - label.setProperty(_LABEL_ICON_SOURCE, icon_source) - label.setProperty(_LABEL_ICON_SIZE, size) - label.setPixmap(get_icon(icon_source).pixmap(size, size)) - - -def refresh_label_icon(label: QLabel) -> None: - """Refresh a QLabel pixmap previously assigned with :func:`set_label_icon`.""" - icon_source = label.property(_LABEL_ICON_SOURCE) - if not isinstance(icon_source, str) or not icon_source: - return - size = label.property(_LABEL_ICON_SIZE) - icon_size = int(size) if isinstance(size, int) and size > 0 else 14 - label.setPixmap(get_icon(icon_source).pixmap(icon_size, icon_size)) - - -def create_color_badge_icon(color_hex: str, size: int = 16) -> QIcon: - """Create a crisp circular color dot icon for configurations and tags.""" - from PySide6.QtCore import Qt - from PySide6.QtGui import QColor, QPainter, QPixmap - - pixmap = QPixmap(size, size) - pixmap.fill(Qt.GlobalColor.transparent) - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - col = QColor(color_hex) if color_hex else QColor("#2196F3") - painter.setBrush(col) - painter.setPen(col.darker(130)) - painter.drawEllipse(2, 2, size - 4, size - 4) - painter.end() - return QIcon(pixmap) +"""Backward-compatibility module re-exporting setuav_studio.ui.style.icons.""" + +from __future__ import annotations + +from setuav_studio.ui.style.icons import ( + application_icon, + create_color_badge_icon, + get_icon, + refresh_label_icon, + register_icon, + register_plugin_icons, + set_label_icon, +) + +__all__ = [ + "application_icon", + "create_color_badge_icon", + "get_icon", + "refresh_label_icon", + "register_icon", + "register_plugin_icons", + "set_label_icon", +] diff --git a/src/setuav_studio/ui/panels/project_explorer/context_menu.py b/src/setuav_studio/ui/panels/project_explorer/context_menu.py index bc718e06..d4f9c2a6 100644 --- a/src/setuav_studio/ui/panels/project_explorer/context_menu.py +++ b/src/setuav_studio/ui/panels/project_explorer/context_menu.py @@ -5,7 +5,7 @@ from PySide6.QtCore import QPoint from PySide6.QtWidgets import QMenu, QTreeWidgetItem -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon if TYPE_CHECKING: from setuav_studio.ui.panels.project_explorer.operations import ( diff --git a/src/setuav_studio/ui/panels/project_explorer/panel.py b/src/setuav_studio/ui/panels/project_explorer/panel.py index 25c2a997..cbf25ca4 100644 --- a/src/setuav_studio/ui/panels/project_explorer/panel.py +++ b/src/setuav_studio/ui/panels/project_explorer/panel.py @@ -11,8 +11,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon from setuav_studio.ui.panels.project_explorer.tree import ProjectExplorer +from setuav_studio.ui.style.icons import get_icon if TYPE_CHECKING: from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/panels/project_explorer/style.py b/src/setuav_studio/ui/panels/project_explorer/style.py index 55b98a40..65233b53 100644 --- a/src/setuav_studio/ui/panels/project_explorer/style.py +++ b/src/setuav_studio/ui/panels/project_explorer/style.py @@ -6,7 +6,7 @@ from PySide6.QtGui import QPainter, QPalette, QPen from PySide6.QtWidgets import QProxyStyle, QStyle, QWidget -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon if TYPE_CHECKING: from setuav_studio_sdk import StudioAPI diff --git a/src/setuav_studio/ui/panels/project_explorer/tree.py b/src/setuav_studio/ui/panels/project_explorer/tree.py index 1417daa3..efc2b09c 100644 --- a/src/setuav_studio/ui/panels/project_explorer/tree.py +++ b/src/setuav_studio/ui/panels/project_explorer/tree.py @@ -191,7 +191,7 @@ def _apply_modified_color( ) -> None: element_id = str(element.get("id") or "") if element_id and saved_elements.get(element_id) != element: - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color item.setForeground( 0, @@ -208,7 +208,7 @@ def _on_modified_changed(self, modified: bool) -> None: self._refresh_modified_colors() def _refresh_modified_colors(self) -> None: - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color for item, element in list(self._element_map.items()): try: diff --git a/src/setuav_studio/ui/panels/project_explorer/tree_builder.py b/src/setuav_studio/ui/panels/project_explorer/tree_builder.py index 04eb8ec1..cd80bb75 100644 --- a/src/setuav_studio/ui/panels/project_explorer/tree_builder.py +++ b/src/setuav_studio/ui/panels/project_explorer/tree_builder.py @@ -6,7 +6,6 @@ from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import QTreeWidgetItem -from setuav_studio.ui.icons import get_icon from setuav_studio.ui.panels.project_explorer.style import ( format_assembly_icon, format_assembly_type, @@ -14,6 +13,7 @@ format_component_type, get_geometry_icon_source, ) +from setuav_studio.ui.style.icons import get_icon if TYPE_CHECKING: from setuav_studio.project import ProjectDocument @@ -368,7 +368,7 @@ def append_project_contribution( current_entry = current_entries.get(analysis_id) saved_entry = self._tree._saved_analysis_results.get(analysis_id) if saved_entry != current_entry: - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color item.setForeground( 0, diff --git a/src/setuav_studio/ui/shell/actions.py b/src/setuav_studio/ui/shell/actions.py index 44060aee..a40f17d5 100644 --- a/src/setuav_studio/ui/shell/actions.py +++ b/src/setuav_studio/ui/shell/actions.py @@ -9,9 +9,9 @@ from setuav_studio.ui.dialog.about import AboutDialog from setuav_studio.ui.dialog.plugin_manager import PluginManagerDialog -from setuav_studio.ui.icons import get_icon from setuav_studio.ui.settings.settings_pages import SettingsDialog, StudioSettings from setuav_studio.ui.shell.command_palette import CommandPaletteDialog +from setuav_studio.ui.style.icons import get_icon from setuav_studio_sdk import ActionContribution if TYPE_CHECKING: @@ -123,7 +123,7 @@ def _setup_menus(self) -> None: self._f1_shortcut.activated.connect(self.open_command_palette) self.command_actions["core.command_palette.open"] = self.command_palette_action - from setuav_studio.ui.theme import current_theme_mode + from setuav_studio.ui.style.theme import current_theme_mode cur_mode = current_theme_mode() self.theme_action_group = QActionGroup(self._window) @@ -188,7 +188,7 @@ def _create_theme_action(self, title: str, mode: str, cur_mode: str) -> QAction: return action def switch_theme(self, mode: str) -> None: - from setuav_studio.ui.theme import apply_theme + from setuav_studio.ui.style.theme import apply_theme from setuav_studio.ui.widget.button import refresh_all_button_roles app = QApplication.instance() diff --git a/src/setuav_studio/ui/shell/command_palette.py b/src/setuav_studio/ui/shell/command_palette.py index 06a54e87..0d8c5f19 100644 --- a/src/setuav_studio/ui/shell/command_palette.py +++ b/src/setuav_studio/ui/shell/command_palette.py @@ -19,8 +19,8 @@ QWidget, ) -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import tokens +from setuav_studio.ui.style.icons import get_icon +from setuav_studio.ui.style.theme import tokens if TYPE_CHECKING: from setuav_studio.api.api import StudioAPI diff --git a/src/setuav_studio/ui/shell/status_bar.py b/src/setuav_studio/ui/shell/status_bar.py index 77fe4e25..b0a4078e 100644 --- a/src/setuav_studio/ui/shell/status_bar.py +++ b/src/setuav_studio/ui/shell/status_bar.py @@ -18,8 +18,8 @@ from setuav_studio.model.constraint import ConstraintChecker from setuav_studio.ui.dialog.log import install_log_buffer from setuav_studio.ui.dialog.problems import Problem, ProblemsDialog -from setuav_studio.ui.icons import get_icon -from setuav_studio.ui.theme import status_color +from setuav_studio.ui.style.icons import get_icon +from setuav_studio.ui.style.theme import status_color if TYPE_CHECKING: from setuav_studio.api import StudioAPI diff --git a/src/setuav_studio/ui/shell/toolbar.py b/src/setuav_studio/ui/shell/toolbar.py index f680a63d..f09cb630 100644 --- a/src/setuav_studio/ui/shell/toolbar.py +++ b/src/setuav_studio/ui/shell/toolbar.py @@ -7,7 +7,7 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QComboBox, QToolBar, QToolButton -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon from setuav_studio_sdk import WorkspaceContribution diff --git a/src/setuav_studio/ui/shell/toolbar_manager.py b/src/setuav_studio/ui/shell/toolbar_manager.py index 2f6dbc27..415ff248 100644 --- a/src/setuav_studio/ui/shell/toolbar_manager.py +++ b/src/setuav_studio/ui/shell/toolbar_manager.py @@ -7,8 +7,8 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QMainWindow, QMenu -from setuav_studio.ui.icons import get_icon from setuav_studio.ui.shell.toolbar import StandardToolBar, ToolSetBar, WorkspaceToolBar +from setuav_studio.ui.style.icons import get_icon from setuav_studio_sdk import ( ToolbarContribution, ToolbarMenuItemContribution, diff --git a/src/setuav_studio/ui/style/__init__.py b/src/setuav_studio/ui/style/__init__.py new file mode 100644 index 00000000..9bd32cfc --- /dev/null +++ b/src/setuav_studio/ui/style/__init__.py @@ -0,0 +1,52 @@ +"""Styling, theme management, and icon utilities for Setuav Studio UI.""" + +from __future__ import annotations + +from setuav_studio.ui.style import icons, theme +from setuav_studio.ui.style.icons import ( + application_icon, + create_color_badge_icon, + get_icon, + refresh_label_icon, + register_icon, + register_plugin_icons, + set_label_icon, +) +from setuav_studio.ui.style.theme import ( + ACCENT_COLOR, + DEFAULT_FONT_SIZE, + FONT_FAMILY, + STATUS_COLORS, + accent_color, + apply_theme, + chart_color, + current_theme_mode, + is_light_theme, + semantic_color, + status_color, + tokens, +) + +__all__ = [ + "ACCENT_COLOR", + "DEFAULT_FONT_SIZE", + "FONT_FAMILY", + "STATUS_COLORS", + "accent_color", + "application_icon", + "apply_theme", + "chart_color", + "create_color_badge_icon", + "current_theme_mode", + "get_icon", + "icons", + "is_light_theme", + "refresh_label_icon", + "register_icon", + "register_plugin_icons", + "semantic_color", + "set_label_icon", + "status_color", + "theme", + "tokens", +] diff --git a/src/setuav_studio/ui/style/icons.py b/src/setuav_studio/ui/style/icons.py new file mode 100644 index 00000000..0ba326e2 --- /dev/null +++ b/src/setuav_studio/ui/style/icons.py @@ -0,0 +1,338 @@ +import logging +import tomllib +from functools import lru_cache +from pathlib import Path +from typing import Any + +import qtawesome as qta +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QIcon, QIconEngine, QPainter, QPalette, QPixmap +from PySide6.QtWidgets import QApplication, QLabel + +logger = logging.getLogger(__name__) + +_ASSET_ROOT = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" +_ICON_MANIFEST = _ASSET_ROOT / "manifest.toml" +# Use the librsvg-rendered fallback at runtime. Qt's SVG renderer does not +# preserve the logo's clip paths, while this PNG is generated from the same +# canonical SVG and retains the complete typography artwork. +_APPLICATION_ICON = _ASSET_ROOT / "studio.png" + +_ICON_MAP = { + # File / Project actions + "file_new": "fa6s.file", + "file_open": "fa6s.file-code", + "folder_open": "fa6s.folder-open", + "project_folder": "fa6s.folder", + "save": "fa6s.floppy-disk", + "save_as": "fa6s.floppy-disk", + "exit": "fa6s.right-from-bracket", + # Edit actions + "undo": "fa6s.rotate-left", + "redo": "fa6s.rotate-right", + "settings": "fa6s.gear", + # Panels & Workspaces + "project_explorer": "fa6s.folder-tree", + "properties": "fa6s.sliders", + "viewer_3d": "fa6s.cube", + "dock_float": "fa6s.up-right-and-down-left-from-center", + "dock_close": "fa6s.xmark", + # Toolbar & General actions + "add": "fa6s.plus", + "remove": "fa6s.trash-can", + "delete": "fa6s.trash-can", + "edit": "fa6s.pen-to-square", + "pencil": "mdi6.pencil", + "pen": "fa6s.pen", + "split": "mdi6.call-split", + "call_split": "mdi6.call-split", + "fit": "fa6s.expand", + "log": "mdi6.message-text-outline", + "export_csv": "fa6s.file-export", + "success": "fa6s.check", + "check": "fa6s.check", + "warning": "fa6s.triangle-exclamation", + "error": "fa6s.circle-xmark", + "constant": "mdi6.pi", + "constraint": "mdi6.link-variant", + "equation": "fa6s.code", + "package": "fa6s.box-open", + "plugin": "fa6s.box-open", + # QtAwesome controls intentionally used inside 3D viewers + "view_colored": "fa6s.palette", + "view_grid": "mdi6.grid", + "view_palette": "fa6s.palette", + "view_fit": "fa6s.expand", + "screenshot": "fa6s.camera", + # Default Component Types + "component": "fa6s.cube", + "instance": "fa6s.clone", + "assembly_generic": "fa6s.cubes", + "component_fuselage": "fa6s.shuttle-space", + "component_lifting_surface": "fa6s.plane", + "component_control_surface": "fa6s.sliders", + "component_propulsion_system": "fa6s.bolt", + "component_structural_system": "fa6s.plane-up", + "component_point_mass": "fa6s.weight-hanging", + "point_mass": "fa6s.weight-hanging", +} + +_LABEL_ICON_SOURCE = "setuavThemeIconSource" +_LABEL_ICON_SIZE = "setuavThemeIconSize" + + +def application_icon() -> QIcon: + """Return the Setuav Studio application icon.""" + return QIcon(str(_APPLICATION_ICON)) + + +@lru_cache(maxsize=1) +def _asset_icon_map() -> dict[str, str]: + """Load logical icon names from the vendored asset manifest.""" + try: + with _ICON_MANIFEST.open("rb") as stream: + values = tomllib.load(stream).get("icons", {}) + except (OSError, tomllib.TOMLDecodeError) as exc: + logger.warning("Could not load icon manifest %s: %s", _ICON_MANIFEST, exc) + return {} + return { + str(name): str(relative_path) + for name, relative_path in values.items() + if isinstance(relative_path, str) + } + + +_PLUGIN_ICON_MAP: dict[str, Path] = {} +_PLUGIN_ICON_MANIFESTS: dict[str, tuple[Path, dict[str, str]]] = {} + + +def register_icon(name: str, icon_path_or_specifier: str | Path | QIcon) -> None: + """Register a custom logical icon name mapped to an asset file or QtAwesome specifier.""" + if isinstance(icon_path_or_specifier, (str, Path)): + p = Path(icon_path_or_specifier) + if p.is_file(): + _PLUGIN_ICON_MAP[name] = p.resolve() + return + if isinstance(icon_path_or_specifier, str): + _ICON_MAP[name] = icon_path_or_specifier + + +def register_plugin_icons( + plugin_id: str, + assets_dir: Path | str, + manifest: dict[str, str] | None = None, +) -> None: + """Register a plugin's asset directory and optional icon name mappings.""" + dir_path = Path(assets_dir).resolve() + icon_manifest: dict[str, str] = dict(manifest or {}) + + manifest_file = dir_path / "manifest.toml" + if not icon_manifest and manifest_file.is_file(): + try: + with manifest_file.open("rb") as stream: + values = tomllib.load(stream).get("icons", {}) + if isinstance(values, dict): + icon_manifest = { + str(k): str(v) for k, v in values.items() if isinstance(v, str) + } + except (OSError, tomllib.TOMLDecodeError) as exc: + logger.warning("Could not load plugin icon manifest %s: %s", manifest_file, exc) + + _PLUGIN_ICON_MANIFESTS[plugin_id] = (dir_path, icon_manifest) + + for name, rel_path in icon_manifest.items(): + full_path = dir_path / rel_path + if full_path.is_file(): + _PLUGIN_ICON_MAP[name] = full_path + + +def _asset_icon(icon_source: str) -> QIcon | None: + if icon_source in _PLUGIN_ICON_MAP: + icon_path = _PLUGIN_ICON_MAP[icon_source] + if icon_path.is_file(): + return QIcon(str(icon_path)) + + for dir_path, manifest in _PLUGIN_ICON_MANIFESTS.values(): + if icon_source in manifest: + full_path = dir_path / manifest[icon_source] + if full_path.is_file(): + return QIcon(str(full_path)) + for ext in (".svg", ".png"): + candidate = dir_path / f"{icon_source}{ext}" + if candidate.is_file(): + return QIcon(str(candidate)) + + relative_path = _asset_icon_map().get(icon_source) + if relative_path is None: + return None + icon_path = _ASSET_ROOT / relative_path + if not icon_path.is_file(): + logger.warning("Mapped icon asset is missing: %s", icon_path) + return None + return QIcon(str(icon_path)) + + +class _ThemeIconEngine(QIconEngine): + """Render a QtAwesome glyph from the palette in effect at paint time.""" + + def __init__( + self, + specifier: str, + color: str | None = None, + color_disabled: str | None = None, + options: dict[str, Any] | None = None, + ) -> None: + super().__init__() + self._specifier = specifier + self._color = color + self._color_disabled = color_disabled + self._options = dict(options or {}) + + def clone(self) -> QIconEngine: + return _ThemeIconEngine( + self._specifier, + self._color, + self._color_disabled, + self._options, + ) + + def key(self) -> str: + return "SetuavThemeIconEngine" + + def pixmap( + self, + size: QSize, + mode: QIcon.Mode, + state: QIcon.State, + ) -> QPixmap: + palette = QApplication.palette() + normal = ( + self._color + or palette.color( + QPalette.ColorGroup.Active, + QPalette.ColorRole.ButtonText, + ).name() + ) + disabled = ( + self._color_disabled + or palette.color( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.ButtonText, + ).name() + ) + selected = palette.color( + QPalette.ColorGroup.Active, + QPalette.ColorRole.HighlightedText, + ).name() + try: + icon = qta.icon( + self._specifier, + color=normal, + color_active=normal, + color_disabled=disabled, + color_selected=selected, + **self._options, + ) + return icon.pixmap(size, mode, state) + except Exception: + pixmap = QPixmap(size) + pixmap.fill(Qt.GlobalColor.transparent) + return pixmap + + def paint( + self, + painter: QPainter, + rect: QRect, + mode: QIcon.Mode, + state: QIcon.State, + ) -> None: + painter.drawPixmap(rect, self.pixmap(rect.size(), mode, state)) + + +def get_icon( + icon_source: str | Path | QIcon, + color: str | None = None, + color_disabled: str | None = None, + **kwargs: Any, +) -> QIcon: + """Return a theme-aware QIcon for a logical name, QtAwesome specifier, or file path.""" + if isinstance(icon_source, QIcon): + return icon_source + + if isinstance(icon_source, Path) or ( + isinstance(icon_source, str) + and ( + icon_source.endswith(".svg") + or icon_source.endswith(".png") + or Path(icon_source).is_file() + ) + ): + return QIcon(str(icon_source)) + + if isinstance(icon_source, str): + asset_icon = _asset_icon(icon_source) + if asset_icon is not None: + return asset_icon + specifier = _ICON_MAP.get(icon_source, icon_source) + try: + # A custom icon engine avoids storing the current theme color in the + # QIcon. Existing actions and buttons therefore repaint correctly + # after an application palette change without rebuilding each icon. + return QIcon( + _ThemeIconEngine( + specifier, + color=color, + color_disabled=color_disabled, + options=kwargs, + ) + ) + except Exception as exc: + logger.warning("Failed to load icon %r: %s", specifier, exc) + return QIcon() + + return QIcon() + + +def set_label_icon(label: QLabel, icon_source: str, size: int = 14) -> None: + """Set and remember a palette-aware icon rendered into a QLabel pixmap.""" + label.setProperty(_LABEL_ICON_SOURCE, icon_source) + label.setProperty(_LABEL_ICON_SIZE, size) + label.setPixmap(get_icon(icon_source).pixmap(size, size)) + + +def refresh_label_icon(label: QLabel) -> None: + """Refresh a QLabel pixmap previously assigned with :func:`set_label_icon`.""" + icon_source = label.property(_LABEL_ICON_SOURCE) + if not isinstance(icon_source, str) or not icon_source: + return + size = label.property(_LABEL_ICON_SIZE) + icon_size = int(size) if isinstance(size, int) and size > 0 else 14 + label.setPixmap(get_icon(icon_source).pixmap(icon_size, icon_size)) + + +def create_color_badge_icon(color_hex: str, size: int = 16) -> QIcon: + """Create a crisp circular color dot icon for configurations and tags.""" + from PySide6.QtCore import Qt + from PySide6.QtGui import QColor, QPainter, QPixmap + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + col = QColor(color_hex) if color_hex else QColor("#2196F3") + painter.setBrush(col) + painter.setPen(col.darker(130)) + painter.drawEllipse(2, 2, size - 4, size - 4) + painter.end() + return QIcon(pixmap) + + +__all__ = [ + "application_icon", + "create_color_badge_icon", + "get_icon", + "refresh_label_icon", + "register_icon", + "register_plugin_icons", + "set_label_icon", +] diff --git a/src/setuav_studio/ui/style/theme.py b/src/setuav_studio/ui/style/theme.py new file mode 100644 index 00000000..676d315a --- /dev/null +++ b/src/setuav_studio/ui/style/theme.py @@ -0,0 +1,587 @@ +import logging +from functools import cache +from importlib import resources + +from PySide6.QtCore import QEvent, QObject +from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette +from PySide6.QtWidgets import QApplication, QComboBox, QLabel + +logger = logging.getLogger(__name__) + + +class ComboBoxWheelFilter(QObject): + """Global event filter that disables mouse wheel value changes on closed QComboBoxes.""" + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.Wheel: + if isinstance(watched, QComboBox): + combo = watched + else: + parent = watched.parent() + combo = parent if isinstance(parent, QComboBox) else None + if combo is not None: + view = combo.view() + if view is None or not view.isVisible(): + event.ignore() + parent = combo.parentWidget() + if parent is not None: + QApplication.sendEvent(parent, event) + return True + return super().eventFilter(watched, event) + + +FONT_FAMILY = "Inter" +DEFAULT_FONT_SIZE = 10 +ACCENT_COLOR = "#c5a9eb" +INACTIVE_SELECTION_COLOR = "#404040" +STATUS_COLORS = { + "info": "#cccccc", + "success": "#6fce9c", + "warning": "#e5b567", + "error": "#e06c75", +} + +DARK_CHART_COLORS: dict[str, str] = { + "blue": "#61afef", + "green": "#6fce9c", + "orange": "#e5b567", + "magenta": "#c678dd", + "purple": "#c678dd", + "red": "#e06c75", + "cyan": "#56b6c2", + "teal": "#56b6c2", +} + +LIGHT_CHART_COLORS: dict[str, str] = { + "blue": "#0969da", + "green": "#1a7f37", + "orange": "#bc4c00", + "magenta": "#8250df", + "purple": "#8250df", + "red": "#cf222e", + "cyan": "#0a7f8c", + "teal": "#0a7f8c", +} + +LIGHT_STATUS_COLORS: dict[str, str] = { + "info": "#57606a", + "success": "#1a7f37", + "warning": "#9a6700", + "error": "#cf222e", +} + +DARK_TOKENS: dict[str, str] = { + "window": "#242424", + "title_bar": "#303030", + "dock": "#242424", + "surface": "#1c1c1c", + "surface_alt": "#303030", + "elevated": "#181818", + "row_alt": "#222222", + "plot": "#141414", + "grid": "#333333", + "border": "#282828", + "border_strong": "#555555", + "text": "#e0e0e0", + "text_muted": "#999999", + "text_dim": "#777777", + "text_bright": "#ffffff", + "axis": "#666666", + "accent": "#c5a9eb", + "accent_text": "#101010", + "inactive_selection": "#333333", + "viewer_surface": "#4a7090", + "viewer_surface_edge": "#8db3cf", + "viewer_fuselage": "#555866", + "viewer_fuselage_edge": "#9a9dab", + "viewer_panel_edge": "#e0e0e0", +} + +LIGHT_TOKENS: dict[str, str] = { + "window": "#f4f4f4", + "title_bar": "#e8e8e8", + "dock": "#f4f4f4", + "surface": "#ffffff", + "surface_alt": "#e8e8e8", + "elevated": "#f5f5f5", + "row_alt": "#fafafa", + "plot": "#ffffff", + "grid": "#e0e0e0", + "border": "#dddddd", + "border_strong": "#b0b0b0", + "text": "#1e1e1e", + "text_muted": "#555555", + "text_dim": "#777777", + "text_bright": "#1a73e8", + "axis": "#777777", + "accent": "#7048e8", + "accent_text": "#ffffff", + "inactive_selection": "#e4e4e4", + "viewer_surface": "#7894aa", + "viewer_surface_edge": "#2b4860", + "viewer_fuselage": "#8b8e99", + "viewer_fuselage_edge": "#4b4e58", + "viewer_panel_edge": "#303030", +} + +BLENDER_TOKENS: dict[str, str] = { + "window": "#303030", + "title_bar": "#3d3d3d", + "dock": "#303030", + "surface": "#282828", + "surface_alt": "#3d3d3d", + "elevated": "#1d1d1d", + "row_alt": "#303030", + "plot": "#1d1d1d", + "grid": "#3d3d3d", + "border": "#282828", + "border_strong": "#6c6c6c", + "text": "#ffffff", + "text_muted": "#b9b9b9", + "text_dim": "#989898", + "text_bright": "#ffffff", + "axis": "#848484", + "accent": "#4772b3", + "accent_text": "#282828", + "inactive_selection": "#545454", + "viewer_surface": "#4772b3", + "viewer_surface_edge": "#5db6ea", + "viewer_fuselage": "#545454", + "viewer_fuselage_edge": "#b9b9b9", + "viewer_panel_edge": "#f1a355", +} + +QT_THEME_NAMES: dict[str, str] = { + "blender": "blender", + "github_dark": "github_dark", + "github_light": "github_light", + "monokai": "monokai", + "nord": "nord", +} +LIGHT_THEME_MODES = {"light", "github_light"} + +_current_mode: str = "blender" + + +@cache +def _load_qt_theme(mode: str): + theme_name = QT_THEME_NAMES.get(mode) + if theme_name is None: + return None + try: + from qt_themes import get_theme + except ModuleNotFoundError: + logger.error( + "Theme %s requested but qt-themes is not installed", + theme_name, + ) + return None + return get_theme(theme_name) + + +@cache +def _named_theme_tokens(mode: str) -> dict[str, str]: + if mode == "blender": + return BLENDER_TOKENS + + theme = _load_qt_theme(mode) + if theme is None: + return LIGHT_TOKENS if mode in LIGHT_THEME_MODES else DARK_TOKENS + + def color(name: str, fallback: str) -> str: + value = getattr(theme, name, None) + return value.name() if isinstance(value, QColor) else fallback + + text = color("text", "#ffffff") + primary = color("primary", "#4772b3") + mantle = color("mantle", "#282828") + is_dark = theme.is_dark_theme() + accent_text = mantle if QColor(primary).valueF() > 0.5 else text + return { + "window": color("base", "#303030"), + "title_bar": color("surface0", "#3d3d3d"), + "dock": color("base", "#303030"), + "surface": mantle, + "surface_alt": color("surface0", "#3d3d3d"), + "elevated": color("crust", "#1d1d1d"), + "row_alt": color("base", "#303030"), + "plot": color("crust", "#1d1d1d" if is_dark else "#ffffff"), + "grid": color("surface0", "#3d3d3d"), + "border": mantle if is_dark else color("surface1", "#bbbbbb"), + "border_strong": color("surface2", "#6c6c6c"), + "text": text, + "text_muted": color("overlay2", "#b9b9b9"), + "text_dim": color("overlay1", "#989898"), + "text_bright": text, + "axis": color("overlay0", "#848484"), + "accent": primary, + "accent_text": accent_text, + "inactive_selection": color("surface1", "#545454"), + "viewer_surface": primary, + "viewer_surface_edge": color("blue", primary), + "viewer_fuselage": color("surface1", "#545454"), + "viewer_fuselage_edge": color("overlay2", "#b9b9b9"), + "viewer_panel_edge": color("orange", "#f1a355"), + } + + +@cache +def _named_theme_chart_colors(mode: str) -> dict[str, str]: + theme = _load_qt_theme(mode) + if theme is None: + return LIGHT_CHART_COLORS if mode in LIGHT_THEME_MODES else DARK_CHART_COLORS + + fallback = _named_theme_tokens(mode)["accent"] + return { + role: value.name() if isinstance(value, QColor) else fallback + for role in ("blue", "green", "orange", "magenta", "red", "cyan") + for value in (getattr(theme, role, None),) + } + + +@cache +def _named_theme_status_colors(mode: str) -> dict[str, str]: + theme = _load_qt_theme(mode) + if theme is None: + return LIGHT_STATUS_COLORS if mode in LIGHT_THEME_MODES else STATUS_COLORS + + colors = _named_theme_chart_colors(mode) + overlay = getattr(theme, "overlay2", None) + yellow = getattr(theme, "yellow", None) + return { + "info": overlay.name() if isinstance(overlay, QColor) else colors["blue"], + "success": colors["green"], + "warning": yellow.name() if isinstance(yellow, QColor) else colors["orange"], + "error": colors["red"], + } + + +def set_theme_mode(mode: str) -> None: + """Set the active built-in or qt-themes mode.""" + global _current_mode + if mode in {"dark", "light", *QT_THEME_NAMES}: + _current_mode = mode + + +def current_theme_mode() -> str: + """Return the active theme mode.""" + return _current_mode + + +def is_light_theme() -> bool: + """Return whether the active palette is a light theme.""" + return _current_mode in LIGHT_THEME_MODES + + +def tokens() -> dict[str, str]: + """Return active theme tokens dictionary.""" + if _current_mode in QT_THEME_NAMES: + return _named_theme_tokens(_current_mode) + return LIGHT_TOKENS if _current_mode == "light" else DARK_TOKENS + + +def accent_color() -> str: + """Return the current UI accent color as a hex string.""" + return tokens()["accent"] + + +def chart_color(role: str) -> str: + """Return a data-series color with sufficient contrast for the active theme.""" + if _current_mode in QT_THEME_NAMES: + palette = _named_theme_chart_colors(_current_mode) + else: + palette = LIGHT_CHART_COLORS if _current_mode == "light" else DARK_CHART_COLORS + return palette.get(role, palette["blue"]) + + +def status_color(level: str) -> str: + """Return a semantic status color suitable for the active theme.""" + if _current_mode in QT_THEME_NAMES: + palette = _named_theme_status_colors(_current_mode) + else: + palette = LIGHT_STATUS_COLORS if _current_mode == "light" else STATUS_COLORS + return palette.get(level, palette["info"]) + + +def semantic_color(role: str) -> str: + """Return the active theme color for a semantic button/action role.""" + if _current_mode in QT_THEME_NAMES: + theme = _load_qt_theme(_current_mode) + if theme is not None: + attribute = { + "primary": "primary", + "secondary": "secondary", + "success": "green", + "warning": "yellow", + "danger": "red", + }.get(role) + value = getattr(theme, attribute, None) if attribute else None + if isinstance(value, QColor): + return value.name() + + return { + "primary": accent_color(), + "secondary": chart_color("blue"), + "success": status_color("success"), + "warning": status_color("warning"), + "danger": status_color("error"), + "neutral": QApplication.palette().color(QPalette.ColorRole.ButtonText).name(), + }.get(role, accent_color()) + + +def rgba(color: str, alpha: float) -> str: + qcolor = QColor(color) + return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})" + + +def _create_dark_palette() -> QPalette: + """Create complete robust dark palette for Qt Fusion.""" + dark_palette = QPalette() + + window = QColor("#242424") + base = QColor("#1c1c1c") + alt_base = QColor("#222222") + text = QColor("#e0e0e0") + disabled_text = QColor("#757575") + button = QColor("#303030") + highlight = QColor(DARK_TOKENS["accent"]) + highlighted_text = QColor(DARK_TOKENS["accent_text"]) + + for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive): + dark_palette.setColor(group, QPalette.ColorRole.Window, window) + dark_palette.setColor(group, QPalette.ColorRole.WindowText, text) + dark_palette.setColor(group, QPalette.ColorRole.Base, base) + dark_palette.setColor(group, QPalette.ColorRole.AlternateBase, alt_base) + dark_palette.setColor(group, QPalette.ColorRole.ToolTipBase, window) + dark_palette.setColor(group, QPalette.ColorRole.ToolTipText, text) + dark_palette.setColor(group, QPalette.ColorRole.Text, text) + dark_palette.setColor(group, QPalette.ColorRole.Button, button) + dark_palette.setColor(group, QPalette.ColorRole.ButtonText, text) + dark_palette.setColor(group, QPalette.ColorRole.BrightText, QColor("#ffffff")) + dark_palette.setColor(group, QPalette.ColorRole.Light, QColor("#3c3c3c")) + dark_palette.setColor(group, QPalette.ColorRole.Midlight, QColor("#303030")) + dark_palette.setColor(group, QPalette.ColorRole.Dark, QColor("#1c1c1c")) + dark_palette.setColor(group, QPalette.ColorRole.Mid, QColor("#282828")) + dark_palette.setColor(group, QPalette.ColorRole.Shadow, QColor("#181818")) + dark_palette.setColor(group, QPalette.ColorRole.Highlight, highlight) + dark_palette.setColor(group, QPalette.ColorRole.HighlightedText, highlighted_text) + dark_palette.setColor(group, QPalette.ColorRole.Link, QColor("#61afef")) + dark_palette.setColor(group, QPalette.ColorRole.LinkVisited, QColor("#c678dd")) + dark_palette.setColor(group, QPalette.ColorRole.Accent, highlight) + dark_palette.setColor(group, QPalette.ColorRole.PlaceholderText, disabled_text) + + disabled = QPalette.ColorGroup.Disabled + dark_palette.setColor(disabled, QPalette.ColorRole.Window, window) + dark_palette.setColor(disabled, QPalette.ColorRole.WindowText, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Base, base) + dark_palette.setColor(disabled, QPalette.ColorRole.AlternateBase, alt_base) + dark_palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, window) + dark_palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Text, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Button, button) + dark_palette.setColor(disabled, QPalette.ColorRole.ButtonText, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.BrightText, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Light, QColor("#3c3c3c")) + dark_palette.setColor(disabled, QPalette.ColorRole.Midlight, QColor("#303030")) + dark_palette.setColor(disabled, QPalette.ColorRole.Dark, QColor("#1c1c1c")) + dark_palette.setColor(disabled, QPalette.ColorRole.Mid, QColor("#282828")) + dark_palette.setColor(disabled, QPalette.ColorRole.Shadow, QColor("#181818")) + dark_palette.setColor(disabled, QPalette.ColorRole.Highlight, QColor("#353535")) + dark_palette.setColor(disabled, QPalette.ColorRole.HighlightedText, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Link, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.LinkVisited, disabled_text) + dark_palette.setColor(disabled, QPalette.ColorRole.Accent, QColor("#353535")) + dark_palette.setColor(disabled, QPalette.ColorRole.PlaceholderText, disabled_text) + + return dark_palette + + +def _create_light_palette() -> QPalette: + """Create complete robust clean light palette for Qt Fusion.""" + light_palette = QPalette() + + window = QColor("#f4f4f4") + base = QColor("#ffffff") + alt_base = QColor("#fafafa") + text = QColor("#1e1e1e") + disabled_text = QColor("#8a8a8a") + button = QColor("#e8e8e8") + highlight = QColor("#7048e8") + highlighted_text = QColor("#ffffff") + + for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive): + light_palette.setColor(group, QPalette.ColorRole.Window, window) + light_palette.setColor(group, QPalette.ColorRole.WindowText, text) + light_palette.setColor(group, QPalette.ColorRole.Base, base) + light_palette.setColor(group, QPalette.ColorRole.AlternateBase, alt_base) + light_palette.setColor(group, QPalette.ColorRole.ToolTipBase, QColor("#ffffdc")) + light_palette.setColor(group, QPalette.ColorRole.ToolTipText, text) + light_palette.setColor(group, QPalette.ColorRole.Text, text) + light_palette.setColor(group, QPalette.ColorRole.Button, button) + light_palette.setColor(group, QPalette.ColorRole.ButtonText, text) + light_palette.setColor(group, QPalette.ColorRole.BrightText, QColor("#ffffff")) + light_palette.setColor(group, QPalette.ColorRole.Light, QColor("#ffffff")) + light_palette.setColor(group, QPalette.ColorRole.Midlight, QColor("#f0f0f0")) + light_palette.setColor(group, QPalette.ColorRole.Dark, QColor("#cccccc")) + light_palette.setColor(group, QPalette.ColorRole.Mid, QColor("#dddddd")) + light_palette.setColor(group, QPalette.ColorRole.Shadow, QColor("#b8b8b8")) + light_palette.setColor(group, QPalette.ColorRole.Highlight, highlight) + light_palette.setColor(group, QPalette.ColorRole.HighlightedText, highlighted_text) + light_palette.setColor(group, QPalette.ColorRole.Link, QColor("#0969da")) + light_palette.setColor(group, QPalette.ColorRole.LinkVisited, QColor("#8250df")) + light_palette.setColor(group, QPalette.ColorRole.Accent, highlight) + light_palette.setColor(group, QPalette.ColorRole.PlaceholderText, disabled_text) + + disabled = QPalette.ColorGroup.Disabled + light_palette.setColor(disabled, QPalette.ColorRole.Window, window) + light_palette.setColor(disabled, QPalette.ColorRole.WindowText, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Base, base) + light_palette.setColor(disabled, QPalette.ColorRole.AlternateBase, alt_base) + light_palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, QColor("#ffffdc")) + light_palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Text, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Button, button) + light_palette.setColor(disabled, QPalette.ColorRole.ButtonText, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.BrightText, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Light, QColor("#ffffff")) + light_palette.setColor(disabled, QPalette.ColorRole.Midlight, QColor("#f0f0f0")) + light_palette.setColor(disabled, QPalette.ColorRole.Dark, QColor("#cccccc")) + light_palette.setColor(disabled, QPalette.ColorRole.Mid, QColor("#dddddd")) + light_palette.setColor(disabled, QPalette.ColorRole.Shadow, QColor("#b8b8b8")) + light_palette.setColor(disabled, QPalette.ColorRole.Highlight, QColor("#d0d0d0")) + light_palette.setColor(disabled, QPalette.ColorRole.HighlightedText, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Link, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.LinkVisited, disabled_text) + light_palette.setColor(disabled, QPalette.ColorRole.Accent, QColor("#d0d0d0")) + light_palette.setColor(disabled, QPalette.ColorRole.PlaceholderText, disabled_text) + + return light_palette + + +def _create_named_theme_palette(mode: str) -> QPalette: + """Create a qt-themes palette without resetting the active Qt style.""" + theme = _load_qt_theme(mode) + if theme is None: + return _create_light_palette() if mode in LIGHT_THEME_MODES else _create_dark_palette() + + from qt_themes import update_palette + + palette = QPalette() + update_palette(palette, theme) + + # qt-themes already defines the principal Disabled roles. Complete the + # remaining ones explicitly so native Fusion controls never fall back to + # platform-dependent black brushes. + disabled = QPalette.ColorGroup.Disabled + disabled_text = theme.overlay1 + palette.setColor(disabled, QPalette.ColorRole.Window, theme.base) + palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, theme.mantle) + palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) + palette.setColor(disabled, QPalette.ColorRole.Light, theme.crust) + palette.setColor(disabled, QPalette.ColorRole.Midlight, theme.mantle) + palette.setColor(disabled, QPalette.ColorRole.Mid, theme.surface0) + palette.setColor(disabled, QPalette.ColorRole.Dark, theme.surface1) + palette.setColor(disabled, QPalette.ColorRole.Shadow, theme.overlay0) + return palette + + +def apply_theme(app: QApplication, mode: str | None = None) -> None: + """Pure 100% native Qt Fusion styling with instant dynamic light/dark palette.""" + if mode is not None: + set_theme_mode(mode) + + if app.style().objectName().lower() != "fusion": + app.setStyle("Fusion") + if current_theme_mode() in QT_THEME_NAMES: + palette = _create_named_theme_palette(current_theme_mode()) + elif current_theme_mode() == "light": + palette = _create_light_palette() + else: + palette = _create_dark_palette() + if app.styleSheet(): + app.setStyleSheet("") + app.setPalette(palette) + application_font = _application_font(DEFAULT_FONT_SIZE) + if app.font() != application_font: + app.setFont(application_font) + + from setuav_studio.ui.style.icons import refresh_label_icon + from setuav_studio.ui.widget.button import refresh_all_button_roles + + for widget in app.allWidgets(): + # Application palette propagation preserves intentional per-widget + # palette overrides and emits Qt's normal palette-change events. + if isinstance(widget, QLabel): + refresh_label_icon(widget) + widget.update() + refresh_all_button_roles(app) + + global _combobox_wheel_filter + if _combobox_wheel_filter is None: + _combobox_wheel_filter = ComboBoxWheelFilter(app) + app.installEventFilter(_combobox_wheel_filter) + + +def build_stylesheet(font_size: int = DEFAULT_FONT_SIZE) -> str: + """Return empty stylesheet to allow pure native Qt Fusion rendering.""" + return "" + + +_INTER_FONT_FILES = ( + "Inter-VariableFont_opsz,wght.ttf", + "Inter-Italic-VariableFont_opsz,wght.ttf", +) +_inter_family: str | None = None +_inter_load_attempted = False +_combobox_wheel_filter: ComboBoxWheelFilter | None = None + + +def _application_font(font_size: int) -> QFont: + inter_family = _load_bundled_inter() + available = set(QFontDatabase.families()) + family = inter_family or next( + (name for name in ("Inter", "Ubuntu", "Noto Sans") if name in available), + QApplication.font().family(), + ) + font = QFont(QApplication.font()) + font.setFamily(family) + font.setPointSize(font_size) + return font + + +def _load_bundled_inter() -> str | None: + global _inter_family, _inter_load_attempted + if _inter_load_attempted: + return _inter_family + _inter_load_attempted = True + + font_root = resources.files("setuav_studio").joinpath("assets", "fonts", "Inter") + for file_name in _INTER_FONT_FILES: + resource = font_root.joinpath(file_name) + try: + with resources.as_file(resource) as font_path: + font_id = QFontDatabase.addApplicationFont(str(font_path)) + except (FileNotFoundError, OSError): + continue + families = QFontDatabase.applicationFontFamilies(font_id) + if families and _inter_family is None: + _inter_family = families[0] + return _inter_family + + +__all__ = [ + "ACCENT_COLOR", + "DEFAULT_FONT_SIZE", + "FONT_FAMILY", + "INACTIVE_SELECTION_COLOR", + "STATUS_COLORS", + "ComboBoxWheelFilter", + "accent_color", + "apply_theme", + "chart_color", + "current_theme_mode", + "is_light_theme", + "semantic_color", + "status_color", + "tokens", +] diff --git a/src/setuav_studio/ui/theme.py b/src/setuav_studio/ui/theme.py index 4d8c9690..c145a42b 100644 --- a/src/setuav_studio/ui/theme.py +++ b/src/setuav_studio/ui/theme.py @@ -1,569 +1,37 @@ -import logging -from functools import cache -from importlib import resources - -from PySide6.QtCore import QEvent, QObject -from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette -from PySide6.QtWidgets import QApplication, QComboBox, QLabel - -logger = logging.getLogger(__name__) - - -class ComboBoxWheelFilter(QObject): - """Global event filter that disables mouse wheel value changes on closed QComboBoxes.""" - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if event.type() == QEvent.Type.Wheel: - if isinstance(watched, QComboBox): - combo = watched - else: - parent = watched.parent() - combo = parent if isinstance(parent, QComboBox) else None - if combo is not None: - view = combo.view() - if view is None or not view.isVisible(): - event.ignore() - parent = combo.parentWidget() - if parent is not None: - QApplication.sendEvent(parent, event) - return True - return super().eventFilter(watched, event) - - -FONT_FAMILY = "Inter" -DEFAULT_FONT_SIZE = 10 -ACCENT_COLOR = "#c5a9eb" -INACTIVE_SELECTION_COLOR = "#404040" -STATUS_COLORS = { - "info": "#cccccc", - "success": "#6fce9c", - "warning": "#e5b567", - "error": "#e06c75", -} - -DARK_CHART_COLORS: dict[str, str] = { - "blue": "#61afef", - "green": "#6fce9c", - "orange": "#e5b567", - "magenta": "#c678dd", - "purple": "#c678dd", - "red": "#e06c75", - "cyan": "#56b6c2", - "teal": "#56b6c2", -} - -LIGHT_CHART_COLORS: dict[str, str] = { - "blue": "#0969da", - "green": "#1a7f37", - "orange": "#bc4c00", - "magenta": "#8250df", - "purple": "#8250df", - "red": "#cf222e", - "cyan": "#0a7f8c", - "teal": "#0a7f8c", -} - -LIGHT_STATUS_COLORS: dict[str, str] = { - "info": "#57606a", - "success": "#1a7f37", - "warning": "#9a6700", - "error": "#cf222e", -} - -DARK_TOKENS: dict[str, str] = { - "window": "#242424", - "title_bar": "#303030", - "dock": "#242424", - "surface": "#1c1c1c", - "surface_alt": "#303030", - "elevated": "#181818", - "row_alt": "#222222", - "plot": "#141414", - "grid": "#333333", - "border": "#282828", - "border_strong": "#555555", - "text": "#e0e0e0", - "text_muted": "#999999", - "text_dim": "#777777", - "text_bright": "#ffffff", - "axis": "#666666", - "accent": "#c5a9eb", - "accent_text": "#101010", - "inactive_selection": "#333333", - "viewer_surface": "#4a7090", - "viewer_surface_edge": "#8db3cf", - "viewer_fuselage": "#555866", - "viewer_fuselage_edge": "#9a9dab", - "viewer_panel_edge": "#e0e0e0", -} - -LIGHT_TOKENS: dict[str, str] = { - "window": "#f4f4f4", - "title_bar": "#e8e8e8", - "dock": "#f4f4f4", - "surface": "#ffffff", - "surface_alt": "#e8e8e8", - "elevated": "#f5f5f5", - "row_alt": "#fafafa", - "plot": "#ffffff", - "grid": "#e0e0e0", - "border": "#dddddd", - "border_strong": "#b0b0b0", - "text": "#1e1e1e", - "text_muted": "#555555", - "text_dim": "#777777", - "text_bright": "#1a73e8", - "axis": "#777777", - "accent": "#7048e8", - "accent_text": "#ffffff", - "inactive_selection": "#e4e4e4", - "viewer_surface": "#7894aa", - "viewer_surface_edge": "#2b4860", - "viewer_fuselage": "#8b8e99", - "viewer_fuselage_edge": "#4b4e58", - "viewer_panel_edge": "#303030", -} - -BLENDER_TOKENS: dict[str, str] = { - "window": "#303030", - "title_bar": "#3d3d3d", - "dock": "#303030", - "surface": "#282828", - "surface_alt": "#3d3d3d", - "elevated": "#1d1d1d", - "row_alt": "#303030", - "plot": "#1d1d1d", - "grid": "#3d3d3d", - "border": "#282828", - "border_strong": "#6c6c6c", - "text": "#ffffff", - "text_muted": "#b9b9b9", - "text_dim": "#989898", - "text_bright": "#ffffff", - "axis": "#848484", - "accent": "#4772b3", - "accent_text": "#282828", - "inactive_selection": "#545454", - "viewer_surface": "#4772b3", - "viewer_surface_edge": "#5db6ea", - "viewer_fuselage": "#545454", - "viewer_fuselage_edge": "#b9b9b9", - "viewer_panel_edge": "#f1a355", -} - -QT_THEME_NAMES: dict[str, str] = { - "blender": "blender", - "github_dark": "github_dark", - "github_light": "github_light", - "monokai": "monokai", - "nord": "nord", -} -LIGHT_THEME_MODES = {"light", "github_light"} - -_current_mode: str = "blender" - - -@cache -def _load_qt_theme(mode: str): - theme_name = QT_THEME_NAMES.get(mode) - if theme_name is None: - return None - try: - from qt_themes import get_theme - except ModuleNotFoundError: - logger.error( - "Theme %s requested but qt-themes is not installed", - theme_name, - ) - return None - return get_theme(theme_name) - - -@cache -def _named_theme_tokens(mode: str) -> dict[str, str]: - if mode == "blender": - return BLENDER_TOKENS - - theme = _load_qt_theme(mode) - if theme is None: - return LIGHT_TOKENS if mode in LIGHT_THEME_MODES else DARK_TOKENS - - def color(name: str, fallback: str) -> str: - value = getattr(theme, name, None) - return value.name() if isinstance(value, QColor) else fallback - - text = color("text", "#ffffff") - primary = color("primary", "#4772b3") - mantle = color("mantle", "#282828") - is_dark = theme.is_dark_theme() - accent_text = mantle if QColor(primary).valueF() > 0.5 else text - return { - "window": color("base", "#303030"), - "title_bar": color("surface0", "#3d3d3d"), - "dock": color("base", "#303030"), - "surface": mantle, - "surface_alt": color("surface0", "#3d3d3d"), - "elevated": color("crust", "#1d1d1d"), - "row_alt": color("base", "#303030"), - "plot": color("crust", "#1d1d1d" if is_dark else "#ffffff"), - "grid": color("surface0", "#3d3d3d"), - "border": mantle if is_dark else color("surface1", "#bbbbbb"), - "border_strong": color("surface2", "#6c6c6c"), - "text": text, - "text_muted": color("overlay2", "#b9b9b9"), - "text_dim": color("overlay1", "#989898"), - "text_bright": text, - "axis": color("overlay0", "#848484"), - "accent": primary, - "accent_text": accent_text, - "inactive_selection": color("surface1", "#545454"), - "viewer_surface": primary, - "viewer_surface_edge": color("blue", primary), - "viewer_fuselage": color("surface1", "#545454"), - "viewer_fuselage_edge": color("overlay2", "#b9b9b9"), - "viewer_panel_edge": color("orange", "#f1a355"), - } - - -@cache -def _named_theme_chart_colors(mode: str) -> dict[str, str]: - theme = _load_qt_theme(mode) - if theme is None: - return LIGHT_CHART_COLORS if mode in LIGHT_THEME_MODES else DARK_CHART_COLORS - - fallback = _named_theme_tokens(mode)["accent"] - return { - role: value.name() if isinstance(value, QColor) else fallback - for role in ("blue", "green", "orange", "magenta", "red", "cyan") - for value in (getattr(theme, role, None),) - } - - -@cache -def _named_theme_status_colors(mode: str) -> dict[str, str]: - theme = _load_qt_theme(mode) - if theme is None: - return LIGHT_STATUS_COLORS if mode in LIGHT_THEME_MODES else STATUS_COLORS - - colors = _named_theme_chart_colors(mode) - overlay = getattr(theme, "overlay2", None) - yellow = getattr(theme, "yellow", None) - return { - "info": overlay.name() if isinstance(overlay, QColor) else colors["blue"], - "success": colors["green"], - "warning": yellow.name() if isinstance(yellow, QColor) else colors["orange"], - "error": colors["red"], - } - - -def set_theme_mode(mode: str) -> None: - """Set the active built-in or qt-themes mode.""" - global _current_mode - if mode in {"dark", "light", *QT_THEME_NAMES}: - _current_mode = mode - - -def current_theme_mode() -> str: - """Return the active theme mode.""" - return _current_mode - - -def is_light_theme() -> bool: - """Return whether the active palette is a light theme.""" - return _current_mode in LIGHT_THEME_MODES - - -def tokens() -> dict[str, str]: - """Return active theme tokens dictionary.""" - if _current_mode in QT_THEME_NAMES: - return _named_theme_tokens(_current_mode) - return LIGHT_TOKENS if _current_mode == "light" else DARK_TOKENS - - -def accent_color() -> str: - """Return the current UI accent color as a hex string.""" - return tokens()["accent"] - - -def chart_color(role: str) -> str: - """Return a data-series color with sufficient contrast for the active theme.""" - if _current_mode in QT_THEME_NAMES: - palette = _named_theme_chart_colors(_current_mode) - else: - palette = LIGHT_CHART_COLORS if _current_mode == "light" else DARK_CHART_COLORS - return palette.get(role, palette["blue"]) - - -def status_color(level: str) -> str: - """Return a semantic status color suitable for the active theme.""" - if _current_mode in QT_THEME_NAMES: - palette = _named_theme_status_colors(_current_mode) - else: - palette = LIGHT_STATUS_COLORS if _current_mode == "light" else STATUS_COLORS - return palette.get(level, palette["info"]) - - -def semantic_color(role: str) -> str: - """Return the active theme color for a semantic button/action role.""" - if _current_mode in QT_THEME_NAMES: - theme = _load_qt_theme(_current_mode) - if theme is not None: - attribute = { - "primary": "primary", - "secondary": "secondary", - "success": "green", - "warning": "yellow", - "danger": "red", - }.get(role) - value = getattr(theme, attribute, None) if attribute else None - if isinstance(value, QColor): - return value.name() - - return { - "primary": accent_color(), - "secondary": chart_color("blue"), - "success": status_color("success"), - "warning": status_color("warning"), - "danger": status_color("error"), - "neutral": QApplication.palette().color(QPalette.ColorRole.ButtonText).name(), - }.get(role, accent_color()) - - -def rgba(color: str, alpha: float) -> str: - qcolor = QColor(color) - return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})" - - -def _create_dark_palette() -> QPalette: - """Create complete robust dark palette for Qt Fusion.""" - dark_palette = QPalette() - - window = QColor("#242424") - base = QColor("#1c1c1c") - alt_base = QColor("#222222") - text = QColor("#e0e0e0") - disabled_text = QColor("#757575") - button = QColor("#303030") - highlight = QColor(DARK_TOKENS["accent"]) - highlighted_text = QColor(DARK_TOKENS["accent_text"]) - - for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive): - dark_palette.setColor(group, QPalette.ColorRole.Window, window) - dark_palette.setColor(group, QPalette.ColorRole.WindowText, text) - dark_palette.setColor(group, QPalette.ColorRole.Base, base) - dark_palette.setColor(group, QPalette.ColorRole.AlternateBase, alt_base) - dark_palette.setColor(group, QPalette.ColorRole.ToolTipBase, window) - dark_palette.setColor(group, QPalette.ColorRole.ToolTipText, text) - dark_palette.setColor(group, QPalette.ColorRole.Text, text) - dark_palette.setColor(group, QPalette.ColorRole.Button, button) - dark_palette.setColor(group, QPalette.ColorRole.ButtonText, text) - dark_palette.setColor(group, QPalette.ColorRole.BrightText, QColor("#ffffff")) - dark_palette.setColor(group, QPalette.ColorRole.Light, QColor("#3c3c3c")) - dark_palette.setColor(group, QPalette.ColorRole.Midlight, QColor("#303030")) - dark_palette.setColor(group, QPalette.ColorRole.Dark, QColor("#1c1c1c")) - dark_palette.setColor(group, QPalette.ColorRole.Mid, QColor("#282828")) - dark_palette.setColor(group, QPalette.ColorRole.Shadow, QColor("#181818")) - dark_palette.setColor(group, QPalette.ColorRole.Highlight, highlight) - dark_palette.setColor(group, QPalette.ColorRole.HighlightedText, highlighted_text) - dark_palette.setColor(group, QPalette.ColorRole.Link, QColor("#61afef")) - dark_palette.setColor(group, QPalette.ColorRole.LinkVisited, QColor("#c678dd")) - dark_palette.setColor(group, QPalette.ColorRole.Accent, highlight) - dark_palette.setColor(group, QPalette.ColorRole.PlaceholderText, disabled_text) - - disabled = QPalette.ColorGroup.Disabled - dark_palette.setColor(disabled, QPalette.ColorRole.Window, window) - dark_palette.setColor(disabled, QPalette.ColorRole.WindowText, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Base, base) - dark_palette.setColor(disabled, QPalette.ColorRole.AlternateBase, alt_base) - dark_palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, window) - dark_palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Text, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Button, button) - dark_palette.setColor(disabled, QPalette.ColorRole.ButtonText, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.BrightText, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Light, QColor("#3c3c3c")) - dark_palette.setColor(disabled, QPalette.ColorRole.Midlight, QColor("#303030")) - dark_palette.setColor(disabled, QPalette.ColorRole.Dark, QColor("#1c1c1c")) - dark_palette.setColor(disabled, QPalette.ColorRole.Mid, QColor("#282828")) - dark_palette.setColor(disabled, QPalette.ColorRole.Shadow, QColor("#181818")) - dark_palette.setColor(disabled, QPalette.ColorRole.Highlight, QColor("#353535")) - dark_palette.setColor(disabled, QPalette.ColorRole.HighlightedText, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Link, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.LinkVisited, disabled_text) - dark_palette.setColor(disabled, QPalette.ColorRole.Accent, QColor("#353535")) - dark_palette.setColor(disabled, QPalette.ColorRole.PlaceholderText, disabled_text) - - return dark_palette - - -def _create_light_palette() -> QPalette: - """Create complete robust clean light palette for Qt Fusion.""" - light_palette = QPalette() - - window = QColor("#f4f4f4") - base = QColor("#ffffff") - alt_base = QColor("#fafafa") - text = QColor("#1e1e1e") - disabled_text = QColor("#8a8a8a") - button = QColor("#e8e8e8") - highlight = QColor("#7048e8") - highlighted_text = QColor("#ffffff") - - for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive): - light_palette.setColor(group, QPalette.ColorRole.Window, window) - light_palette.setColor(group, QPalette.ColorRole.WindowText, text) - light_palette.setColor(group, QPalette.ColorRole.Base, base) - light_palette.setColor(group, QPalette.ColorRole.AlternateBase, alt_base) - light_palette.setColor(group, QPalette.ColorRole.ToolTipBase, QColor("#ffffdc")) - light_palette.setColor(group, QPalette.ColorRole.ToolTipText, text) - light_palette.setColor(group, QPalette.ColorRole.Text, text) - light_palette.setColor(group, QPalette.ColorRole.Button, button) - light_palette.setColor(group, QPalette.ColorRole.ButtonText, text) - light_palette.setColor(group, QPalette.ColorRole.BrightText, QColor("#ffffff")) - light_palette.setColor(group, QPalette.ColorRole.Light, QColor("#ffffff")) - light_palette.setColor(group, QPalette.ColorRole.Midlight, QColor("#f0f0f0")) - light_palette.setColor(group, QPalette.ColorRole.Dark, QColor("#cccccc")) - light_palette.setColor(group, QPalette.ColorRole.Mid, QColor("#dddddd")) - light_palette.setColor(group, QPalette.ColorRole.Shadow, QColor("#b8b8b8")) - light_palette.setColor(group, QPalette.ColorRole.Highlight, highlight) - light_palette.setColor(group, QPalette.ColorRole.HighlightedText, highlighted_text) - light_palette.setColor(group, QPalette.ColorRole.Link, QColor("#0969da")) - light_palette.setColor(group, QPalette.ColorRole.LinkVisited, QColor("#8250df")) - light_palette.setColor(group, QPalette.ColorRole.Accent, highlight) - light_palette.setColor(group, QPalette.ColorRole.PlaceholderText, disabled_text) - - disabled = QPalette.ColorGroup.Disabled - light_palette.setColor(disabled, QPalette.ColorRole.Window, window) - light_palette.setColor(disabled, QPalette.ColorRole.WindowText, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Base, base) - light_palette.setColor(disabled, QPalette.ColorRole.AlternateBase, alt_base) - light_palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, QColor("#ffffdc")) - light_palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Text, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Button, button) - light_palette.setColor(disabled, QPalette.ColorRole.ButtonText, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.BrightText, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Light, QColor("#ffffff")) - light_palette.setColor(disabled, QPalette.ColorRole.Midlight, QColor("#f0f0f0")) - light_palette.setColor(disabled, QPalette.ColorRole.Dark, QColor("#cccccc")) - light_palette.setColor(disabled, QPalette.ColorRole.Mid, QColor("#dddddd")) - light_palette.setColor(disabled, QPalette.ColorRole.Shadow, QColor("#b8b8b8")) - light_palette.setColor(disabled, QPalette.ColorRole.Highlight, QColor("#d0d0d0")) - light_palette.setColor(disabled, QPalette.ColorRole.HighlightedText, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Link, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.LinkVisited, disabled_text) - light_palette.setColor(disabled, QPalette.ColorRole.Accent, QColor("#d0d0d0")) - light_palette.setColor(disabled, QPalette.ColorRole.PlaceholderText, disabled_text) - - return light_palette - - -def _create_named_theme_palette(mode: str) -> QPalette: - """Create a qt-themes palette without resetting the active Qt style.""" - theme = _load_qt_theme(mode) - if theme is None: - return _create_light_palette() if mode in LIGHT_THEME_MODES else _create_dark_palette() - - from qt_themes import update_palette - - palette = QPalette() - update_palette(palette, theme) - - # qt-themes already defines the principal Disabled roles. Complete the - # remaining ones explicitly so native Fusion controls never fall back to - # platform-dependent black brushes. - disabled = QPalette.ColorGroup.Disabled - disabled_text = theme.overlay1 - palette.setColor(disabled, QPalette.ColorRole.Window, theme.base) - palette.setColor(disabled, QPalette.ColorRole.ToolTipBase, theme.mantle) - palette.setColor(disabled, QPalette.ColorRole.ToolTipText, disabled_text) - palette.setColor(disabled, QPalette.ColorRole.Light, theme.crust) - palette.setColor(disabled, QPalette.ColorRole.Midlight, theme.mantle) - palette.setColor(disabled, QPalette.ColorRole.Mid, theme.surface0) - palette.setColor(disabled, QPalette.ColorRole.Dark, theme.surface1) - palette.setColor(disabled, QPalette.ColorRole.Shadow, theme.overlay0) - return palette - - -def apply_theme(app: QApplication, mode: str | None = None) -> None: - """Pure 100% native Qt Fusion styling with instant dynamic light/dark palette.""" - if mode is not None: - set_theme_mode(mode) - - if app.style().objectName().lower() != "fusion": - app.setStyle("Fusion") - if current_theme_mode() in QT_THEME_NAMES: - palette = _create_named_theme_palette(current_theme_mode()) - elif current_theme_mode() == "light": - palette = _create_light_palette() - else: - palette = _create_dark_palette() - if app.styleSheet(): - app.setStyleSheet("") - app.setPalette(palette) - application_font = _application_font(DEFAULT_FONT_SIZE) - if app.font() != application_font: - app.setFont(application_font) - - from setuav_studio.ui.icons import refresh_label_icon - from setuav_studio.ui.widget.button import refresh_all_button_roles - - for widget in app.allWidgets(): - # Application palette propagation preserves intentional per-widget - # palette overrides and emits Qt's normal palette-change events. - if isinstance(widget, QLabel): - refresh_label_icon(widget) - widget.update() - refresh_all_button_roles(app) - - global _combobox_wheel_filter - if _combobox_wheel_filter is None: - _combobox_wheel_filter = ComboBoxWheelFilter(app) - app.installEventFilter(_combobox_wheel_filter) - - -def build_stylesheet(font_size: int = DEFAULT_FONT_SIZE) -> str: - """Return empty stylesheet to allow pure native Qt Fusion rendering.""" - return "" - - -_INTER_FONT_FILES = ( - "Inter-VariableFont_opsz,wght.ttf", - "Inter-Italic-VariableFont_opsz,wght.ttf", +"""Backward-compatibility module re-exporting setuav_studio.ui.style.theme.""" + +from __future__ import annotations + +from setuav_studio.ui.style.theme import ( + ACCENT_COLOR, + DEFAULT_FONT_SIZE, + FONT_FAMILY, + INACTIVE_SELECTION_COLOR, + STATUS_COLORS, + ComboBoxWheelFilter, + accent_color, + apply_theme, + chart_color, + current_theme_mode, + is_light_theme, + semantic_color, + status_color, + tokens, ) -_inter_family: str | None = None -_inter_load_attempted = False -_combobox_wheel_filter: ComboBoxWheelFilter | None = None - - -def _application_font(font_size: int) -> QFont: - inter_family = _load_bundled_inter() - available = set(QFontDatabase.families()) - family = inter_family or next( - (name for name in ("Inter", "Ubuntu", "Noto Sans") if name in available), - QApplication.font().family(), - ) - font = QFont(QApplication.font()) - font.setFamily(family) - font.setPointSize(font_size) - return font - - -def _load_bundled_inter() -> str | None: - global _inter_family, _inter_load_attempted - if _inter_load_attempted: - return _inter_family - _inter_load_attempted = True - font_root = resources.files("setuav_studio").joinpath("assets", "fonts", "Inter") - for file_name in _INTER_FONT_FILES: - resource = font_root.joinpath(file_name) - try: - with resources.as_file(resource) as font_path: - font_id = QFontDatabase.addApplicationFont(str(font_path)) - except (FileNotFoundError, OSError): - continue - families = QFontDatabase.applicationFontFamilies(font_id) - if families and _inter_family is None: - _inter_family = families[0] - return _inter_family +__all__ = [ + "ACCENT_COLOR", + "DEFAULT_FONT_SIZE", + "FONT_FAMILY", + "INACTIVE_SELECTION_COLOR", + "STATUS_COLORS", + "ComboBoxWheelFilter", + "accent_color", + "apply_theme", + "chart_color", + "current_theme_mode", + "is_light_theme", + "semantic_color", + "status_color", + "tokens", +] diff --git a/src/setuav_studio/ui/widget/button.py b/src/setuav_studio/ui/widget/button.py index 5ddf4ee5..37a3f804 100644 --- a/src/setuav_studio/ui/widget/button.py +++ b/src/setuav_studio/ui/widget/button.py @@ -5,7 +5,7 @@ from PySide6.QtGui import QColor, QPalette from PySide6.QtWidgets import QAbstractButton, QApplication -from setuav_studio.ui.icons import get_icon +from setuav_studio.ui.style.icons import get_icon _ROLE_PROPERTY = "setuavButtonRole" _VARIANT_PROPERTY = "setuavButtonVariant" @@ -63,7 +63,7 @@ def refresh_button_role(button: QAbstractButton) -> None: ) if role == "primary" and variant == "filled": - from setuav_studio.ui.theme import semantic_color + from setuav_studio.ui.style.theme import semantic_color role_color = QColor(semantic_color(role)) foreground = _contrasting_foreground(role_color) diff --git a/src/setuav_studio/ui/widget/chart.py b/src/setuav_studio/ui/widget/chart.py index fdbf8aef..f2113fe2 100644 --- a/src/setuav_studio/ui/widget/chart.py +++ b/src/setuav_studio/ui/widget/chart.py @@ -63,7 +63,7 @@ def setTitle(self, title: str) -> None: def update_theme_style(self) -> None: """Synchronize colors and pens with current application theme tokens.""" - from setuav_studio.ui.theme import chart_color, is_light_theme, tokens + from setuav_studio.ui.style.theme import chart_color, is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -104,7 +104,7 @@ def update_theme_style(self) -> None: def get_theme_colors(self) -> tuple[QColor, QColor]: """Return (grid_color, dim_text_color) based on current theme.""" - from setuav_studio.ui.theme import is_light_theme, tokens + from setuav_studio.ui.style.theme import is_light_theme, tokens tok = tokens() is_light = is_light_theme() @@ -141,7 +141,7 @@ def create_line_series( style: Qt.PenStyle = Qt.PenStyle.SolidLine, ) -> QLineSeries: """Create and return a styled QLineSeries with optional themeColorRole.""" - from setuav_studio.ui.theme import chart_color + from setuav_studio.ui.style.theme import chart_color series = QLineSeries() if name: diff --git a/tests/aerodynamics/test_aero_plugin.py b/tests/aerodynamics/test_aero_plugin.py index fa608a84..a90818a1 100644 --- a/tests/aerodynamics/test_aero_plugin.py +++ b/tests/aerodynamics/test_aero_plugin.py @@ -844,7 +844,7 @@ def test_project_tree_delete_key_deletes_component(self) -> None: self.assertEqual(len(project.data["components"]), 0) def test_unsaved_analysis_result_is_marked_dirty_yellow(self) -> None: - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color self.plugin.activate(self.api) project = ProjectDocument( diff --git a/tests/core/test_plugins.py b/tests/core/test_plugins.py index f59e3518..8b255891 100644 --- a/tests/core/test_plugins.py +++ b/tests/core/test_plugins.py @@ -2,12 +2,12 @@ from pathlib import Path from typing import Any, ClassVar -from plugins.geometry.data import GeometryData, LoftGeometry, Section -from plugins.geometry.fuselage import FuselageEditor -from plugins.geometry.mesh import build_loft_wire_vertices from PySide6.QtWidgets import QWidget from plugins.geometry import GeometryPlugin +from plugins.geometry.data import GeometryData, LoftGeometry, Section +from plugins.geometry.fuselage import FuselageEditor +from plugins.geometry.mesh import build_loft_wire_vertices from plugins.weight_balance.canvas import View2DCanvas from plugins.weight_balance.scene import View2DScene from setuav_studio.api import ( diff --git a/tests/core/test_shell_contributions.py b/tests/core/test_shell_contributions.py index 7508dd48..474c81c7 100644 --- a/tests/core/test_shell_contributions.py +++ b/tests/core/test_shell_contributions.py @@ -57,7 +57,7 @@ def test_theme_switch_refreshes_actions_widgets_and_persistence(self) -> None: self.addCleanup(broken_widget.deleteLater) with ( - patch("setuav_studio.ui.theme.apply_theme") as apply_theme, + patch("setuav_studio.ui.style.theme.apply_theme") as apply_theme, patch("setuav_studio.ui.widget.button.refresh_all_button_roles") as refresh_roles, patch.object(StudioSettings, "load", return_value=StudioSettings(theme_mode="dark")), patch.object(StudioSettings, "save") as save_settings, @@ -71,7 +71,7 @@ def test_theme_switch_refreshes_actions_widgets_and_persistence(self) -> None: themed_widget.update_theme_style.assert_called_once() # type: ignore[attr-defined] with ( - patch("setuav_studio.ui.theme.apply_theme"), + patch("setuav_studio.ui.style.theme.apply_theme"), patch("setuav_studio.ui.widget.button.refresh_all_button_roles"), patch.object(StudioSettings, "load", return_value=StudioSettings(theme_mode="nord")), patch.object(StudioSettings, "save") as unchanged_save, diff --git a/tests/core/test_theme.py b/tests/core/test_theme.py index cb64a02c..c2085e0d 100644 --- a/tests/core/test_theme.py +++ b/tests/core/test_theme.py @@ -3,9 +3,9 @@ from PySide6.QtGui import QPalette -from setuav_studio.ui.icons import application_icon, get_icon from setuav_studio.ui.settings.settings_pages import StudioSettings -from setuav_studio.ui.theme import ( +from setuav_studio.ui.style.icons import application_icon, get_icon +from setuav_studio.ui.style.theme import ( ACCENT_COLOR, DEFAULT_FONT_SIZE, FONT_FAMILY, @@ -42,7 +42,7 @@ def test_application_icon_is_available(self) -> None: self.assertFalse(application_icon().isNull()) def test_theme_mode_switching_and_tokens(self) -> None: - from setuav_studio.ui.theme import ( + from setuav_studio.ui.style.theme import ( BLENDER_TOKENS, DARK_TOKENS, LIGHT_TOKENS, @@ -83,7 +83,7 @@ def test_theme_mode_switching_and_tokens(self) -> None: set_theme_mode("dark") def test_application_palette_and_existing_icons_follow_theme(self) -> None: - from setuav_studio.ui.theme import apply_theme, tokens + from setuav_studio.ui.style.theme import apply_theme, tokens app = get_qapp() @@ -108,7 +108,7 @@ def test_application_palette_and_existing_icons_follow_theme(self) -> None: def test_chart_series_and_axes_are_rethemed(self) -> None: from plugins.aerodynamics.charts_dock import SingleChartWidget - from setuav_studio.ui.theme import apply_theme, chart_color, tokens + from setuav_studio.ui.style.theme import apply_theme, chart_color, tokens app = get_qapp() apply_theme(app, "dark") diff --git a/tests/core/test_workspaces.py b/tests/core/test_workspaces.py index 5561dc81..6f3225f4 100644 --- a/tests/core/test_workspaces.py +++ b/tests/core/test_workspaces.py @@ -68,7 +68,7 @@ def test_status_bar_shows_colored_messages_and_auto_clears(self) -> None: from PySide6.QtCore import QTimer from PySide6.QtGui import QPalette - from setuav_studio.ui.theme import status_color + from setuav_studio.ui.style.theme import status_color api = StudioAPI() win = MainWindow(api) diff --git a/tests/geometry/test_geometry.py b/tests/geometry/test_geometry.py index aee34cb5..7b0dafbc 100644 --- a/tests/geometry/test_geometry.py +++ b/tests/geometry/test_geometry.py @@ -6,6 +6,8 @@ from typing import Any from unittest import mock +from PySide6.QtCore import Qt + from plugins.geometry.data import GeometryData, LoftGeometry, Section from plugins.geometry.fuselage_geometry import ( SECTION_SAMPLES, @@ -17,8 +19,6 @@ ) from plugins.geometry.mesh import build_loft_solid_vertices from plugins.geometry.scene import build_project_geometry -from PySide6.QtCore import Qt - from setuav_studio.project import ProjectDocument, open_project from tests._common import TEST_PROJECT_PATH, get_qapp @@ -199,7 +199,6 @@ def test_solid_mesh_contains_triangle_vertices(self) -> None: def test_lifting_surface_editor_population_and_metrics(self) -> None: from plugins.geometry.lifting_surface import LiftingSurfaceEditor - from setuav_studio.api import StudioAPI get_qapp() @@ -531,7 +530,6 @@ def test_control_surface_cutouts_and_deflection(self) -> None: from plugins.geometry.lifting_surface_geometry import ( build_lifting_surface_geometry, ) - from setuav_studio.project import ProjectDocument wing_component = { @@ -886,7 +884,6 @@ def test_fuselage_wing_root_stub_geometry(self) -> None: def test_control_surface_editor(self) -> None: from plugins.geometry.control_surface import ControlSurfaceEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -934,7 +931,6 @@ def test_control_surface_editor(self) -> None: def test_control_surface_sizing_modes_and_live_sync(self) -> None: from plugins.geometry.lifting_surface import LiftingSurfaceEditor - from setuav_studio.api import StudioAPI from setuav_studio.project import ProjectDocument @@ -1011,7 +1007,6 @@ def test_control_surface_add_delete_no_duplication(self) -> None: build_lifting_surface_geometry, ) from plugins.geometry.scene import build_project_geometry - from setuav_studio.api import StudioAPI from setuav_studio.project import ProjectDocument @@ -1273,7 +1268,6 @@ def test_wing_tip_caps_flat_round_sharp(self) -> None: def test_lifting_surface_editor_tip_caps_ui(self) -> None: """Verify LiftingSurfaceEditor tip caps table interactions and project mutation.""" from plugins.geometry.lifting_surface import LiftingSurfaceEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -1436,7 +1430,6 @@ def test_fuselage_section_dialog_and_metrics(self) -> None: FuselageCanvasWidget, FuselageSectionDialog, ) - from setuav_studio.api import StudioAPI # 1. Test geometric metrics calculation @@ -1672,7 +1665,7 @@ def test_nowheel_combobox_and_filter(self) -> None: from PySide6.QtGui import QWheelEvent from PySide6.QtWidgets import QComboBox - from setuav_studio.ui.theme import ComboBoxWheelFilter + from setuav_studio.ui.style.theme import ComboBoxWheelFilter from setuav_studio.ui.widget.spinbox import NoWheelComboBox # 1. Test NoWheelComboBox ignores wheelEvent @@ -1789,9 +1782,10 @@ def test_airfoil_dialog_naca_generation(self) -> None: def test_airfoil_dialog_dat_import(self) -> None: """Verify .dat import populates coordinates table, canvas, and spec.""" - from plugins.geometry.airfoil_dialog import AirfoilDialog from PySide6.QtWidgets import QFileDialog + from plugins.geometry.airfoil_dialog import AirfoilDialog + with tempfile.TemporaryDirectory() as tmp: dat_path = Path(tmp) / "custom.dat" dat_path.write_text( @@ -1852,7 +1846,6 @@ def test_airfoil_dialog_category_filter_and_apply_semantics(self) -> None: def test_fuselage_editor_population(self) -> None: """Verify FuselageEditor loads general info, segments, sections, and transforms.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -1894,7 +1887,6 @@ def test_fuselage_editor_population(self) -> None: def test_fuselage_editor_segment_actions(self) -> None: """Verify add/duplicate/move/delete segment mutations.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -1931,7 +1923,6 @@ def test_fuselage_editor_segment_actions(self) -> None: def test_fuselage_editor_section_actions(self) -> None: """Verify add/duplicate/move/delete section mutations and x interpolation.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -1973,7 +1964,6 @@ def test_fuselage_editor_section_actions(self) -> None: def test_fuselage_editor_profile_transform_and_vertices(self) -> None: """Verify profile type change, numeric property, transform, and polygon vertex edits.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -2024,7 +2014,6 @@ def test_fuselage_editor_profile_transform_and_vertices(self) -> None: def test_fuselage_editor_general_and_segment_edits(self) -> None: """Verify general name/mass edits and segment tag/loft choice edits.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI() @@ -2057,7 +2046,6 @@ def test_fuselage_editor_general_and_segment_edits(self) -> None: def test_fuselage_editor_property_expression_changes(self) -> None: """Verify editing section properties via expressions or numbers applies correctly.""" from plugins.geometry.fuselage import FuselageEditor - from setuav_studio.api import StudioAPI api = StudioAPI()