diff --git a/.github/workflows/ci-beta.yml b/.github/workflows/ci-beta.yml new file mode 100644 index 00000000..b7ade026 --- /dev/null +++ b/.github/workflows/ci-beta.yml @@ -0,0 +1,138 @@ +# Beta-tier proxy, BHoM variant. Bundles the tier's checks plus +# copyright-compliance (BHoM enforces OSS copyright; the BHE variant omits it). +# Copy to .github/workflows/ci-beta.yml in each BHoM beta repo. +# Runs on pull_request, non-blocking until a ruleset requires its checks. +# Workflow name CI keeps check contexts bare: required contexts are the job names. + +name: CI + +on: + pull_request: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +jobs: + ci-build: + runs-on: windows-2025-vs2026 + timeout-minutes: 30 + steps: + - name: Run build + uses: BHoM/CI_Toolkit/.github/actions/ci-build@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-code-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run code compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: code + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-copyright-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run copyright compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: copyright + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-dataset-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run dataset compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: dataset + patterns: ':(icase)*datasets*.json' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-documentation-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run documentation compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: documentation + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-project-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run project compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: project + patterns: '*AssemblyInfo.cs *.csproj' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-dataset-tests: + runs-on: windows-2025-vs2026 + timeout-minutes: 30 + steps: + - name: Run dataset tests + uses: BHoM/CI_Toolkit/.github/actions/ci-dataset-tests@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-serialisation: + runs-on: windows-2025-vs2026 + timeout-minutes: 90 + steps: + - name: Run serialisation + uses: BHoM/CI_Toolkit/.github/actions/ci-serialisation@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + base_ref: ${{ github.base_ref }} + + ci-versioning: + runs-on: windows-2025-vs2026 + timeout-minutes: 90 + steps: + - name: Run versioning + uses: BHoM/CI_Toolkit/.github/actions/ci-versioning@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} diff --git a/Python_Engine/Compute/BasePythonEnvironment.cs b/Python_Engine/Compute/BasePythonEnvironment.cs index fa90129e..8ec03656 100644 --- a/Python_Engine/Compute/BasePythonEnvironment.cs +++ b/Python_Engine/Compute/BasePythonEnvironment.cs @@ -60,7 +60,11 @@ public static PythonEnvironment BasePythonEnvironment( bool exists = File.Exists(targetExecutable); if (exists && reload) - return new PythonEnvironment() { Name = Query.ToolkitName(), Executable = targetExecutable }; + { + PythonEnvironment env = new PythonEnvironment() { Name = Query.ToolkitName(), Executable = targetExecutable }; + UpdateBHoMPackages(env); + return env; + } if (exists && !reload) // remove all existing environments and kernels diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs new file mode 100644 index 00000000..c1f3024f --- /dev/null +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -0,0 +1,110 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using BH.oM.Base; +using BH.oM.Base.Attributes; +using BH.oM.Python; +using BH.oM.Python.Enums; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +namespace BH.Engine.Python +{ + public static partial class Compute + { + [Description("Using the environment name (and version if the environment name is the base python environment), directly install/update missing BHoM packages.")] + [Input("environmentName", "The name of the environment to update.")] + [Input("version", "If the environment name is the name of the base python environment, the version that needs updating.")] + public static void UpdateBHoMPackages(this string environmentName, PythonVersion version = PythonVersion.Undefined) + { + //construct the expected PythonEnvironment and pass to UpdateBHoMPackages + PythonEnvironment env = new PythonEnvironment() + { + Name = environmentName, + }; + + //python version is only used if the environment name is this toolkits name. + if (environmentName == Query.ToolkitName()) + { + if (version == PythonVersion.Undefined) + { + BH.Engine.Base.Compute.RecordError("The version must be specified when updating BHoM packages for a base python environment. No updates have occurred."); + return; + } + + env.Executable = Path.Combine(Query.DirectoryBaseEnvironment(version), "python.exe"); + } + else + env.Executable = Query.VirtualEnvironmentExecutable(environmentName); + + UpdateBHoMPackages(env); + } + + /***************************************************/ + + [Description("Using a PythonEnvironment, directly install/update missing BHoM packages.")] + [Input("environment", "The python environment to update.")] + public static void UpdateBHoMPackages(this PythonEnvironment environment) + { + if (!File.Exists(environment.Executable)) + { + BH.Engine.Base.Compute.RecordError($"Given environment or base install {environment.Executable} does not exist."); + return; + } + + //`python -m pip list -e --format json` lists all environment packages that are editable installs. + //As all BHoM packages are editable installs, this handily lists all the packages that should be updated if necessary. + //side effect of updating other editable installs if they have changed, but I suspect this won't be a problem as anyone who has these would update packages manually instead. + System.Diagnostics.Process process = new System.Diagnostics.Process() + { + StartInfo = new System.Diagnostics.ProcessStartInfo() + { + FileName = environment.Executable, + Arguments = $"-m pip list -e --format json", + UseShellExecute = false, + RedirectStandardOutput = true, + } + }; + + process.StartInfo.Environment["PYTHONHOME"] = ""; + string stdOut; + + using (Process p = Process.Start(process.StartInfo)) + { + stdOut = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); + } + + stdOut = stdOut.TrimEnd('\r', '\n'); + + IEnumerable objs = Serialiser.Convert.FromJsonArray(stdOut).OfType(); + + foreach (CustomObject obj in objs) + InstallPackageLocal(environment, (string)obj.CustomData["editable_project_location"]); + } + } +} \ No newline at end of file diff --git a/Python_Engine/Compute/VirtualEnvironment.cs b/Python_Engine/Compute/VirtualEnvironment.cs index 1e02dad5..6ed9dde7 100644 --- a/Python_Engine/Compute/VirtualEnvironment.cs +++ b/Python_Engine/Compute/VirtualEnvironment.cs @@ -60,7 +60,11 @@ public static PythonEnvironment VirtualEnvironment(this PythonVersion version, s bool exists = Query.VirtualEnvironmentExists(name); if (exists && reload) - return new PythonEnvironment() { Name = name, Executable = targetExecutable }; + { + PythonEnvironment env = new PythonEnvironment() { Name = name, Executable = targetExecutable }; + UpdateBHoMPackages(env); + return env; + } if (exists && !reload) { diff --git a/Python_Engine/Python/Dockerfile b/Python_Engine/Python/Dockerfile index 349adb60..dde75b24 100644 --- a/Python_Engine/Python/Dockerfile +++ b/Python_Engine/Python/Dockerfile @@ -1,7 +1,22 @@ FROM python:3.10-slim - COPY . /Python_Toolkit +#requires build argument --build-context bhom-assemblies C:/ProgramData/BHoM/Assemblies +COPY --from=bhom-assemblies . /bin/BHoM/Assemblies + +#install .net core 8.0, and set the pythonnet runtime to the .net core CLR +RUN apt-get update \ + && apt-get install -y wget + +RUN wget https://packages.microsoft.com/config/debian/13/packages-microsoft-prod.deb -O packages-microsoft-prod.deb +RUN dpkg -i packages-microsoft-prod.deb +RUN rm packages-microsoft-prod.deb + +RUN apt-get update \ + && apt-get install -y aspnetcore-runtime-10.0 + +ENV PYTHONNET_RUNTIME="coreclr" + RUN pip3 install ./Python_Toolkit RUN rm ./Python_Toolkit -rf \ No newline at end of file diff --git a/Python_Engine/Python/pyproject.toml b/Python_Engine/Python/pyproject.toml index 6a0be4e7..6db0bd6c 100644 --- a/Python_Engine/Python/pyproject.toml +++ b/Python_Engine/Python/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pytest-cov>=6.0.0", "pytest-order", "virtualenv", + "pythonnet", ] [urls] diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index bc0efd26..e3ab0b23 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -6,9 +6,15 @@ import tempfile import importlib.metadata +if os.name == 'nt': + from .installer_info import INSTALLER_INFO +else: + INSTALLER_INFO = {"Version": importlib.metadata.version("python_toolkit")} + BHOM_LOG_FOLDER = Path(path.expandvars("%PROGRAMDATA%/BHoM/Logs")) +TEMP_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" TOOLKIT_NAME = "Python_Toolkit" -BHOM_VERSION = importlib.metadata.version("python_toolkit") +BHOM_VERSION = INSTALLER_INFO["Version"] #Environment variable that if set disables BHoM analytics logging. DISABLE_ANALYTICS = os.environ.get("DISABLE_BHOM_ANALYTICS", None) @@ -18,5 +24,18 @@ DISABLE_ANALYTICS = True if not BHOM_LOG_FOLDER.exists(): - BHOM_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" - BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) \ No newline at end of file + + try: + BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + + #migration recovery for any logs in the temp folder + if TEMP_LOG_FOLDER.exists(): + for file in TEMP_LOG_FOLDER.glob("*.log"): + file.rename(BHOM_LOG_FOLDER / file.name) + + except Exception as e: + BHOM_LOG_FOLDER = TEMP_LOG_FOLDER + BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + + + diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index 4b684a3e..7ba81cb9 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -71,6 +71,8 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: usage_log_entries.sort(key=lambda x: x.ProjectID) + short_bhom_version = ".".join(BHOM_VERSION.split(".", 2)[0:2]) + for file_id, filegroup in groupby(usage_log_entries, lambda x: x.FileId): filegroup = list(filegroup) project_id = filegroup[0].ProjectID @@ -89,15 +91,17 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: "CallerName": first_entry.CallerName, "SelectedItem": first_entry.SelectedItem, "Computer": socket.gethostname(), - "UserName": os.environ.get("USERNAME"), - "BHoMVersion": BHOM_VERSION, + "Username": os.environ.get("USERNAME"), + "BHoMVersion": short_bhom_version, "FileId": file_id, "FileName": filename, "ProjectID": project_id, "NbCallingComponents": len(set([a.ComponentId for a in methodgroup])), - "TotalNbCals": len(methodgroup), + "TotalNbCalls": len(methodgroup), "Errors": list(itertools.chain.from_iterable([x.Errors for x in methodgroup])), - "_t": "BH.oM.BHoMAnalytics.UsageEntry" + "_t": "BH.oM.BHoMAnalytics.UsageEntry", + "__Time__": datetime.now(), + "_bhomVersion": short_bhom_version }) return db_entries @@ -150,7 +154,7 @@ def decorator(function: Callable): @wraps(function) def wrapper(*args, **kwargs) -> Any: """A wrapper around the function that captures usage analytics.""" - + if disable: CONSOLE_LOGGER.debug("bhom_analytics is curently disabled.") return function(*args, **kwargs) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py new file mode 100644 index 00000000..10785de5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -0,0 +1,312 @@ +from base64 import decode +import copy +from ctypes import ArgumentError +from datetime import datetime +from json import encoder +from pathlib import Path +import uuid +import re +from typing import Any, List, Dict, Union +import json +from json import JSONEncoder, JSONDecoder +from .logging import CONSOLE_LOGGER +from . import BHOM_VERSION +from .util import bson_unix_ticks, bson_unix_ticks_to_datetime +import pandas as pd +import numpy as np + +BHOM_SHORT_VERSION = ".".join(BHOM_VERSION.split(".")[0:2]) + +def convert_pascal_to_camel(s: str): + """Converts a string to camel_case.""" + sections = re.split("(?<=.)(?=[A-Z])", s) #zero-length match before capitals, skipping capital at the 0th index + parts = [] + for sec in sections: + parts.append(sec.lower()) + + return "_".join(parts) + +def convert_camel_to_pascal(s: str): + """Converts a string to PascalCase, ignoring _ if it is the first character.""" + sections = re.split(r'(?<=.)_', s) #match all `_` except if it is the first character. + + parts = [] + for sec in sections: #capitalise each section unless the section is empty (in which case, append an underscore) or the section starts with an underscore (first section can begin with _) + if sec == "": + parts.append("_") + continue + elif sec.startswith("_"): + parts.append(sec) + continue + + parts.append(sec.capitalize()) + + return ''.join(parts) + +class BHoMJSONDecoder(JSONDecoder): + def __init__(self, *args, **kwargs): + json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, d): + if "$date" in d: + CONSOLE_LOGGER.debug(f"deserialising timestamp {d}") + return bson_unix_ticks_to_datetime(d["$date"]) + + if "_t" not in d: + CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") + return self.deserialise_unknown(d) + + props = { + "_t": d.pop("_t"), + "_bhom_version": d.pop("_bhomVersion", BHOM_SHORT_VERSION) + } + + if (props["_bhom_version"] is not None) and (props["_bhom_version"] != BHOM_SHORT_VERSION): + CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props['_bhom_version']}) is different from the BHoM version that python_toolkit was installed with ({BHOM_SHORT_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") + + if d.get("BHoM_Guid", None) is not None: + #deserialise as BHoM Object + + #get default BHoMObject properties and replace with defaults if not present + props["name"] = d.pop("Name", "") + props["bhom_guid"] = uuid.UUID(d.pop("BHoM_Guid")) + props["tags"] = d.pop("Tags", []) + props["fragments"] = d.pop("Fragments", []) + props["custom_data"] = d.pop("CustomData", {}) + + #convert all other properties to camel_case as python users expect + for prop_name in d: + props[convert_pascal_to_camel(prop_name)] = d[prop_name] + + return self.deserialise_unknown(BHoMObject(**props)) + else: + if props["_t"].startswith("System.Collections.Generic.List"): #this handles when the BHoM serialiser makes generic lists from CustomObject list properties, which get converted to a dictionary. The BHoM serialiser does recognise lists if it can find the object definition via reflection. + return d["_v"] + + #deserialise as IObject + for prop_name in d: + props[convert_pascal_to_camel(prop_name)] = d[prop_name] + + return self.deserialise_unknown(IObject(**props)) + + def deserialise_unknown(self, obj: Union['IObject', 'BHoMObject', dict]): + """Override this method in a subclass to add extra object hook on top of the existing method. If the object is an object serialised by the BHoM serialiser, the output object will be a BHoMObject or IObject, so use isinstance() to select.""" + return obj + +class BHoMJSONEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, BHoMObject): + #initialise special BHoMObject properties + + props = { + "Name": o.name, + "BHoM_Guid": str(o.bhom_guid), + "_t": o._t + } + + if len(o.tags) > 0: + props["Tags"] = o.tags + + if len(o.fragments) > 0: + props["Fragments"] = o.fragments + + if len(o.custom_data) > 0: + props["CustomData"] = o.custom_data + + if o._bhom_version is not None: + props["_bhomVersion"] = o._bhom_version + + #get property names with reflection and convert all properties to PascalCase as the BHoM serialiser expects + for prop_name, value in vars(o).copy().items(): + if prop_name in ["name", "bhom_guid", "tags", "fragments", "custom_data", "_t", "_bhom_version"]: + continue + + props[convert_camel_to_pascal(prop_name)] = value + + return props + elif isinstance(o, IObject): + props = { + "_t": o._t + } + + if o._bhom_version is not None: + props["_bhomVersion"] = o._bhom_version + + for prop_name, value in vars(o).copy().items(): + if prop_name in ["_t", "_bhom_version"]: + continue + + props[convert_camel_to_pascal(prop_name)] = value + + return props + #handle common non-serialisable types + elif isinstance(o, uuid.UUID): #UUID object is not json serialisable by default + return str(o) + elif isinstance(o, pd.DatetimeIndex): + return [date.isoformat() for date in o] + elif isinstance(o, np.ndarray): + return o.tolist() + elif isinstance(o, pd.Timestamp): + return {"$date": bson_unix_ticks(o.to_pydatetime(), True)} + elif isinstance(o, datetime): + return {"$date": bson_unix_ticks(o, True)} + elif isinstance(o, pd.Series): + return dict(zip(o.index.astype(str), o)) + elif isinstance(o, Path): + return { "FileName": str(o.name), "Directory": str(o.parent), "_t": "BH.oM.Adapter.FileSettings", "BHoM_Guid": str(uuid.uuid4()) } + + return self.serialise_unknown(o) #if the object is unknown at this point, call serialise_unknown to allow subclasses to add their own serialisation logic. + + def serialise_unknown(self, obj: Any) -> Union[Dict[str, str], str, List[str], Any]: + """ + This is called by the BHoMJSONEncoder to serialise unknown objects so that the BHoM can handle them if they exist as BHoM Objects in c# but not in python (i.e. an object from an external library). + + Override this method in a sub class with its own logic, and call super().serialise_unknown(obj) for objects that are unknown, or do not wish to serialise. + The following objects won't be passed to this method: BHoMObject, IObject, uuid.UUID, pandas.DatetimeIndex, numpy.ndarray, pandas.Timestamp, pandas.Series, pathlib.Path + """ + return super(type(self), self).default(obj) #fallback to default json decoder (ValueError) if object is not a BHoMObject or common serialisable type. + +class IObject: + """More generic version of BHoMObject, for non-native objects serialised by the BHoM serialiser, but do not inherit from BHoMObject.""" + _t: str + _bhom_version: str + + def __init__( + self, + _t: str, + _bhom_version: str = None, + **kwargs + ) -> 'IObject': + self._t = _t + self._bhom_version = _bhom_version + + #set properties with reflection. + for kwarg in kwargs: + setattr(self, kwarg, kwargs[kwarg]) + + def __repr__(self) -> str: + return f"{type(self).__name__} of type {self._t}, version: '{getattr(self, '_bhom_version', 'Unknown')}'" + + def __eq__(self, other) -> bool: + if not isinstance(other, IObject): + return False + + if self._t != other._t: + return False + + vself = vars(self).copy() + vother = vars(other).copy() + + #ignore these properties when comparing by property. + ignore = ["_bhom_version"] + _ = [(vself.pop(p, None), vother.pop(p, None)) for p in ignore] + + return vself == vother + + @classmethod + def from_json(cls, j: str, decoder_class: type = BHoMJSONDecoder) -> 'IObject': + obj = json.loads(j, cls=decoder_class) + + if not isinstance(obj, cls): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. + raise TypeError("The object provided does not deserialise to a valid BHoM object.") + + return obj + + def to_json(self, encoder_class: type = BHoMJSONEncoder) -> str: + return json.dumps(self, cls=encoder_class) + + @classmethod + def from_dict(cls, d: dict) -> 'IObject': + try: + return cls(**d) #should be valid as long as the dictionary has all necessary entries. + except ArgumentError as ae: + raise ArgumentError("Input dictionary was missing some required arguments, see traceback for more information.") from ae + + def to_dict(self, encoder_class: type = BHoMJSONEncoder) -> dict: + """Convert this IObject to a dictionary via deep copying vars(self).""" + return copy.deepcopy(vars(self)) + +class BHoMObject(IObject): + name: str + bhom_guid: uuid.UUID + tags: List[str] + fragments: List[Dict[str, object]] + custom_data: Dict[str, object] + + def __init__( + self, + _t: str, #don't make default, as subclasses of this class should set this with super().__init__ + name: str = "", + bhom_guid: uuid.UUID = uuid.uuid4(), + tags: List[str] = [], + fragments: List[Dict[str, object]] = [], + custom_data: Dict[str, object] = {}, + _bhom_version: str = None, + **kwargs + ) -> 'BHoMObject': + self._t = _t + self.name = name + self.bhom_guid = bhom_guid + self.fragments = fragments + self.tags = tags + self.custom_data = custom_data + self._bhom_version = _bhom_version + + #set non-CustomData properties with reflection. + for kwarg in kwargs: + setattr(self, kwarg, kwargs[kwarg]) + + def __repr__(self) -> str: + return f"{type(self).__name__} of type {self._t}, name: '{self.name}', version: '{getattr(self, '_bhom_version', 'Unknown')}', id: '{self.bhom_guid}'" + + def __eq__(self, other) -> bool: + if not isinstance(other, BHoMObject): + return False + + if self._t != other._t: + return False + + vself = vars(self).copy() + vother = vars(other).copy() + + #ignore these properties when comparing by property. + ignore = ["bhom_guid", "_bhom_version"] + _ = [(vself.pop(p, None), vother.pop(p, None)) for p in ignore] + + return vself == vother + + @classmethod + def from_json(cls, j: str, decoder_class: type = BHoMJSONDecoder): + obj = json.loads(j, cls=decoder_class) + + if isinstance(obj, list): + CONSOLE_LOGGER.warning("The root element of the JSON provided was a list, assuming that the first item is the desired object. If you intended to deserialise a list, please use the `.from_json_array(j)` method instead.") + obj = obj[0] + + if issubclass(cls, BHoMObject) and cls != BHoMObject: + obj = cls._from_bhom_object(obj) + + if not isinstance(obj, cls): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. + raise TypeError("The object provided does not deserialise to a valid BHoM object.") + + return obj + + @classmethod + def from_json_array(cls, j: str, decoder_class: type = BHoMJSONDecoder): + objs = json.loads(j, cls=decoder_class) + + if not isinstance(objs, list): + raise TypeError("The root element of the JSON provided was not a JSON array. Perhaps you intended to use `.from_json(j)` instead?") + + out = [] + for obj in objs: + if issubclass(cls, BHoMObject) and cls != BHoMObject: + out.append(cls._from_bhom_object(obj)) + continue + out.append(obj) + + return out + + @classmethod + def _from_bhom_object(cls, o: 'BHoMObject'): + return cls(**vars(o).copy()) #assuming that the sub class is correctly set up, then this should work \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py new file mode 100644 index 00000000..70cea52d --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py @@ -0,0 +1 @@ +from ._bhom_callable_decorator import bhom_wrapper \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py new file mode 100644 index 00000000..436ebcb9 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -0,0 +1,89 @@ +import json +from typing import Any, Callable, Union, Dict +from functools import wraps +from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject, IObject + +class _BHoMWrapper: + _registered_methods: Dict[str, Callable] = {} + + def bhom_callable(self, identifier: str, argument_types:Dict[str, type] = {}, encoder_cls: type = BHoMJSONEncoder, decoder_cls: type = BHoMJSONDecoder): + """Decorator for functions to be made callable from BHoM C# methods/adapters. + + Note: methods that this wraps must not have "__input_json__" as a default kwarg, as this is used internally to allow BHoM adapters to call the method. + + when __input_json__ is set, this will cause the method to always output BHoM style json (using the encoder_cls provided to this decorator) + + Args: + argument_types (dict[str, type]): this is a dictionary that is used to map the argument names to types (specifically BHoMObject types) to subclasses of BHoMObjects. + For example, if you have a class that is a subclass of BHoMObject, the default serialiser will only deserialise json to a BHoMObject. + To go the extra step to get your class, you must provide the type in this dictionary to allow the wrapper to convert the BHoMObject type to your desired type. + + encoder_cls (JSONEncoder): A JSONEncoder (ideally one that is a subclass of BHoMJSONEncoder). Mainly this is for if a custom encoder has been implemented for a specific toolkit. + + decoder_cls (JSONDecoder): same as encoder_cls but for JSONDecoder. + """ + def decorator(function: Callable): + + @wraps(function) + def wrapper(*args, **kwargs) -> Union[str, Any]: + + do_wrap:bool = False + + if "__input_json__" in kwargs and len(args) == 0: + do_wrap = True + + #get dictionary from input as file path or json like string. + input_json = kwargs.pop("__input_json__") + + if not input_json.startswith("{"): #assume it's a path + with open(input_json, "r") as f: + input_json = f.read() + + try: + json_kwargs: Union[dict, IObject] = json.loads(input_json, cls=decoder_cls) + if isinstance(json_kwargs, IObject): + json_kwargs = json_kwargs.to_dict() + except: + CONSOLE_LOGGER.error("Could not load JSON from file or string due to invalid JSON. Attempting to run with given args and kwargs.", exc_info=1) + + #update kwargs with json + for kwarg_name in json_kwargs: + val = json_kwargs[kwarg_name] + + if kwarg_name in argument_types: + t = argument_types[kwarg_name] + + if issubclass(t, BHoMObject) and type(json_kwargs[kwarg_name]) is BHoMObject: + val = t._from_bhom_object(json_kwargs[kwarg_name]) + + kwargs[kwarg_name] = val + + rtn = function(*args, **kwargs) + + if do_wrap: + #don't bother serialising if the method already returns a string. + if isinstance(rtn, str): + return rtn + + json_rtn = json.dumps(rtn, cls=encoder_cls) + + return json_rtn + + return rtn + + self._registered_methods[identifier] = wrapper + return wrapper + + return decorator + + def get_registered_method(self, method_identifier: str): + method = self._registered_methods.get(method_identifier, None) + + if method is None: + raise NotImplementedError(f"The requested method {method_identifier} is not implemented or could not be found.") + + return method + +#the registered methods are stored as a class attribute, so this isn't actually needed +#but this is easier to use, otherwise a new instance must be created every time a method needs to be wrapped +bhom_wrapper = _BHoMWrapper() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py new file mode 100644 index 00000000..10f1b76d --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py @@ -0,0 +1,17 @@ +import os +import sys +import json + +import clr + +#append assemblies dir +sys.path.append(os.path.expandvars("%ProgramData%\\BHoM\\Assemblies")) + +#add required CLR references (dotnet dlls) +clr.AddReference("UI_Engine") +clr.AddReference("Serialiser_Engine") + +from BH.Engine.UI import Query +from BH.Engine.Serialiser import Convert + +INSTALLER_INFO = json.loads(Convert.ToJson(Query.Information())) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py b/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py new file mode 100644 index 00000000..2a21c362 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py @@ -0,0 +1,7 @@ +from .decorators import bhom_wrapper +from . import wrapped + +def run_wrapped(identifier: str, json: str): + method = bhom_wrapper.get_registered_method(identifier) + + print(method(__input_json__ = json)) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py new file mode 100644 index 00000000..10636f63 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py @@ -0,0 +1,13 @@ +from importlib import import_module +from pathlib import Path +import os + +python_files = Path(__file__).parent.glob("**/*.py") + +for file in python_files: + if file.name == "__init__.py": + continue + + rel = file.relative_to(Path(__file__).parent) + module = "." + str(rel).replace(".py", "").replace(os.path.sep, ".") + import_module(module, "python_toolkit.bhom.wrapped") \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py new file mode 100644 index 00000000..ac0764f1 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py @@ -0,0 +1,10 @@ +from python_toolkit.plot.heatmap import heatmap +from ..decorators import bhom_wrapper +import pandas as pd + +@bhom_wrapper.bhom_callable("test") +def heatmap_2(geometry, **kwargs): + print(geometry) + print(kwargs["arg2"]) + geometry.x = 20 + return geometry diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py index db2b5827..0dbeb309 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -13,7 +13,9 @@ RadioSelection, ValidatedEntryBox, ) +from .bhom_base_child_window import BHoMBaseChildWindow from .windows import ( + BHoMModalWindow, DirectoryFileSelector, LandingPage, ProcessingWindow, @@ -41,6 +43,8 @@ "PathSelector", "RadioSelection", "ValidatedEntryBox", + "BHoMBaseChildWindow", + "BHoMModalWindow", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py new file mode 100644 index 00000000..3946fd88 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py @@ -0,0 +1,148 @@ +"""Themed Toplevel base class for BHoM child windows and dialogs.""" + +from __future__ import annotations + +import tkinter as tk +from typing import Callable, Literal, List, Optional + +from python_toolkit.bhom_tkinter.bhom_window_shell import BHoMWindowShell +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class BHoMBaseChildWindow(tk.Toplevel, BHoMWindowShell): + """Themed child window for modals and standalone dialogs.""" + + def __init__( + self, + parent: tk.Misc | None = None, + *, + title: str = "Application", + min_width: int = 400, + min_height: int = 400, + width: Optional[int] = None, + height: Optional[int] = None, + resizable: bool = True, + center_on_screen: bool = True, + show_submit: bool = True, + submit_text: str = "Submit", + submit_command: Optional[Callable] = None, + close_on_submit: bool = True, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable] = None, + on_close_window: Optional[Callable] = None, + theme_mode: str | None = None, + widgets: Optional[List[BHoMBaseWidget]] = None, + top_most: bool = False, + fullscreen: bool = False, + buttons_side: Literal["left", "right"] = "right", + grid_dimensions: Optional[tuple[int, int]] = None, + show_banner: bool = False, + defer_show: bool = False, + content_padding: int = 20, + modal: bool = False, + **kwargs, + ) -> None: + self._standalone_root: tk.Tk | None = None + self._modal = modal + self._modal_closed = False + + if parent is None: + self._standalone_root = tk.Tk() + self._standalone_root.withdraw() + parent = self._standalone_root + elif theme_mode is None and hasattr(parent, "theme"): + theme_mode = "dark" if getattr(parent.theme, "dark_theme", False) else "light" + + if theme_mode is None: + theme_mode = "auto" + + super().__init__(parent, **kwargs) + if parent is not self._standalone_root: + self.transient(parent) + + self._init_bhom_shell( + title=title, + min_width=min_width, + min_height=min_height, + width=width, + height=height, + resizable=resizable, + center_on_screen=center_on_screen, + show_submit=show_submit, + submit_text=submit_text, + submit_command=submit_command, + close_on_submit=close_on_submit, + show_close=show_close, + close_text=close_text, + close_command=close_command, + on_close_window=on_close_window, + theme_mode=theme_mode, + widgets=widgets, + top_most=top_most, + fullscreen=fullscreen, + buttons_side=buttons_side, + grid_dimensions=grid_dimensions, + show_banner=show_banner, + defer_show=defer_show, + content_padding=content_padding, + ) + + if modal: + self.protocol("WM_DELETE_WINDOW", self.close) + + host = parent if parent is not self._standalone_root else None + if host is not None and hasattr(host, "theme"): + self.theme = host.theme + self._load_theme() + self._set_window_icon() + + def mainloop(self, n: int = 0) -> None: + """Run the event loop for standalone dialogs, or block until closed.""" + if self._standalone_root is not None: + self._standalone_root.mainloop(n) + return + self.wait_window() + + def show(self) -> None: + """Display the child window and optionally capture input.""" + self._show_window_with_styling() + if self._modal: + self.grab_set() + try: + self.focus_force() + except Exception: + pass + + def run(self) -> None: + """Show a modal child window and block until it closes.""" + self.show() + self.wait_window() + + def close(self) -> None: + """Close a modal child window and release the input grab.""" + if self._modal_closed: + return + self._modal_closed = True + self._on_close() + + def destroy_root(self) -> None: + """Destroy the child window without stopping a host application loop.""" + try: + self.grab_release() + except Exception: + pass + try: + if self.winfo_exists(): + self.destroy() + except tk.TclError: + pass + if self._standalone_root is not None: + try: + self._standalone_root.quit() + except Exception: + pass + try: + self._standalone_root.destroy() + except tk.TclError: + pass diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index 8318e655..7a37b35f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -25,8 +25,9 @@ from python_toolkit.bhom_tkinter.widgets.button import Button import python_toolkit from python_toolkit.bhom_tkinter.theming.theme import ThemeManager +from python_toolkit.bhom_tkinter.bhom_window_shell import BHoMWindowShell -class BHoMBaseWindow(tk.Tk): +class BHoMBaseWindow(tk.Tk, BHoMWindowShell): """ A reusable default root window template for tkinter applications. Includes a branded banner, content area, and optional action buttons. @@ -55,6 +56,9 @@ def __init__( fullscreen: bool = False, buttons_side: Literal["left", "right"] = "right", grid_dimensions: Optional[tuple[int, int]] = None, + show_banner: bool = True, + defer_show: bool = False, + content_padding: int = 20, **kwargs ): """ @@ -82,571 +86,38 @@ def __init__( fullscreen (bool): Whether the window starts in fullscreen mode (default: False). buttons_side (str): Side for buttons - "left" or "right" (default: "right"). grid_dimensions (tuple[int, int], optional): If provided, configures content area with specified rows and columns for grid layout. + show_banner (bool): Whether to render the branded banner header (default: True). + defer_show (bool): If True, size the window but stay withdrawn until shown manually. + content_padding (int): Padding applied to the content frame. **kwargs """ super().__init__(**kwargs) - self.title(title) - self._icon_image = None - self.minsize(min_width, min_height) - self.resizable(resizable, resizable) - - self.top_most = top_most - self.attributes("-topmost", True) - if not self.top_most: - self.after(0, lambda: self.attributes("-topmost", False)) - - self.fullscreen = fullscreen - - # Avoid sharing widget instances across windows/runs. - self.widgets = list(widgets) if widgets is not None else [] - - # Hide window during setup to prevent flash - self.withdraw() - - - self.theme = ThemeManager(theme_mode) - self._load_theme() - self._set_window_icon() - - self.min_width = min_width - self.min_height = min_height - self.fixed_width = width - self.fixed_height = height - self.center_on_screen = center_on_screen - self.submit_command = submit_command - self.close_on_submit = close_on_submit - self.close_command = close_command - self.result = None - self._is_exiting = False - self.button_bar: Optional[ttk.Frame] = None - self._has_been_shown = False - self._pending_resize_job: Optional[str] = None - self._is_resizing = False - self._rigid_width = width is not None - self._rigid_height = height is not None - self._auto_fit_width = width is None - self._auto_fit_height = height is None - self._post_show_size_applied = False - self.grid_dimensions = grid_dimensions - self._cached_widget_values: dict[str, object] = {} - - # Handle window close (X button) - self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) - - # Main container - self.main_container = ttk.Frame(self) - self.main_container.pack(fill=tk.BOTH, expand=True) - - # Banner section - self._build_banner(self.main_container, title, self.theme.logo_path) - - # Content area (public access for adding widgets) - self.content_frame = ttk.Frame(self.main_container, padding=20) - self.content_frame.pack(fill=tk.BOTH, expand=True) - - if self.grid_dimensions: - self.grid_content_frame(*self.grid_dimensions) - - # Bottom button frame (if needed) - if show_submit or show_close: - self._build_buttons(self.main_container, show_submit, submit_text, show_close, close_text, buttons_side) - - self._bind_dynamic_sizing() - - # Apply sizing - self._apply_sizing() - self.build() - - def grid_content_frame(self, x_count: int, y_count: int) -> None: - """Configure the content frame with a grid layout of specified dimensions.""" - self.grid_dimensions = (x_count, y_count) - for r in range(y_count): - self.content_frame.rowconfigure(r, weight=1) - for c in range(x_count): - self.content_frame.columnconfigure(c, weight=1) - - def build(self): - """Call build on all child widgets that have it (for deferred widget construction).""" - - if any(not isinstance(w, BHoMBaseWidget) for w in self.widgets): - raise TypeError("All items in widgets list must be instances of BHoMBaseWidget.") - - for widget in self.widgets: - widget.build() - - self.refresh_sizing() - - def _set_window_icon(self) -> None: - """Set a custom window icon, replacing Tk's default icon.""" - icon_path = self.theme.icon_path - - if not icon_path: - return - - # Windows prefers .ico for titlebar/taskbar icons. - if icon_path.suffix.lower() == ".ico": - try: - self.iconbitmap(default=str(icon_path)) - return - except tk.TclError: - pass - - # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). - try: - self._icon_image = tk.PhotoImage(file=str(icon_path)) - self.iconphoto(True, self._icon_image) - return - except tk.TclError: - pass - - except Exception as ex: - print(f"Warning: Could not set window icon from {icon_path}: {ex}") - - def _set_titlebar_theme(self) -> None: - """ - Apply titlebar theme using Windows API. - - Args: - theme_style: Theme style key (`light` or `dark`). - - Returns: - None - """ - try: - - use_dark = 1 if self.theme.dark_theme else 0 - - if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): - hwnd = self.winfo_id() - hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) - if hwnd: - DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - ctypes.windll.dwmapi.DwmSetWindowAttribute( - hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, - ctypes.byref(ctypes.c_int(use_dark)), - ctypes.sizeof(ctypes.c_int) - ) - - except Exception: - pass - - def _load_theme(self) -> str: - """ - Load a custom theme from a TCL file. - - Args: - custom_theme_path (Path, optional): Path to custom TCL theme file. - If None, uses default style.tcl in same directory. - theme_name (str): Name of the theme to apply from the TCL file. - - Returns: - str: Name of the theme that ended up being applied. - """ - style = ttk.Style() - - try: - current_themes = set(style.theme_names()) - - expected_theme = self.theme.path.stem.replace("_theme", "") - # Load the TCL theme file - try: - self.tk.call('source', str(self.theme.path)) - except tk.TclError as source_error: - if "already exists" not in str(source_error).lower(): - raise - - available_theme_names = style.theme_names() - newly_added = [name for name in available_theme_names if name not in current_themes] - if expected_theme in available_theme_names: - selected_theme = expected_theme - elif newly_added: - selected_theme = newly_added[-1] - else: - selected_theme = style.theme_use() if available_theme_names else "default" - - style.theme_use(selected_theme) - self._ensure_typography_styles(style) - return selected_theme - - except Exception as e: - print(f"Warning: Could not load custom theme: {e}") - try: - active_theme = style.theme_use() - self._ensure_typography_styles(style) - return active_theme - except Exception: - return "default" - - def _ensure_typography_styles(self, style: ttk.Style) -> None: - """Ensure key typography styles exist and remain visually distinct.""" - defaults = { - "TLabel": ("Segoe UI", 10, "bold"), - "Body.TLabel": ("Segoe UI", 10), - "Caption.TLabel": ("Segoe UI", 9), - "Small.TLabel": ("Segoe UI", 8), - "Heading.TLabel": ("Segoe UI", 12, "bold"), - "Subtitle.TLabel": ("Segoe UI", 14, "bold"), - "Headline.TLabel": ("Segoe UI", 16, "bold"), - "Title.TLabel": ("Segoe UI", 24, "bold"), - "LargeTitle.TLabel": ("Segoe UI", 24, "bold"), - "Display.TLabel": ("Segoe UI", 28, "bold"), - } - - def _lookup_font(style_name: str) -> str: - try: - return str(style.lookup(style_name, "font") or "").strip() - except Exception: - return "" - - for style_name, font_spec in defaults.items(): - if not _lookup_font(style_name): - try: - style.configure(style_name, font=font_spec) - except Exception: - pass - - base_font = _lookup_font("TLabel") - for style_name, font_spec in ( - ("Caption.TLabel", defaults["Caption.TLabel"]), - ("Subtitle.TLabel", defaults["Subtitle.TLabel"]), - ("Headline.TLabel", defaults["Headline.TLabel"]), - ("LargeTitle.TLabel", defaults["LargeTitle.TLabel"]), - ): - resolved = _lookup_font(style_name) - if not resolved or resolved == base_font: - try: - style.configure(style_name, font=font_spec) - except Exception: - pass - - def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: - """Build the branded banner section. - - Args: - parent: Parent frame to host the banner. - title: Banner title text. - logo_path: Optional logo image path. - """ - banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) - banner.pack(fill=tk.BOTH, padx=0, pady=0) - - banner_content = ttk.Frame(banner, padding=10) - banner_content.pack(fill=tk.BOTH, expand=True) - - # Text container - text_container = ttk.Frame(banner_content) - text_container.pack(side=tk.LEFT, fill=tk.Y) - - logo_container = ttk.Frame(banner_content, width=80) - logo_container.pack(side=tk.RIGHT, fill=tk.Y) - - # Logo (if provided) - if logo_path and logo_path.exists(): - try: - from PIL import Image, ImageTk - img = Image.open(logo_path) - img.thumbnail((80, 80), Image.Resampling.LANCZOS) - # Bind image to this root explicitly to avoid stale image handles - # when previous runs failed and tore down a different Tk interpreter. - self.logo_image = ImageTk.PhotoImage(img, master=self) - logo_label = Label(logo_container, image=self.logo_image) - logo_label.pack(fill=tk.BOTH, expand=True) - except tk.TclError: - pass - except ImportError: - pass # PIL not available, skip logo - - # Title - title_label = Label( - text_container, - text=title, - style="LargeTitle.TLabel" + self._init_bhom_shell( + title=title, + min_width=min_width, + min_height=min_height, + width=width, + height=height, + resizable=resizable, + center_on_screen=center_on_screen, + show_submit=show_submit, + submit_text=submit_text, + submit_command=submit_command, + close_on_submit=close_on_submit, + show_close=show_close, + close_text=close_text, + close_command=close_command, + on_close_window=on_close_window, + theme_mode=theme_mode, + widgets=widgets, + top_most=top_most, + fullscreen=fullscreen, + buttons_side=buttons_side, + grid_dimensions=grid_dimensions, + show_banner=show_banner, + defer_show=defer_show, + content_padding=content_padding, ) - title_label.pack(anchor="w") - - # Subtitle - subtitle_label = Label( - text_container, - text="powered by BHoM", - style="Caption.TLabel" - ) - subtitle_label.pack(anchor="w") - - def _build_buttons( - self, - parent: ttk.Frame, - show_submit: bool, - submit_text: str, - show_close: bool, - close_text: str, - buttons_side: Literal["left", "right"] = "right" - ) -> None: - """Build the bottom button bar. - - Args: - parent: Parent frame for the button bar. - show_submit: Whether to create submit button. - submit_text: Submit button label. - show_close: Whether to create close button. - close_text: Close button label. - """ - self.button_bar = ttk.Frame(parent, padding=(20, 10)) - self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) - - button_container = ttk.Frame(self.button_bar) - button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) - - if show_submit: - submit_widget = Button( - button_container, - text=submit_text, - command=self._on_submit, - style="Primary.TButton", - width=12, - alignment="center", - ) - submit_widget.pack(side=tk.LEFT, padx=5) - # expose inner ttk.Button for compatibility - self.submit_button = submit_widget.button - - if show_close: - close_widget = Button( - button_container, - text=close_text, - command=self._on_close, - width=12, - alignment="center", - ) - close_widget.pack(side=tk.LEFT, padx=5) - # expose inner ttk.Button for compatibility - self.close_button = close_widget.button - - def _bind_dynamic_sizing(self) -> None: - """Bind layout changes to schedule auto sizing updates.""" - self.main_container.bind("", self._schedule_dynamic_sizing) - self.content_frame.bind("", self._schedule_dynamic_sizing) - if self.button_bar is not None: - self.button_bar.bind("", self._schedule_dynamic_sizing) - - def _schedule_dynamic_sizing(self, _event=None) -> None: - """Debounce dynamic sizing updates triggered by layout changes.""" - # Avoid fighting user-driven manual resize after the window is visible. - # Initial sizing is handled by `_apply_sizing` + one post-show pass. - if self._has_been_shown: - return - if self._is_resizing: - return - if not (self._auto_fit_width or self._auto_fit_height): - return - if self._pending_resize_job is not None: - try: - self.after_cancel(self._pending_resize_job) - except Exception: - pass - self._pending_resize_job = self.after(30, self._apply_sizing) - - def _apply_sizing(self) -> None: - """Apply window sizing and positioning.""" - self._pending_resize_job = None - self._is_resizing = True - self.update_idletasks() - - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - if hasattr(self, "main_container"): - required_width = max(required_width, self.main_container.winfo_reqwidth()) - required_height = max(required_height, self.main_container.winfo_reqheight()) - - if self.button_bar is not None and self.button_bar.winfo_manager(): - required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) - - # Determine final dimensions: auto-size unless a rigid dimension is explicitly provided. - if self._rigid_width: - final_width = max(self.min_width, int(self.fixed_width or 0)) - else: - final_width = max(self.min_width, required_width) - - if self._rigid_height: - final_height = max(self.min_height, int(self.fixed_height or 0)) - else: - final_height = max(self.min_height, required_height) - - # Fullscreen overrides normal sizing/positioning - if self.fullscreen: - self.attributes("-fullscreen", True) - self.after(0, self._show_window_with_styling) - self._is_resizing = False - return - - # Position - if self.center_on_screen and not self._has_been_shown: - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - x = (screen_width - final_width) // 2 - y = (screen_height - final_height) // 2 - self.geometry(f"{final_width}x{final_height}+{x}+{y}") - elif self._has_been_shown: - x = self.winfo_x() - y = self.winfo_y() - self.geometry(f"{final_width}x{final_height}+{x}+{y}") - else: - self.geometry(f"{final_width}x{final_height}") - - # Defer window display until after styling is applied - self.after(0, self._show_window_with_styling) - self._is_resizing = False - - def _apply_post_show_sizing(self) -> None: - """Run one extra grow-only size pass after first show. - - On some Windows setups, control metrics settle after deiconify/theme - application, which can under-estimate initial required height and clip - bottom controls. - """ - if self._post_show_size_applied: - return - - self.update_idletasks() - - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - if hasattr(self, "main_container"): - required_width = max(required_width, self.main_container.winfo_reqwidth()) - required_height = max(required_height, self.main_container.winfo_reqheight()) - - if self.button_bar is not None and self.button_bar.winfo_manager(): - required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) - - current_width = self.winfo_width() - current_height = self.winfo_height() - - target_width = current_width - target_height = current_height - - if self._auto_fit_width: - target_width = max(current_width, self.min_width, required_width) - elif self._rigid_width and self.fixed_width is not None: - target_width = max(self.min_width, int(self.fixed_width)) - - if self._auto_fit_height: - target_height = max(current_height, self.min_height, required_height) - elif self._rigid_height and self.fixed_height is not None: - target_height = max(self.min_height, int(self.fixed_height)) - - if target_width != current_width or target_height != current_height: - x = self.winfo_x() - y = self.winfo_y() - self.geometry(f"{target_width}x{target_height}+{x}+{y}") - - self._post_show_size_applied = True - - def _show_window_with_styling(self) -> None: - """Apply titlebar styling and show the window.""" - self._set_titlebar_theme() - - # Show window after styling - self.deiconify() - self._has_been_shown = True - if not self._post_show_size_applied and (self._auto_fit_width or self._auto_fit_height): - self.after_idle(self._apply_post_show_sizing) - - def refresh_sizing(self) -> None: - """Recalculate and apply window sizing (useful after adding widgets).""" - self._apply_sizing() - - def close(self) -> None: - """Close and destroy the window. Override in subclasses for custom close behaviour.""" - self.destroy_root() - - def destroy_root(self) -> None: - """Safely terminate and destroy the Tk root window.""" - - try: - if self.winfo_exists(): - self.quit() - self.destroy() - except tk.TclError: - pass - - def _exit(self, result: str, callback: Optional[Callable] = None) -> None: - """Handle any exit path and always destroy the root window. - - Args: - result: Result token to store before closing. - callback: Optional callback invoked before destruction. - """ - if self._is_exiting: - return - self._is_exiting = True - self.result = result - try: - if callback: - callback() - except tk.TclError as ex: - message = str(ex).lower() - if not ("image" in message and "doesn't exist" in message): - print(f"Warning: Exit callback raised an exception: {ex}") - except Exception as ex: - print(f"Warning: Exit callback raised an exception: {ex}") - finally: - # Capture values while widgets still exist so `get()` remains usable - # after root teardown. - self._cached_widget_values = self._collect_widget_values() - self.destroy_root() - - def _on_submit(self) -> None: - """Handle submit button click.""" - if self.close_on_submit: - self._exit("submit", self.submit_command) - return - - self.result = "submit" - try: - if self.submit_command: - self.submit_command() - except tk.TclError as ex: - message = str(ex).lower() - if not ("image" in message and "doesn't exist" in message): - print(f"Warning: Exit callback raised an exception: {ex}") - except Exception as ex: - print(f"Warning: Exit callback raised an exception: {ex}") - finally: - self._cached_widget_values = self._collect_widget_values() - - - def _on_close(self) -> None: - """Handle close button click.""" - self._exit("close", self.close_command) - - def _on_close_window(self, callback: Optional[Callable]) -> None: - """Handle window X button click.""" - self._exit("window_closed", callback) - - def get(self): - try: - if not self.winfo_exists(): - return dict(self._cached_widget_values) - except Exception: - return dict(self._cached_widget_values) - - widget_values = self._collect_widget_values() - self._cached_widget_values = dict(widget_values) - return widget_values - - def _collect_widget_values(self) -> dict[str, object]: - """Collect values from all registered widgets.""" - widget_values: dict[str, object] = {} - - for widget in self.widgets: - - if hasattr(widget, "get"): - try: - widget_values[widget.id] = widget.get() - except Exception as ex: - print(f"Warning: Failed to get value from widget {widget}: {ex}") - return widget_values if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py new file mode 100644 index 00000000..9e615fd5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py @@ -0,0 +1,614 @@ +"""Shared themed window chrome mixin for BHoM root and child windows.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from pathlib import Path +from typing import Optional, Callable, Literal, List +import platform +import ctypes +import python_toolkit +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.theming.theme import ThemeManager + +from python_toolkit.bhom import BHOM_VERSION + + +class BHoMWindowShell: + """Shared themed window chrome for BHoM Tk root and Toplevel windows.""" + + def _init_bhom_shell( + self, + *, + title: str, + min_width: int, + min_height: int, + width: Optional[int], + height: Optional[int], + resizable: bool, + center_on_screen: bool, + show_submit: bool, + submit_text: str, + submit_command: Optional[Callable], + close_on_submit: bool, + show_close: bool, + close_text: str, + close_command: Optional[Callable], + on_close_window: Optional[Callable], + theme_mode: str, + widgets: Optional[List[BHoMBaseWidget]], + top_most: bool, + fullscreen: bool, + buttons_side: Literal["left", "right"], + grid_dimensions: Optional[tuple[int, int]], + show_banner: bool = True, + defer_show: bool = False, + content_padding: int = 20, + ) -> None: + self.defer_show = defer_show + self.title(title) + self._icon_image = None + self.minsize(min_width, min_height) + self.resizable(resizable, resizable) + + self.top_most = top_most + self.attributes("-topmost", True) + if not self.top_most: + self.after(0, lambda: self.attributes("-topmost", False)) + + self.fullscreen = fullscreen + self.widgets = list(widgets) if widgets is not None else [] + self.withdraw() + + self.theme = ThemeManager(theme_mode) + self._load_theme() + self._set_window_icon() + + self.min_width = min_width + self.min_height = min_height + self.fixed_width = width + self.fixed_height = height + self.center_on_screen = center_on_screen + self.submit_command = submit_command + self.close_on_submit = close_on_submit + self.close_command = close_command + self.result = None + self._is_exiting = False + self.button_bar: Optional[ttk.Frame] = None + self._has_been_shown = False + self._pending_resize_job: Optional[str] = None + self._is_resizing = False + self._rigid_width = width is not None + self._rigid_height = height is not None + self._auto_fit_width = width is None + self._auto_fit_height = height is None + self._post_show_size_applied = False + self.grid_dimensions = grid_dimensions + self._cached_widget_values: dict[str, object] = {} + + self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) + + self.main_container = ttk.Frame(self) + self.main_container.pack(fill=tk.BOTH, expand=True) + + if show_banner: + self._build_banner(self.main_container, title, self.theme.logo_path) + + self.content_frame = ttk.Frame(self.main_container, padding=content_padding) + self.content_frame.pack(fill=tk.BOTH, expand=True) + + if self.grid_dimensions: + self.grid_content_frame(*self.grid_dimensions) + + if show_submit or show_close: + self._build_buttons( + self.main_container, + show_submit, + submit_text, + show_close, + close_text, + buttons_side, + ) + + self._bind_dynamic_sizing() + self._apply_sizing() + self.build() + + def grid_content_frame(self, x_count: int, y_count: int) -> None: + """Configure the content frame with a grid layout of specified dimensions.""" + self.grid_dimensions = (x_count, y_count) + for r in range(y_count): + self.content_frame.rowconfigure(r, weight=1) + for c in range(x_count): + self.content_frame.columnconfigure(c, weight=1) + + def build(self): + """Call build on all child widgets that have it (for deferred widget construction).""" + + if any(not isinstance(w, BHoMBaseWidget) for w in self.widgets): + raise TypeError("All items in widgets list must be instances of BHoMBaseWidget.") + + for widget in self.widgets: + widget.build() + + self.refresh_sizing() + + def _set_window_icon(self) -> None: + """Set a custom window icon, replacing Tk's default icon.""" + icon_path = self.theme.icon_path + + if not icon_path: + return + + # Windows prefers .ico for titlebar/taskbar icons. + if icon_path.suffix.lower() == ".ico": + try: + self.iconbitmap(default=str(icon_path)) + return + except tk.TclError: + pass + + # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). + try: + self._icon_image = tk.PhotoImage(file=str(icon_path)) + self.iconphoto(True, self._icon_image) + return + except tk.TclError: + pass + + except Exception as ex: + print(f"Warning: Could not set window icon from {icon_path}: {ex}") + + def _set_titlebar_theme(self) -> None: + """ + Apply titlebar theme using Windows API. + + Args: + theme_style: Theme style key (`light` or `dark`). + + Returns: + None + """ + try: + + use_dark = 1 if self.theme.dark_theme else 0 + + if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): + hwnd = self.winfo_id() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + if hwnd: + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(use_dark)), + ctypes.sizeof(ctypes.c_int) + ) + + except Exception: + pass + + def _load_theme(self) -> str: + """ + Load a custom theme from a TCL file. + + Args: + custom_theme_path (Path, optional): Path to custom TCL theme file. + If None, uses default style.tcl in same directory. + theme_name (str): Name of the theme to apply from the TCL file. + + Returns: + str: Name of the theme that ended up being applied. + """ + style = ttk.Style() + + try: + current_themes = set(style.theme_names()) + + expected_theme = self.theme.path.stem.replace("_theme", "") + # Load the TCL theme file + try: + self.tk.call('source', str(self.theme.path)) + except tk.TclError as source_error: + if "already exists" not in str(source_error).lower(): + raise + + available_theme_names = style.theme_names() + newly_added = [name for name in available_theme_names if name not in current_themes] + if expected_theme in available_theme_names: + selected_theme = expected_theme + elif newly_added: + selected_theme = newly_added[-1] + else: + selected_theme = style.theme_use() if available_theme_names else "default" + + style.theme_use(selected_theme) + self._ensure_typography_styles(style) + return selected_theme + + except Exception as e: + print(f"Warning: Could not load custom theme: {e}") + try: + active_theme = style.theme_use() + self._ensure_typography_styles(style) + return active_theme + except Exception: + return "default" + + def _ensure_typography_styles(self, style: ttk.Style) -> None: + """Ensure key typography styles exist and remain visually distinct.""" + defaults = { + "TLabel": ("Segoe UI", 10), + "Body.TLabel": ("Segoe UI", 10), + "Caption.TLabel": ("Segoe UI", 9), + "Small.TLabel": ("Segoe UI", 8), + "Heading.TLabel": ("Segoe UI", 12, "bold"), + "Subtitle.TLabel": ("Segoe UI", 12, "bold"), + "Headline.TLabel": ("Segoe UI", 14, "bold"), + "Title.TLabel": ("Segoe UI", 18, "bold"), + "LargeTitle.TLabel": ("Segoe UI", 18, "bold"), + "Display.TLabel": ("Segoe UI", 20, "bold"), + } + + def _lookup_font(style_name: str) -> str: + try: + return str(style.lookup(style_name, "font") or "").strip() + except Exception: + return "" + + for style_name, font_spec in defaults.items(): + if not _lookup_font(style_name): + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + + base_font = _lookup_font("TLabel") + for style_name, font_spec in ( + ("Caption.TLabel", defaults["Caption.TLabel"]), + ("Subtitle.TLabel", defaults["Subtitle.TLabel"]), + ("Headline.TLabel", defaults["Headline.TLabel"]), + ("LargeTitle.TLabel", defaults["LargeTitle.TLabel"]), + ): + resolved = _lookup_font(style_name) + if not resolved or resolved == base_font: + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + + def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: + """Build the branded banner section. + + Args: + parent: Parent frame to host the banner. + title: Banner title text. + logo_path: Optional logo image path. + """ + banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) + banner.pack(fill=tk.BOTH, padx=0, pady=0) + + banner_content = ttk.Frame(banner, padding=10) + banner_content.pack(fill=tk.BOTH, expand=True) + + # Text container + text_container = ttk.Frame(banner_content) + text_container.pack(side=tk.LEFT, fill=tk.Y) + + logo_container = ttk.Frame(banner_content, width=80) + logo_container.pack(side=tk.RIGHT, fill=tk.Y) + + # Logo (if provided) + if logo_path and logo_path.exists(): + try: + from PIL import Image, ImageTk + img = Image.open(logo_path) + img.thumbnail((80, 80), Image.Resampling.LANCZOS) + # Bind image to this root explicitly to avoid stale image handles + # when previous runs failed and tore down a different Tk interpreter. + self.logo_image = ImageTk.PhotoImage(img, master=self) + logo_label = Label(logo_container, image=self.logo_image) + logo_label.pack(fill=tk.BOTH, expand=True) + except tk.TclError: + pass + except ImportError: + pass # PIL not available, skip logo + + # Title + title_label = Label( + text_container, + text=title, + style="LargeTitle.TLabel" + ) + title_label.pack(anchor="w") + + # Subtitle + subtitle_label = Label( + text_container, + text=f"powered by BHoM (v{BHOM_VERSION})", + style="Caption.TLabel" + ) + subtitle_label.pack(anchor="w") + + def _build_buttons( + self, + parent: ttk.Frame, + show_submit: bool, + submit_text: str, + show_close: bool, + close_text: str, + buttons_side: Literal["left", "right"] = "right" + ) -> None: + """Build the bottom button bar. + + Args: + parent: Parent frame for the button bar. + show_submit: Whether to create submit button. + submit_text: Submit button label. + show_close: Whether to create close button. + close_text: Close button label. + """ + self.button_bar = ttk.Frame(parent, padding=(20, 10)) + self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) + + button_container = ttk.Frame(self.button_bar) + button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) + + if show_submit: + submit_widget = Button( + button_container, + text=submit_text, + command=self._on_submit, + style="Primary.TButton", + width=12, + alignment="center", + ) + submit_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.submit_button = submit_widget.button + + if show_close: + close_widget = Button( + button_container, + text=close_text, + command=self._on_close, + width=12, + alignment="center", + ) + close_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.close_button = close_widget.button + + def _bind_dynamic_sizing(self) -> None: + """Bind layout changes to schedule auto sizing updates.""" + self.main_container.bind("", self._schedule_dynamic_sizing) + self.content_frame.bind("", self._schedule_dynamic_sizing) + if self.button_bar is not None: + self.button_bar.bind("", self._schedule_dynamic_sizing) + + def _schedule_dynamic_sizing(self, _event=None) -> None: + """Debounce dynamic sizing updates triggered by layout changes.""" + # Avoid fighting user-driven manual resize after the window is visible. + # Initial sizing is handled by `_apply_sizing` + one post-show pass. + if self._has_been_shown: + return + if self._is_resizing: + return + if not (self._auto_fit_width or self._auto_fit_height): + return + if self._pending_resize_job is not None: + try: + self.after_cancel(self._pending_resize_job) + except Exception: + pass + self._pending_resize_job = self.after(30, self._apply_sizing) + + def _apply_sizing(self) -> None: + """Apply window sizing and positioning.""" + self._pending_resize_job = None + self._is_resizing = True + self.update_idletasks() + + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + if hasattr(self, "main_container"): + required_width = max(required_width, self.main_container.winfo_reqwidth()) + required_height = max(required_height, self.main_container.winfo_reqheight()) + + if self.button_bar is not None and self.button_bar.winfo_manager(): + required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) + + # Determine final dimensions: auto-size unless a rigid dimension is explicitly provided. + if self._rigid_width: + final_width = max(self.min_width, int(self.fixed_width or 0)) + else: + final_width = max(self.min_width, required_width) + + if self._rigid_height: + final_height = max(self.min_height, int(self.fixed_height or 0)) + else: + final_height = max(self.min_height, required_height) + + # Fullscreen overrides normal sizing/positioning + if self.fullscreen: + self.attributes("-fullscreen", True) + if not getattr(self, "defer_show", False): + self.after(0, self._show_window_with_styling) + self._is_resizing = False + return + + # Position + if self.center_on_screen and not self._has_been_shown: + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = (screen_width - final_width) // 2 + y = (screen_height - final_height) // 2 + self.geometry(f"{final_width}x{final_height}+{x}+{y}") + elif self._has_been_shown: + x = self.winfo_x() + y = self.winfo_y() + self.geometry(f"{final_width}x{final_height}+{x}+{y}") + else: + self.geometry(f"{final_width}x{final_height}") + + if getattr(self, "defer_show", False): + self._is_resizing = False + return + + # Defer window display until after styling is applied + self.after(0, self._show_window_with_styling) + self._is_resizing = False + + def _apply_post_show_sizing(self) -> None: + """Run one extra grow-only size pass after first show. + + On some Windows setups, control metrics settle after deiconify/theme + application, which can under-estimate initial required height and clip + bottom controls. + """ + if self._post_show_size_applied: + return + + self.update_idletasks() + + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + if hasattr(self, "main_container"): + required_width = max(required_width, self.main_container.winfo_reqwidth()) + required_height = max(required_height, self.main_container.winfo_reqheight()) + + if self.button_bar is not None and self.button_bar.winfo_manager(): + required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) + + current_width = self.winfo_width() + current_height = self.winfo_height() + + target_width = current_width + target_height = current_height + + if self._auto_fit_width: + target_width = max(current_width, self.min_width, required_width) + elif self._rigid_width and self.fixed_width is not None: + target_width = max(self.min_width, int(self.fixed_width)) + + if self._auto_fit_height: + target_height = max(current_height, self.min_height, required_height) + elif self._rigid_height and self.fixed_height is not None: + target_height = max(self.min_height, int(self.fixed_height)) + + if target_width != current_width or target_height != current_height: + x = self.winfo_x() + y = self.winfo_y() + self.geometry(f"{target_width}x{target_height}+{x}+{y}") + + self._post_show_size_applied = True + + def _show_window_with_styling(self) -> None: + """Apply titlebar styling and show the window.""" + self._set_titlebar_theme() + + # Show window after styling + self.deiconify() + self._has_been_shown = True + if not self._post_show_size_applied and (self._auto_fit_width or self._auto_fit_height): + self.after_idle(self._apply_post_show_sizing) + + def refresh_sizing(self) -> None: + """Recalculate and apply window sizing (useful after adding widgets).""" + self._apply_sizing() + + def close(self) -> None: + """Close and destroy the window. Override in subclasses for custom close behaviour.""" + self.destroy_root() + + def destroy_root(self) -> None: + """Safely terminate and destroy the Tk root window.""" + + try: + if self.winfo_exists(): + self.quit() + self.destroy() + except tk.TclError: + pass + + def _exit(self, result: str, callback: Optional[Callable] = None) -> None: + """Handle any exit path and always destroy the root window. + + Args: + result: Result token to store before closing. + callback: Optional callback invoked before destruction. + """ + if self._is_exiting: + return + self._is_exiting = True + self.result = result + try: + if callback: + callback() + except tk.TclError as ex: + message = str(ex).lower() + if not ("image" in message and "doesn't exist" in message): + print(f"Warning: Exit callback raised an exception: {ex}") + except Exception as ex: + print(f"Warning: Exit callback raised an exception: {ex}") + finally: + # Capture values while widgets still exist so `get()` remains usable + # after root teardown. + self._cached_widget_values = self._collect_widget_values() + self.destroy_root() + + def _on_submit(self) -> None: + """Handle submit button click.""" + if self.close_on_submit: + self._exit("submit", self.submit_command) + return + + self.result = "submit" + try: + if self.submit_command: + self.submit_command() + except tk.TclError as ex: + message = str(ex).lower() + if not ("image" in message and "doesn't exist" in message): + print(f"Warning: Exit callback raised an exception: {ex}") + except Exception as ex: + print(f"Warning: Exit callback raised an exception: {ex}") + finally: + self._cached_widget_values = self._collect_widget_values() + + + def _on_close(self) -> None: + """Handle close button click.""" + self._exit("close", self.close_command) + + def _on_close_window(self, callback: Optional[Callable]) -> None: + """Handle window X button click.""" + self._exit("window_closed", callback) + + def get(self): + try: + if not self.winfo_exists(): + return dict(self._cached_widget_values) + except Exception: + return dict(self._cached_widget_values) + + widget_values = self._collect_widget_values() + self._cached_widget_values = dict(widget_values) + return widget_values + + def _collect_widget_values(self) -> dict[str, object]: + """Collect values from all registered widgets.""" + widget_values: dict[str, object] = {} + + for widget in self.widgets: + + if hasattr(widget, "get"): + try: + widget_values[widget.id] = widget.get() + except Exception as ex: + print(f"Warning: Failed to get value from widget {widget}: {ex}") + return widget_values + + diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl index acedbf7b..cf3798fe 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -63,7 +63,7 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure Card.TFrame \ -background $colors(-dark) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ -bordercolor $colors(-border-light) @@ -71,30 +71,30 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} + -font {{Segoe UI} 10} ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ + -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ + -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -140,22 +140,22 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -focuscolor "" \ - -padding {16 8} \ - -relief raised + -padding {12 6} \ + -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 + -padding {14 8} \ + -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 + -padding {8 4} \ + -borderwidth 1 ttk::style map TButton \ -background [list \ @@ -185,9 +185,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-primary-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Primary.TButton \ -background [list \ @@ -211,9 +211,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-secondary) \ -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Secondary.TButton \ -background [list \ @@ -231,9 +231,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-tertiary) \ -lightcolor $colors(-tertiary) \ -darkcolor "#9fad00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Accent.TButton \ -background [list \ @@ -253,9 +253,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-success) \ -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Success.TButton \ -background [list \ @@ -271,7 +271,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {14 8} \ + -padding {10 4} \ -relief flat ttk::style map Link.TButton \ @@ -288,9 +288,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-primary) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Outline.TButton \ -background [list \ @@ -308,7 +308,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {14 8} + -padding {10 4} ttk::style map Text.TButton \ -background [list \ @@ -325,8 +325,8 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TEntry \ @@ -346,8 +346,8 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TCombobox \ @@ -365,11 +365,11 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TCheckbutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -394,11 +394,11 @@ namespace eval ttk::theme::bhom_dark { } ttk::style configure Checkbox.TCheckbutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorrelief flat \ - -indicatorsize 18 \ + -indicatorsize 16 \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -423,11 +423,11 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -453,9 +453,9 @@ namespace eval ttk::theme::bhom_dark { } ttk::style configure Radio.TRadiobutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorsize 15 \ -borderwidth 0 \ -relief flat \ @@ -497,7 +497,7 @@ namespace eval ttk::theme::bhom_dark { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 10 + -width 8 ttk::style map TScrollbar \ -background [list \ @@ -510,7 +510,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Vertical.TScrollbar \ -background [list \ @@ -523,7 +523,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -573,24 +573,24 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised + -borderwidth 1 \ + -thickness 16 \ + -relief flat # Notebook - soft rounded tabs ttk::style configure TNotebook \ -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -tabmargins {2 5 2 0} \ - -borderwidth 2 + -borderwidth 1 ttk::style configure TNotebook.Tab \ -background $colors(-dark) \ -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 + -font {{Segoe UI} 10 bold} \ + -padding {12 6} \ + -borderwidth 1 ttk::style map TNotebook.Tab \ -background [list \ @@ -610,9 +610,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-border-light) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} + -borderwidth 1 \ + -rowheight 26 \ + -padding {4 2} ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -622,9 +622,9 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-dark) \ -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} + -relief flat \ + -padding {8 6} \ + -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ -background [list active $colors(-hover-bg)] \ @@ -647,22 +647,22 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ - -padding {16 12} + -padding {10 8} ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} + -font {{Segoe UI} 10 bold} \ + -padding {8 -6} # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 8 \ + -sashthickness 6 \ -gripcount 0 \ -background $colors(-border) @@ -676,8 +676,8 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TSpinbox \ @@ -695,9 +695,9 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised + -padding {12 6} \ + -borderwidth 1 \ + -relief flat ttk::style map TMenubutton \ -background [list \ @@ -718,7 +718,7 @@ namespace eval ttk::theme::bhom_dark { # Set default options for tk widgets (non-ttk) option add *Background "#1e1e1e" option add *Foreground "#ffffff" -option add *Font {{Segoe UI} 10 bold} +option add *Font {{Segoe UI} 10} option add *selectBackground "#1b6ec2" option add *selectForeground "#ffffff" option add *activeBackground "#2a2d2e" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl index c6973f46..8b52cc3b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -63,7 +63,7 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Card.TFrame \ -background $colors(-dark) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ -bordercolor $colors(-border-light) @@ -71,30 +71,30 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} + -font {{Segoe UI} 10} ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ + -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ + -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -140,22 +140,22 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -focuscolor "" \ - -padding {16 8} \ - -relief raised + -padding {12 6} \ + -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 + -padding {14 8} \ + -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 + -padding {8 4} \ + -borderwidth 1 ttk::style map TButton \ -background [list \ @@ -185,9 +185,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-primary-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Primary.TButton \ -background [list \ @@ -214,9 +214,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-secondary) \ -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Secondary.TButton \ -background [list \ @@ -234,9 +234,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-tertiary) \ -lightcolor $colors(-tertiary) \ -darkcolor "#8a8a00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Accent.TButton \ -background [list \ @@ -256,9 +256,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-success) \ -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Success.TButton \ -background [list \ @@ -274,7 +274,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {14 8} \ + -padding {10 4} \ -relief flat ttk::style map Link.TButton \ @@ -291,9 +291,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-primary) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Outline.TButton \ -background [list \ @@ -311,7 +311,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {14 8} + -padding {10 4} ttk::style map Text.TButton \ -background [list \ @@ -328,8 +328,8 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TEntry \ @@ -349,8 +349,8 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TCombobox \ @@ -368,11 +368,11 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TCheckbutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -397,11 +397,11 @@ namespace eval ttk::theme::bhom_light { } ttk::style configure Checkbox.TCheckbutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorrelief flat \ - -indicatorsize 18 \ + -indicatorsize 16 \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -426,11 +426,11 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -456,9 +456,9 @@ namespace eval ttk::theme::bhom_light { } ttk::style configure Radio.TRadiobutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorsize 15 \ -borderwidth 0 \ -relief flat \ @@ -500,7 +500,7 @@ namespace eval ttk::theme::bhom_light { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 10 + -width 8 ttk::style map TScrollbar \ -background [list \ @@ -513,7 +513,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Vertical.TScrollbar \ -background [list \ @@ -526,7 +526,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -576,24 +576,24 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised + -borderwidth 1 \ + -thickness 16 \ + -relief flat # Notebook - soft rounded tabs ttk::style configure TNotebook \ -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -tabmargins {2 5 2 0} \ - -borderwidth 2 + -borderwidth 1 ttk::style configure TNotebook.Tab \ -background $colors(-dark) \ -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 + -font {{Segoe UI} 10 bold} \ + -padding {12 6} \ + -borderwidth 1 ttk::style map TNotebook.Tab \ -background [list \ @@ -613,9 +613,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-border-light) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} + -borderwidth 1 \ + -rowheight 26 \ + -padding {4 2} ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -625,9 +625,9 @@ namespace eval ttk::theme::bhom_light { -background $colors(-dark) \ -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} + -relief flat \ + -padding {8 6} \ + -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ -background [list active $colors(-hover-bg)] \ @@ -650,22 +650,22 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ - -padding {16 12} + -padding {10 8} ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} + -font {{Segoe UI} 10 bold} \ + -padding {8 -6} # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 8 \ + -sashthickness 6 \ -gripcount 0 \ -background $colors(-border) @@ -679,8 +679,8 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TSpinbox \ @@ -698,9 +698,9 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised + -padding {12 6} \ + -borderwidth 1 \ + -relief flat ttk::style map TMenubutton \ -background [list \ @@ -717,7 +717,7 @@ namespace eval ttk::theme::bhom_light { # Set default options for tk widgets (non-ttk) option add *Background "#ffffff" option add *Foreground "#1a1a1a" -option add *Font {{Segoe UI} 10 bold} +option add *Font {{Segoe UI} 10} option add *selectBackground "#1b6ec2" option add *selectForeground "#ffffff" option add *activeBackground "#e0e0e0" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py index ad94474c..2e899779 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -27,12 +27,16 @@ def __init__( day_button_padx: int = 1, day_button_pady: int = 1, day_button_text_alignment: Literal["left", "center", "right"] = "center", + fixed_week_rows: int | None = None, + selector_position: Literal["top", "bottom"] = "bottom", + selection_label_format: Literal["short", "long"] = "short", **kwargs): super().__init__(parent, **kwargs) self.year = def_year self.month = def_month + self.day = def_day self.show_year_selector = show_year_selector self.year_min = year_min self.year_max = year_max @@ -44,6 +48,17 @@ def __init__( alignment_candidate = "center" self.day_button_text_alignment = alignment_candidate self.day_button_style = f"CalendarDay.{id(self)}.TButton" + self.fixed_week_rows = ( + max(1, int(fixed_week_rows)) if fixed_week_rows is not None else None + ) + selector_candidate = str(selector_position).strip().lower() + self.selector_position = ( + selector_candidate if selector_candidate in {"top", "bottom"} else "bottom" + ) + label_format = str(selection_label_format).strip().lower() + self.selection_label_format = ( + label_format if label_format in {"short", "long"} else "short" + ) anchor_map = { "left": "w", @@ -53,20 +68,17 @@ def __init__( ttk.Style(self).configure(self.day_button_style, anchor=anchor_map[self.day_button_text_alignment]) self.cal_frame = ttk.Frame(self.content_frame) - self.cal_frame.pack(side="top", fill="x") - self.month_frame = ttk.Frame(self.content_frame) - self.month_frame.pack(side="top", anchor=self._pack_anchor) - self.date_frame = ttk.Frame(self.content_frame) - self.date_frame.pack(side="top", fill="x") if self.show_year_selector: self.year_selector() self.month_selector() + self._pack_sections() self._initialized = False - self.set_day(def_day) + self._clamp_day() self.redraw() + self._refresh_selection_label() self._initialized = True def year_selector(self): @@ -93,6 +105,44 @@ def month_selector(self): ) self.month_dropdown.pack(side="left", padx=4, pady=4) + def _pack_sections(self) -> None: + """Pack month selectors and calendar sections in the configured order.""" + for frame in (self.cal_frame, self.month_frame, self.date_frame): + frame.pack_forget() + + if self.selector_position == "top": + section_order = (self.month_frame, self.cal_frame, self.date_frame) + else: + section_order = (self.cal_frame, self.month_frame, self.date_frame) + + for frame in section_order: + if frame is self.month_frame: + frame.pack(side="top", anchor=self._pack_anchor, fill="x") + else: + frame.pack(side="top", fill="x") + + def _clamp_day(self) -> None: + last_day = calendar.monthrange(self.year, self.month)[1] + if self.day > last_day: + self.day = last_day + + def _refresh_selection_label(self) -> None: + for child in self.date_frame.winfo_children(): + child.destroy() + + try: + selected = datetime.date(self.year, self.month, self.day) + if self.selection_label_format == "long": + text = f"Selected: {selected.strftime('%A %d %B %Y')}" + else: + text = f"Selected Date: {self.months[self.month - 1]} {self.day}" + except ValueError as error: + text = f"Selected: invalid date ({error})" + + label = Label(self.date_frame, text=text) + self.align_child_text(label) + label.pack(anchor=self._pack_anchor, padx=4, pady=4) + def set_year(self, value): """Update the selected year and redraw the calendar. @@ -100,7 +150,9 @@ def set_year(self, value): value: The selected year as a string. """ self.year = int(value) + self._clamp_day() self.redraw() + self._refresh_selection_label() def set_month(self, value): """Update the selected month and redraw the calendar. @@ -109,7 +161,9 @@ def set_month(self, value): value: The selected month name as a string. """ self.month = self.months.index(value) + 1 + self._clamp_day() self.redraw() + self._refresh_selection_label() def redraw(self): """Rebuild the month grid buttons for the current month and year.""" @@ -124,9 +178,13 @@ def redraw(self): self.align_child_text(label) label.grid(row=0, column=col, sticky="nsew") - cal = calendar.monthcalendar(self.year, self.month) + weeks = calendar.monthcalendar(self.year, self.month) + if self.fixed_week_rows is not None: + while len(weeks) < self.fixed_week_rows: + weeks.append([0, 0, 0, 0, 0, 0, 0]) + weeks = weeks[: self.fixed_week_rows] - for row, week in enumerate(cal): + for row, week in enumerate(weeks): for col, day in enumerate(week): text = "" if day == 0 else day state = "normal" if day > 0 else "disabled" @@ -154,14 +212,7 @@ def set_day(self, num): if not num or num <= 0: return self.day = num - - for child in self.date_frame.winfo_children(): - child.destroy() - - date = self.months[self.month-1] + " " + str(self.day) - label = Label(self.date_frame, text=f"Selected Date: {date}") - self.align_child_text(label) - label.pack(anchor=self._pack_anchor, padx=4, pady=4) + self._refresh_selection_label() if self._initialized: self._fire_on_change(self.get()) @@ -195,8 +246,9 @@ def set(self, value: datetime.date): self.year_dropdown.set(str(self.year)) if hasattr(self, 'month_dropdown'): self.month_dropdown.set(self.months[self.month - 1]) - self.set_day(self.day) + self._clamp_day() self.redraw() + self._refresh_selection_label() def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the currently selected date. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py index c372f4bb..cdd1dabd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -1,9 +1,13 @@ +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from .directory_file_selector import DirectoryFileSelector from .landing_page import LandingPage +from .modal_window import BHoMModalWindow from .processing_window import ProcessingWindow from .warning_box import WarningBox __all__ = [ + "BHoMBaseChildWindow", + "BHoMModalWindow", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py index 67e9caba..f708d537 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -8,9 +8,9 @@ from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow -class DirectoryFileSelector(BHoMBaseWindow): +class DirectoryFileSelector(BHoMBaseChildWindow): """Display matching files and return the user's multi-selection.""" def __init__( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py new file mode 100644 index 00000000..9ed71d82 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py @@ -0,0 +1,50 @@ +"""Themed modal dialog window for child forms on a BHoM parent window.""" + +from __future__ import annotations + +import tkinter as tk +from typing import Callable, Optional + +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow + + +class BHoMModalWindow(BHoMBaseChildWindow): + """Modal child window with BHoM theming and a content area.""" + + def __init__( + self, + parent: tk.Misc, + *, + title: str, + width: int | None = None, + height: int | None = None, + min_width: int = 320, + min_height: int = 240, + resizable: bool = False, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable[[], None]] = None, + theme_mode: str | None = None, + content_padding: int = 20, + **kwargs, + ) -> None: + super().__init__( + parent, + title=title, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + resizable=resizable, + show_submit=False, + show_close=show_close, + close_text=close_text, + close_command=close_command, + theme_mode=theme_mode, + content_padding=content_padding, + show_banner=False, + defer_show=True, + modal=True, + center_on_screen=False, + **kwargs, + ) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index b958dfab..3cb342e2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -5,9 +5,9 @@ import time import threading -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow -class ProcessingWindow(BHoMBaseWindow): +class ProcessingWindow(BHoMBaseChildWindow): """A simple processing window with animated indicator.""" def __init__(self, title="Processing", message="Processing...", *args, **kwargs): @@ -25,19 +25,18 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) theme_mode="auto", show_close=False, show_submit=False, + show_banner=False, + top_most=True, *args, **kwargs ) - - self.title(title) + self.attributes("-topmost", True) self.resizable(False, False) - # Container - container = ttk.Frame(self, padding=20) + container = ttk.Frame(self.content_frame, padding=20) container.pack(fill="both", expand=True) - # Message label (to calculate size) self.message_label = ttk.Label( container, text=message, @@ -53,7 +52,6 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) pass self.message_label.pack(pady=(0, 20)) - # Animation frame animation_frame = ttk.Frame(container) animation_frame.pack(expand=True) @@ -71,30 +69,12 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) pass self.animation_label.pack() - # Animation state self.animation_frames = ["●", "●", "●"] self.current_frame = 0 self.is_running = False - # Update to calculate the required size self.update_idletasks() - - # Get the required width and height - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - # Set minimum size - min_width = 300 - min_height = 150 - window_width = max(required_width, min_width) - window_height = max(required_height, min_height) - - # Center on screen - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - x = (screen_width - window_width) // 2 - y = (screen_height - window_height) // 2 - self.geometry(f"{window_width}x{window_height}+{x}+{y}") + self.refresh_sizing() def start(self): @@ -103,7 +83,6 @@ def start(self): return self.is_running = True - # Run the Tk mainloop on the calling thread (must be main thread on many platforms). try: self._animate() self.mainloop() @@ -153,24 +132,11 @@ def keep_alive(self): def stop(self): """Stop the animation and close the window.""" self.is_running = False - try: - # Stop the mainloop if running and then destroy the window - if self.winfo_exists(): - try: - self.quit() - except Exception: - pass - try: - self.destroy() - except Exception: - pass - except Exception: - pass + self.destroy_root() def _animate(self): """Update animation frames.""" if self.is_running: - # Create rotating dot animation dots = ["◐", "◓", "◑", "◒"] self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 @@ -180,15 +146,12 @@ def update_message(self, message: str): """Update the message text.""" try: self.message_label.config(text=message) - # schedule an idle update so the UI refreshes promptly self.update_idletasks() except Exception: pass if __name__ == "__main__": - # Test the processing window - processing = ProcessingWindow(title="Test Processing", message="Running Comfort and Safety Calculation...") def worker(): for i in range(50): diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py index c92e8f1a..1ae67fdd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -1,11 +1,11 @@ """Standardized warning dialog window for errors, warnings, and info messages.""" from python_toolkit.bhom_tkinter.widgets.label import Label -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions -class WarningBox(BHoMBaseWindow): +class WarningBox(BHoMBaseChildWindow): """Show categorized messages using the shared BHoM window styling.""" def __init__( diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index 9eb2c6fe..ed42fdaf 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -2,6 +2,7 @@ import calendar import textwrap +from typing import Tuple import matplotlib.collections as mcollections import matplotlib.lines as mlines @@ -20,6 +21,9 @@ def diurnal( series: pd.Series, ax: plt.Axes = None, period: str = "daily", + quantile_range: Tuple[float, float] = (0.05, 0.95), + median: bool = True, + mean: bool = True, **kwargs, ) -> plt.Axes: """Plot a profile aggregated across days in the specified timeframe. @@ -31,6 +35,12 @@ def diurnal( A matplotlib Axes object. Defaults to None. period (str, optional): The period to aggregate over. Must be one of "dailyy", "weekly", or "monthly". Defaults to "daily". + quantile_range (Tuple[float, float]): + The quantile range to display in a lighter (30% alpha) colour on the plot. Defaults to (0.05, 0.95). + median (bool, optional): + Whether to plot the median line. Default `True`. + mean (bool, optional): + Whether to plot the mean line. Default `True`. **kwargs (Dict[str, Any], optional): Additional keyword arguments to pass to the matplotlib plotting function. legend (bool, optional): @@ -43,7 +53,6 @@ def diurnal( A matplotlib Axes object. """ - if not isinstance(series.index, pd.DatetimeIndex): raise ValueError("Series passed is not datetime indexed.") @@ -61,7 +70,6 @@ def diurnal( raise ValueError("minmax_range must be increasing.") minmax_alpha = kwargs.pop("minmax_alpha", 0.1) - quantile_range = kwargs.pop("quantile_range", [0.05, 0.95]) if quantile_range[0] > quantile_range[1]: raise ValueError("quantile_range must be increasing.") if quantile_range[0] < minmax_range[0] or quantile_range[1] > minmax_range[1]: @@ -126,14 +134,14 @@ def diurnal( # Get values to plot minima = group.min() lower = group.quantile(quantile_range[0]) - median = group.median() - mean = group.mean() + median_series = group.median() + mean_series = group.mean() upper = group.quantile(quantile_range[1]) maxima = group.max() # create df for re-indexing df = pd.concat( - [minima, lower, median, mean, upper, maxima], + [minima, lower, median_series, mean_series, upper, maxima], axis=1, keys=["minima", "lower", "median", "mean", "upper", "maxima"], ).reindex(target_idx) @@ -183,24 +191,26 @@ def diurnal( label="_nolegend_", ) # mean/median - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] - + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], - c=color, - ls="-", - lw=1, - label="Average" if n == 0 else "_nolegend_", - ) - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] - + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], - c=color, - ls="--", - lw=1, - label="Median" if n == 0 else "_nolegend_", - ) + if mean: + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] + + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], + c=color, + ls="-", + lw=1, + label="Average" if n == 0 else "_nolegend_", + ) + if median: + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] + + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], + c=color, + ls="--", + lw=1, + label="Median" if n == 0 else "_nolegend_", + ) # format axes ax.set_xlim(0, len(df)) diff --git a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py index 845fad84..2eb7e280 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py @@ -9,6 +9,15 @@ from ..helpers.timeseries import validate_timeseries +def _bin_edges(coords, default_width: float): + """Edges so each centre keeps a flat pcolormesh cell.""" + coords = np.asarray(coords, dtype=float) + if coords.size == 0: + return coords + width = default_width if coords.size == 1 else np.diff(coords)[-1] + return np.concatenate([coords, coords[-1:] + width]) + + @bhom_analytics() def heatmap( series: pd.Series, @@ -71,10 +80,12 @@ def heatmap( if ax is None: ax = plt.gca() + kwargs.pop("shading", None) pcm = ax.pcolormesh( - x, - y, - z[:-1, :-1], + _bin_edges(x, 1.0), + _bin_edges(y, 1.0 / 24.0), + z, + shading="flat", **kwargs, ) diff --git a/Python_Engine/Python/src/python_toolkit/units/__init__.py b/Python_Engine/Python/src/python_toolkit/units/__init__.py new file mode 100644 index 00000000..87a074dd --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/__init__.py @@ -0,0 +1,15 @@ +from .area import AreaUnit +from .energy import EnergyUnit +from .length import LengthUnit +from .power import PowerUnit +from .speed import SpeedUnit +from .temperature import TemperatureUnit + +__all__ = [ + "AreaUnit", + "EnergyUnit", + "LengthUnit", + "PowerUnit", + "SpeedUnit", + "TemperatureUnit" +] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/units/area.py b/Python_Engine/Python/src/python_toolkit/units/area.py new file mode 100644 index 00000000..e59b6e98 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/area.py @@ -0,0 +1,13 @@ +from enum import Enum + +class AreaUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres squared, lambda from metres squared) + """ + ft2 = ("Feet Squared", lambda ft2: ft2 * (3.048e-1**2), lambda m2: m2 / (3.048e-1**2)) + km2 = ("Kilometres Squared", lambda km2: km2 * (1e3**2), lambda m2: m2 / (1e3**2)) + m2 = ("Metres Squared", lambda m2: m2, lambda m2: m2) + mi2 = ("Miles Squared", lambda mi2: mi2 * (1.609344e3**2), lambda m2: m2 / (1.609344e3**2)) + + def convert(self, value: float, to_unit: "AreaUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/energy.py b/Python_Engine/Python/src/python_toolkit/units/energy.py new file mode 100644 index 00000000..f1fad47d --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/energy.py @@ -0,0 +1,18 @@ +from enum import Enum + +class EnergyUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to joules, lambda from joules) + """ + BTU = ("British Thermal Unit", lambda btu: btu * 1.05505585262e3, lambda j: j / 1.05505585262e3) + J = ("Joule", lambda j: j, lambda j: j) + kBTU = ("Kilo British Thermal Unit", lambda kbtu: kbtu * 1.05505585262e6, lambda j: j / 1.05505585262e6) + kJ = ("Kilojoule", lambda kj: kj * 1e3, lambda j: j / 1e3) + kWh = ("kilowatt Hour", lambda kwh: kwh * 3.6e6, lambda j: j / 3.6e6) + MBTU = ("Mega British Thermal Unit", lambda mbtu: mbtu * 1.05505585262e9, lambda j: j / 1.05505585262e9) + MJ = ("Megajoule", lambda mj: mj * 1e6, lambda j: j / 1e6) + MWh = ("Megawatt Hour", lambda mwh: mwh * 3.6e9, lambda j: j / 3.6e9) + Wh = ("Watt Hour", lambda wh: wh * 3.6e3, lambda j: j / 3.6e3) + + def convert(self, value: float, to_unit: "EnergyUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/length.py b/Python_Engine/Python/src/python_toolkit/units/length.py new file mode 100644 index 00000000..f8ef8688 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/length.py @@ -0,0 +1,17 @@ +from enum import Enum + +class LengthUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres, lambda from metres) + """ + cm = ("Centimetre", lambda cm: cm * 1e-2, lambda m: m / 1e-2) + ft = ("Foot", lambda ft: ft * 3.048e-1, lambda m: m / 3.048e-1) + In = ("Inch", lambda In: In * 2.54e-2, lambda m: m / 2.54e-2) + km = ("Kilometre", lambda km: km * 1e3, lambda m: m / 1e3) + m = ("Metre", lambda m: m, lambda m: m) + mi = ("Mile", lambda mi: mi * 1.609344e3, lambda m: m / 1.609344e3) + mm = ("Millimetre", lambda mm: mm * 1e-3, lambda m: m / 1e-3) + yd = ("Yard", lambda yd: yd * 9.144e-1, lambda m: m / 9.144e-1) + + def convert(self, value: float, to_unit: "LengthUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/power.py b/Python_Engine/Python/src/python_toolkit/units/power.py new file mode 100644 index 00000000..386a0608 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/power.py @@ -0,0 +1,15 @@ +from enum import Enum + +class PowerUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to watts, lambda from watts) + """ + BTU_h = ("British Thermal Unit Per Hour", lambda btu_h: btu_h * (1.05505585262e3 / 3600), lambda w: w / (1.05505585262e3 / 3600)) + W = ("Watt", lambda w: w, lambda w: w) + kBTU_h = ("Kilo British Thermal Unit Per Hour", lambda kbtu_h: kbtu_h * 1.05505585262e6, lambda w: w / 1.05505585262e6) + kW = ("Kilowatt", lambda kw: kw * 1e3, lambda w: w / 1e3) + MBTU_h = ("Mega British Thermal Unit Per Hour", lambda mbtu_h: mbtu_h * (1.05505585262e9 / 3600), lambda w: w / (1.05505585262e9 / 3600)) + MW = ("Megawatt", lambda mj: mj * 1e6, lambda j: j / 1e6) + + def convert(self, value: float, to_unit: "PowerUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/speed.py b/Python_Engine/Python/src/python_toolkit/units/speed.py new file mode 100644 index 00000000..f1ca7bdb --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/speed.py @@ -0,0 +1,13 @@ +from enum import Enum + +class SpeedUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres per secomd, lambda from metres per second) + """ + m_s = ("Metres Per Second", lambda m_s: m_s, lambda m_s: m_s) + km_h = ("Kilometres Per Hour", lambda km_h: km_h * (1e3 / 3.6e3), lambda m_s: m_s / (1e3 / 3.6e3)) + mi_h = ("Miles Per Hour", lambda mi_h: mi_h * (1.609344e3 / 3.6e3), lambda m_s: m_s / (1.609344e3 / 3.6e3)) + ft_s = ("Feet Per Second", lambda ft_s: ft_s * 3.048e-1, lambda m_s: m_s / 3.048e-1) + + def convert(self, value: float, to_unit: "SpeedUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/temperature.py b/Python_Engine/Python/src/python_toolkit/units/temperature.py new file mode 100644 index 00000000..efca3378 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/temperature.py @@ -0,0 +1,13 @@ +from enum import Enum + +class TemperatureUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to kelvin, lambda from kelvin) + """ + C = ("Celcius", lambda c: c + 273.15, lambda k: k - 273.15) + K = ("Kelvin", lambda k: k, lambda k: k) + F = ("Fahrenheit", lambda f: (f + 459.67) * (5/9), lambda k: (k / (5/9)) - 459.67) + R = ("Rankine", lambda r: r * (5/9), lambda k: k / (5/9)) + + def convert(self, value: float, to_unit: "TemperatureUnit"): + return to_unit.value[2]((self.value[1](value))) diff --git a/Python_Engine/Python/tests/test_bhom_serialiser.py b/Python_Engine/Python/tests/test_bhom_serialiser.py new file mode 100644 index 00000000..0d1385f3 --- /dev/null +++ b/Python_Engine/Python/tests/test_bhom_serialiser.py @@ -0,0 +1,95 @@ +from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, IObject, BHoMJSONDecoder, BHoMJSONEncoder +import uuid +import json + +SERIALISED_BHOM_OBJECT = '{ "_t" : "BH.oM.Adapter.FileSettings", "FileName" : "test.txt", "Directory" : "path/to/file", "BHoM_Guid" : "f428e614-bda1-4228-882e-21d0f318a322", "Name" : "", "_bhomVersion" : "9.2" }' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. +DESERIALISED_BHOM_OBJECT = BHoMObject(_t = "BH.oM.Adapter.FileSettings", bhom_guid = uuid.UUID("f428e614-bda1-4228-882e-21d0f318a322"), file_name = "test.txt", directory = "path/to/file", _bhom_version = "9.2") #TODO: make equivalent BHoMObject here identical to the one above. + +SERIALISED_IOBJECT = '{ "_t" : "BH.oM.Geometry.Point", "X" : 0.10000000000000001, "Y" : 0.10000000000000001, "Z" : 0.10000000000000001, "_bhomVersion" : "9.2" }' +DESERIALISED_IOBJECT = IObject(_t = "BH.oM.Geometry.Point", x = 0.10000000000000001, y = 0.10000000000000001, z = 0.10000000000000001, _bhom_version = "9.2") + +def test_case_convert(): + """Test that the camel and pascal converters are working correctly by using expected outputs and a round trip both ways.""" + #TODO: find edge cases within bhom to see if round trip converters work properly. + #arrange + test_pascal_str = "ThisIsAPascalCaseString" + test_camel_str = "this_is_a_camel_case_string" + expected_pascal_out = "this_is_a_pascal_case_string" + expected_camel_out = "ThisIsACamelCaseString" + + #act + pascal_out = convert_pascal_to_camel(test_pascal_str) + pascal_round_trip = convert_camel_to_pascal(pascal_out) + + camel_out = convert_camel_to_pascal(test_camel_str) + camel_round_trip = convert_pascal_to_camel(camel_out) + + #assert + assert pascal_out == expected_pascal_out, f"pascal conversion got '{pascal_out}' but expected '{expected_pascal_out}'." + assert camel_out == expected_camel_out, f"camel conversion got '{camel_out}' but expected '{expected_camel_out}'." + assert pascal_round_trip == test_pascal_str, f"pascal round trip got '{pascal_round_trip}' but expected '{test_pascal_str}'." + assert camel_round_trip == test_camel_str, f"camel round trip got '{camel_round_trip}' but expected '{test_camel_str}'." + +def test_serialise_bhom_object(): + """Test that bhom objects serialise correctly to a format that the c# bhom serialiser accepts as valid, and with the correct property case.""" + #act + serialised = json.dumps(DESERIALISED_BHOM_OBJECT, cls=BHoMJSONEncoder) + serialised_bhom_object_to_json = DESERIALISED_BHOM_OBJECT.to_json() + round_trip = BHoMObject.from_json(serialised) + + #assert + assert serialised == serialised_bhom_object_to_json, f"Direct serialisation to json differed to BHoMObject to_json method." + assert round_trip == DESERIALISED_BHOM_OBJECT, f"BHoMObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. + +def test_serialise_iobject(): + #act + serialised = json.dumps(DESERIALISED_IOBJECT, cls=BHoMJSONEncoder) + serialised_iobject_to_json = DESERIALISED_IOBJECT.to_json() + round_trip = IObject.from_json(serialised) + + #assert + assert serialised == serialised_iobject_to_json, f"Direct serialisation to json differed to IObject to_json method." + assert round_trip == DESERIALISED_IOBJECT, f"IObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. + +def test_deserialise_bhom_object(): + """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" + #act + obj = json.loads(SERIALISED_BHOM_OBJECT, cls=BHoMJSONDecoder) + obj_bhom_object_from_json = BHoMObject.from_json(SERIALISED_BHOM_OBJECT) + + #assert + assert isinstance(obj, BHoMObject), "JSON decoded an object of the wrong type" + assert obj == DESERIALISED_BHOM_OBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_BHOM_OBJECT})." + assert obj_bhom_object_from_json == obj, f"Direct deserialisation from json differed to BHoMObject from_json method." + +def test_deserialise_iobject(): + """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" + #act + obj = json.loads(SERIALISED_IOBJECT, cls=BHoMJSONDecoder) + obj_iobject_from_json = IObject.from_json(SERIALISED_IOBJECT) + + #assert + assert isinstance(obj, IObject), "JSON decoded an object of the wrong type" + assert obj == DESERIALISED_IOBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_IOBJECT})." + assert obj_iobject_from_json == obj, f"Direct deserialisation from json differed to IObject from_json method." + +def test_subclass(): + #arrange + class TestSubClass(BHoMObject): + _t: str = "BH.oM.Base.CustomObject" + some_other_data: str + + def __init__(self, some_other_data, **kwargs): + self.some_other_data = some_other_data + _t = kwargs.pop("_t", self._t) + super().__init__(_t, **kwargs) + + #act + test_object = TestSubClass("this is some test data") + test_object_json = test_object.to_json() + test_object_round_trip = TestSubClass.from_json(test_object_json) + + #assert + assert test_object == test_object_round_trip + assert test_object.some_other_data == "this is some test data" + assert test_object._t == "BH.oM.Base.CustomObject" \ No newline at end of file diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 5586c8dd..932a18fd 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -4,6 +4,7 @@ from pathlib import Path import matplotlib.pyplot as plt +import tkinter as tk from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow from python_toolkit.bhom_tkinter.widgets import ( @@ -28,6 +29,8 @@ ProcessingWindow, WarningBox, ) +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.windows.modal_window import BHoMModalWindow def _demo_callback(*_args, **_kwargs): @@ -316,3 +319,32 @@ def test_rebuild(): root.destroy_root() + +def test_modal_window_is_themed_toplevel(): + """Modal windows should be themed Toplevels, not tk.Tk roots.""" + host = BHoMBaseWindow(title="Modal host", show_submit=False, show_close=False) + host.withdraw() + + modal = BHoMModalWindow( + host, + title="Modal test", + width=420, + height=260, + show_close=True, + ) + assert isinstance(modal, tk.Toplevel) + assert isinstance(modal, BHoMBaseChildWindow) + assert hasattr(modal, "content_frame") + assert not isinstance(modal, BHoMBaseWindow) + + Label( + modal.content_frame, + text="Modal body", + build_options=PackingOptions(anchor="w"), + ).build() + modal.update_idletasks() + assert modal.winfo_exists() + + modal.close() + host.destroy_root() + diff --git a/Python_Engine/Python/tests/test_plot.py b/Python_Engine/Python/tests/test_plot.py index 3544bc6c..bf94258b 100644 --- a/Python_Engine/Python/tests/test_plot.py +++ b/Python_Engine/Python/tests/test_plot.py @@ -160,4 +160,31 @@ def test_heatmap(): ), plt.Axes, ) - plt.close("all") \ No newline at end of file + plt.close("all") + + +def _heatmap_cell_count(series: pd.Series) -> int: + ax = heatmap(series) + count = np.ma.getdata(ax.collections[0].get_array()).size + plt.close("all") + return count + + +def test_heatmap_keeps_last_bins(): + hourly_two_days = pd.Series( + np.arange(48, dtype=float), + index=pd.date_range("2000-01-01", periods=48, freq="h"), + ) + assert _heatmap_cell_count(hourly_two_days) == 48 + + half_hourly_day = pd.Series( + np.arange(48, dtype=float), + index=pd.date_range("2000-01-01", periods=48, freq="30min"), + ) + assert _heatmap_cell_count(half_hourly_day) == 48 + + hourly_one_day = pd.Series( + np.arange(24, dtype=float), + index=pd.date_range("2000-01-01", periods=24, freq="h"), + ) + assert _heatmap_cell_count(hourly_one_day) == 24 \ No newline at end of file diff --git a/Python_Engine/Python_Engine.csproj b/Python_Engine/Python_Engine.csproj index c2e02e29..d10626af 100644 --- a/Python_Engine/Python_Engine.csproj +++ b/Python_Engine/Python_Engine.csproj @@ -7,7 +7,7 @@ BHoM Copyright © https://github.com/BHoM BH.Engine.Python - 9.2.0.0 + 9.3.0.0 ..\Build\ @@ -39,6 +39,6 @@ - + diff --git a/Python_oM/Python_oM.csproj b/Python_oM/Python_oM.csproj index 251d3dbe..854b7fe8 100644 --- a/Python_oM/Python_oM.csproj +++ b/Python_oM/Python_oM.csproj @@ -8,7 +8,7 @@ BHoM Copyright © https://github.com/BHoM BH.oM.Python - 9.2.0.0 + 9.3.0.0 ..\Build\