diff --git a/Python_Engine/Compute/BasePythonEnvironment.cs b/Python_Engine/Compute/BasePythonEnvironment.cs index 4d32501..fa90129 100644 --- a/Python_Engine/Compute/BasePythonEnvironment.cs +++ b/Python_Engine/Compute/BasePythonEnvironment.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -79,3 +79,4 @@ public static PythonEnvironment BasePythonEnvironment( } + diff --git a/Python_Engine/Compute/Download.cs b/Python_Engine/Compute/Download.cs index c609247..054f03a 100644 --- a/Python_Engine/Compute/Download.cs +++ b/Python_Engine/Compute/Download.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -91,7 +91,7 @@ public static string DownloadFile( [Description("Download and install a specified version of python, and return the executable for it.")] [Input("version", "The version of python to download.")] - [Output("pythonExecutable", "The executable (python.exe) for the python version that was installed")] + [Output("pythonExecutable", "The executable (python.exe) for the python version that was installed.")] public static string DownloadPythonVersion(this PythonVersion version) { string url = version.EmbeddableURL(); @@ -122,6 +122,7 @@ public static string DownloadPythonVersion(this PythonVersion version) } }) { + install.StartInfo.Environment["PYTHONHOME"] = ""; install.Start(); string stderr = install.StandardError.ReadToEnd(); install.WaitForExit(); @@ -148,3 +149,4 @@ public static string DownloadPythonVersion(this PythonVersion version) } + diff --git a/Python_Engine/Compute/InstallPackage.cs b/Python_Engine/Compute/Install.cs similarity index 96% rename from Python_Engine/Compute/InstallPackage.cs rename to Python_Engine/Compute/Install.cs index f7f331d..7b7f0c9 100644 --- a/Python_Engine/Compute/InstallPackage.cs +++ b/Python_Engine/Compute/Install.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -59,9 +59,10 @@ List packages FileName = executable, Arguments = $"-m pip install --no-warn-script-location {packagesString}", UseShellExecute = false, - RedirectStandardError = true, + RedirectStandardError = true } }; + process.StartInfo.Environment["PYTHONHOME"] = ""; using (Process p = Process.Start(process.StartInfo)) { string standardError = p.StandardError.ReadToEnd(); @@ -111,6 +112,7 @@ string requirements RedirectStandardError = true, } }; + process.StartInfo.Environment["PYTHONHOME"] = ""; using (Process p = Process.Start(process.StartInfo)) { string standardError = p.StandardError.ReadToEnd(); @@ -155,6 +157,7 @@ string packageDirectory RedirectStandardError = true, } }; + process.StartInfo.Environment["PYTHONHOME"] = ""; using (Process p = Process.Start(process.StartInfo)) { string standardError = p.StandardError.ReadToEnd(); @@ -178,3 +181,4 @@ string packageDirectory } + diff --git a/Python_Engine/Compute/Remove.cs b/Python_Engine/Compute/Remove.cs index 48393e0..aca0d76 100644 --- a/Python_Engine/Compute/Remove.cs +++ b/Python_Engine/Compute/Remove.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -109,6 +109,7 @@ public static void RemoveBaseVersion(PythonVersion version = PythonVersion.v3_10 } }) { + uninstall.StartInfo.Environment["PYTHONHOME"] = ""; uninstall.Start(); string stderr = uninstall.StandardError.ReadToEnd(); uninstall.WaitForExit(); @@ -139,3 +140,4 @@ public static void RemoveEverything() } + diff --git a/Python_Engine/Compute/RequirementsTxt.cs b/Python_Engine/Compute/RequirementsTxt.cs index c2bf549..7084732 100644 --- a/Python_Engine/Compute/RequirementsTxt.cs +++ b/Python_Engine/Compute/RequirementsTxt.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -69,6 +69,8 @@ public static string RequirementsTxt( RedirectStandardOutput = true } }; + + process.StartInfo.Environment["PYTHONHOME"] = ""; using (Process p = Process.Start(process.StartInfo)) { StreamWriter sr = new StreamWriter(targetPath); @@ -86,3 +88,4 @@ public static string RequirementsTxt( } + diff --git a/Python_Engine/Compute/Run.cs b/Python_Engine/Compute/Run.cs index f3e2760..7340e69 100644 --- a/Python_Engine/Compute/Run.cs +++ b/Python_Engine/Compute/Run.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -52,6 +52,7 @@ public static string RunCommandStdout(string command, bool hideWindows = true, s process.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden; commandMode = "/C"; } + process.StartInfo.Environment["PYTHONHOME"] = ""; process.StartInfo.FileName = "cmd.exe"; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; @@ -100,3 +101,4 @@ public static string RunPythonString(this oM.Python.PythonEnvironment env, strin } + diff --git a/Python_Engine/Compute/UnzipFile.cs b/Python_Engine/Compute/UnzipFile.cs index dfbef9b..5c292a4 100644 --- a/Python_Engine/Compute/UnzipFile.cs +++ b/Python_Engine/Compute/UnzipFile.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -83,3 +83,4 @@ public static void UnzipFile( } + diff --git a/Python_Engine/Compute/VirtualEnvironment.cs b/Python_Engine/Compute/VirtualEnvironment.cs index 3292aeb..f9d4952 100644 --- a/Python_Engine/Compute/VirtualEnvironment.cs +++ b/Python_Engine/Compute/VirtualEnvironment.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -91,6 +91,8 @@ public static PythonEnvironment VirtualEnvironment(this PythonVersion version, s } }; + process.StartInfo.Environment["PYTHONHOME"] = ""; + using (Process p = Process.Start(process.StartInfo)) { string standardError = p.StandardError.ReadToEnd(); @@ -114,6 +116,8 @@ public static PythonEnvironment VirtualEnvironment(this PythonVersion version, s } }; + process.StartInfo.Environment["PYTHONHOME"] = ""; + using (Process p = Process.Start(process2.StartInfo)) { string standardError = p.StandardError.ReadToEnd(); @@ -128,3 +132,4 @@ public static PythonEnvironment VirtualEnvironment(this PythonVersion version, s } + diff --git a/Python_Engine/Convert/ToPython.cs b/Python_Engine/Convert/ToPython.cs index 597ae42..193669d 100644 --- a/Python_Engine/Convert/ToPython.cs +++ b/Python_Engine/Convert/ToPython.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -39,3 +39,4 @@ public static bool ToPython(this T[,] input) } } } + diff --git a/Python_Engine/Modify/AddQuotesIfRequired.cs b/Python_Engine/Modify/AddQuotesIfRequired.cs index fbe8db3..ad54d61 100644 --- a/Python_Engine/Modify/AddQuotesIfRequired.cs +++ b/Python_Engine/Modify/AddQuotesIfRequired.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -48,3 +48,4 @@ public static string AddQuotesIfRequired(this string path) + diff --git a/Python_Engine/Python/src/python_toolkit/__init__.py b/Python_Engine/Python/src/python_toolkit/__init__.py new file mode 100644 index 0000000..3e022cf --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/__init__.py @@ -0,0 +1,20 @@ +"""Base module for the python_toolkit package.""" +# pylint: disable=E0401 +import getpass +import os +from pathlib import Path + +import matplotlib.pyplot as plt + +# pylint: disable=E0401 + +# get common paths +DATA_DIRECTORY = (Path(__file__).parent.parent / "data").absolute() +BHOM_DIRECTORY = (Path(__file__).parent / "bhom").absolute() +HOME_DIRECTORY = (Path("C:/Users/") / getpass.getuser()).absolute() + +TOOLKIT_NAME = "Python_Toolkit" + +if os.name == "nt": + # override "HOME" in case this is set to something other than default for windows + os.environ["HOME"] = (Path("C:/Users/") / getpass.getuser()).as_posix() diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom.mplstyle b/Python_Engine/Python/src/python_toolkit/bhom.mplstyle similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom/bhom.mplstyle rename to Python_Engine/Python/src/python_toolkit/bhom.mplstyle diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index 28f3edc..bc0efd2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -1,5 +1,6 @@ """Root for the bhom subpackage.""" +import os from pathlib import Path # pylint: disable=E0401 from os import path import tempfile @@ -9,6 +10,13 @@ TOOLKIT_NAME = "Python_Toolkit" BHOM_VERSION = importlib.metadata.version("python_toolkit") +#Environment variable that if set disables BHoM analytics logging. +DISABLE_ANALYTICS = os.environ.get("DISABLE_BHOM_ANALYTICS", None) +if DISABLE_ANALYTICS is None: + DISABLE_ANALYTICS = False +else: + 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 diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index 0dcbf88..4b684a3 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -1,21 +1,128 @@ """BHoM analytics decorator.""" # pylint: disable=E0401 +import codecs +from dataclasses import dataclass, field import inspect +from itertools import groupby +import itertools import json +import os +from pathlib import Path +import socket import sys +import traceback import uuid from functools import wraps -from typing import Any, Callable +from typing import Any, Callable, Dict, List, Union from datetime import datetime # pylint: enable=E0401 -from .logging import ANALYTICS_LOGGER -from .util import csharp_ticks -from . import BHOM_VERSION, TOOLKIT_NAME, BHOM_LOG_FOLDER - - -def bhom_analytics() -> Callable: +from .logging import ANALYTICS_LOGGER, CONSOLE_LOGGER +from .util import bson_unix_ticks, bson_unix_ticks_to_datetime +from . import BHOM_VERSION, TOOLKIT_NAME, BHOM_LOG_FOLDER, DISABLE_ANALYTICS + +@dataclass +class UsageLogEntry(): + BHoMVersion:str = BHOM_VERSION + BHoM_Guid:uuid.UUID = uuid.uuid4() + CallerName:str = "" + ComponentId:uuid.UUID = uuid.uuid4() + CustomData:Dict = field(default_factory = {"interpreter", sys.executable}) + Errors:List[str] = field(default_factory = []) + FileId:str = "" + FileName:str = "" + Fragments:List[str] = field(default_factory = []) + Name:str = "" + ProjectID:str = "" + SelectedItem:Dict = field(default_factory = {"MethodName": "", "Parameters": [], "TypeName": ""}) + Time:Dict = field(default_factory = {"$date": 0}) + UI:str = "Python" + UiVersion:str = sys.version + _t:str = "BH.oM.Base.UsageLogEntry" + + @classmethod + def from_json(cls, json_str:str) -> 'UsageLogEntry': + d = json.loads(json_str) + if "CustomData" not in d: + d["CustomData"] = None + if "Fragments" not in d: + d["Fragments"] = None + return UsageLogEntry(d["BHoMVersion"], d["BHoM_Guid"], d["CallerName"], d["ComponentId"], d["CustomData"], d["Errors"], d["FileId"], d["FileName"], d["Fragments"], d["Name"], d["ProjectID"], d["SelectedItem"], d["Time"], d["UI"], d["UiVersion"]) + +def load_logs_from_file(filename:str) -> List[UsageLogEntry]: + logs:List[UsageLogEntry] = [] + + #adapted from https://stackoverflow.com/questions/30629297/remove-byte-order-mark-from-objects-in-a-list + #due to some files generated by BHoM logs being encoded with utf-8 BOM instead of utf-8 + with open(filename, "r") as f: + lines = f.readlines() + if lines[0].__contains__(codecs.BOM_UTF8.decode(f.encoding)): + # A Byte Order Mark is present + lines[0] = lines[0].strip(codecs.BOM_UTF8.decode(f.encoding)) + for line in lines: + if len(line) != 0: + logs.append(UsageLogEntry.from_json(line)) + + return logs + +def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: + db_entries:List[Dict] = [] + + usage_log_entries.sort(key=lambda x: x.ProjectID) + + for file_id, filegroup in groupby(usage_log_entries, lambda x: x.FileId): + filegroup = list(filegroup) + project_id = filegroup[0].ProjectID + filename = filegroup[0].FileName + filegroup.sort(key = lambda x: x.CallerName + str(x.SelectedItem)) + + for method_name, methodgroup in groupby(filegroup, lambda x: x.CallerName + str(x.SelectedItem)): + methodgroup = list(methodgroup) + first_entry = methodgroup[0] + + db_entries.append({ + "StartTime": bson_unix_ticks_to_datetime(min(methodgroup, key=lambda x: x.Time["$date"]).Time["$date"], short=True), + "EndTime": bson_unix_ticks_to_datetime(max(methodgroup, key=lambda x: x.Time["$date"]).Time["$date"], short=True), + "UI": first_entry.UI, + "UiVersion":first_entry.UiVersion, + "CallerName": first_entry.CallerName, + "SelectedItem": first_entry.SelectedItem, + "Computer": socket.gethostname(), + "UserName": os.environ.get("USERNAME"), + "BHoMVersion": BHOM_VERSION, + "FileId": file_id, + "FileName": filename, + "ProjectID": project_id, + "NbCallingComponents": len(set([a.ComponentId for a in methodgroup])), + "TotalNbCals": len(methodgroup), + "Errors": list(itertools.chain.from_iterable([x.Errors for x in methodgroup])), + "_t": "BH.oM.BHoMAnalytics.UsageEntry" + }) + + return db_entries + +def convert_exc_info_to_bhom_error(exc_info): + time = bson_unix_ticks(datetime.now(), short=True) + utcTime = bson_unix_ticks(short=True) + stack_trace = traceback.extract_tb(exc_info[2]) + message = str(exc_info[1]) + Type = "Error" #using string but ideally this would be an enum value. + return {"Time": {"$date": time}, "UtcTime": {"$date": utcTime}, "StackTrace": stack_trace, "Message": message, "Type": Type, "_t": "BH.oM.Base.Debugging.Event"} + +global PROJECT_NUMBER +PROJECT_NUMBER = None + +def set_project_number(project_number: Union[str, None]): + global PROJECT_NUMBER + CONSOLE_LOGGER.debug(f"Setting project number: {PROJECT_NUMBER} to {project_number}") + PROJECT_NUMBER = project_number + +def get_project_number() -> Union[str, None]: + CONSOLE_LOGGER.debug(f"Retrieving project number: {PROJECT_NUMBER}") + return PROJECT_NUMBER + +def bhom_analytics(project_id:Callable = get_project_number, disable:bool = DISABLE_ANALYTICS) -> Callable: """Decorator for capturing usage data. Returns @@ -24,6 +131,8 @@ def bhom_analytics() -> Callable: The decorated function. """ + _componentId = uuid.uuid4() + def decorator(function: Callable): """A decorator to capture usage data for called methods/functions. @@ -42,8 +151,19 @@ def decorator(function: Callable): 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) + _id = uuid.uuid4() + #for now for file IDs, generate one using the project ID + pid = project_id() + if pid == None: + pid = "" + + file_id = uuid.uuid3(uuid.NAMESPACE_OID, pid) + # get the data being passed to the function, expected dtype and return type argspec = inspect.getfullargspec(function)[-1] argspec.pop("return", None) @@ -54,36 +174,34 @@ def wrapper(*args, **kwargs) -> Any: "BHoMVersion": BHOM_VERSION, "BHoM_Guid": _id, "CallerName": function.__name__, - "ComponentId": _id, - "CustomData": {"interpreter", sys.executable}, + "ComponentId": _componentId, + "CustomData": {"interpreter": sys.executable}, "Errors": [], - "FileId": "", - "FileName": "", + "FileId": str(file_id), + "FileName": str(file_id), "Fragments": [], "Name": "", - # TODO - get project properties from another function/logging - # method (or from BHoM DLL analytics capture ...) - "ProjectID": "", + "ProjectID": project_id(), "SelectedItem": { "MethodName": function.__name__, "Parameters": _args, "TypeName": f"{function.__module__}.{function.__qualname__}" }, "Time": { - "$date": csharp_ticks(short=True), + "$date": bson_unix_ticks(short=True), }, "UI": "Python", - "UiVersion": TOOLKIT_NAME, + "UiVersion": sys.version, "_t": "BH.oM.Base.UsageLogEntry", } try: result = function(*args, **kwargs) except Exception as exc: # pylint: disable=broad-except - exec_metadata["Errors"].extend(sys.exc_info()) + exec_metadata["Errors"].extend(convert_exc_info_to_bhom_error(sys.exc_info())) raise exc finally: - log_file = BHOM_LOG_FOLDER / f"{function.__module__.split('.')[0]}_{datetime.now().strftime('%Y%m%d')}.log" + log_file = BHOM_LOG_FOLDER / f"Usage_{function.__module__.split('.')[0]}_{datetime.now().strftime('%Y%m%d')}.log" if ANALYTICS_LOGGER.handlers[0].baseFilename != str(log_file): ANALYTICS_LOGGER.handlers[0].close() diff --git a/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png b/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png new file mode 100644 index 0000000..3c28410 Binary files /dev/null and b/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png differ diff --git a/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png b/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png new file mode 100644 index 0000000..a9a6a86 Binary files /dev/null and b/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png differ diff --git a/Python_Engine/Python/src/python_toolkit/bhom/logging/console.py b/Python_Engine/Python/src/python_toolkit/bhom/logging/console.py index d36178b..029dc9a 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/logging/console.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/logging/console.py @@ -8,12 +8,14 @@ from .. import TOOLKIT_NAME +level = logging.INFO + formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s") handler = logging.StreamHandler(sys.stdout) -handler.setLevel(logging.DEBUG) +handler.setLevel(level) handler.setFormatter(formatter) CONSOLE_LOGGER = logging.getLogger(f"{TOOLKIT_NAME}[console]") CONSOLE_LOGGER.propagate = False -CONSOLE_LOGGER.setLevel(logging.DEBUG) +CONSOLE_LOGGER.setLevel(level) CONSOLE_LOGGER.addHandler(handler) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/util.py b/Python_Engine/Python/src/python_toolkit/bhom/util.py index 3200384..1c06ab0 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/util.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/util.py @@ -1,12 +1,11 @@ """General utility functions.""" # pylint: disable=E0401 -from datetime import datetime - +from datetime import datetime, timedelta # pylint: enable=E0401 -def csharp_ticks(date_time: datetime = datetime.utcnow(), short: bool = False) -> int: - """Python implementation of C# DateTime.UtcNow.Ticks. +def bson_unix_ticks(date_time: datetime = datetime.utcnow(), short: bool = False) -> int: + """Python implementation of unix ticks. Args: date_time (datetime, optional): The datetime to convert to ticks. Defaults to datetime.utcnow(). @@ -16,9 +15,17 @@ def csharp_ticks(date_time: datetime = datetime.utcnow(), short: bool = False) - int: The ticks. """ - _ticks = (date_time - datetime(1, 1, 1)).total_seconds() + _ticks = (date_time - datetime(1970, 1, 1)).total_seconds() * 10**3 if short: return int(_ticks) - return int(_ticks * (10**7)) + return int(_ticks*(10**3)) + +def bson_unix_ticks_to_datetime(ticks: int, short:bool = False) -> datetime: + + if not short: + ticks = int(ticks / (10**3)) + + return datetime(1970, 1, 1) + timedelta(milliseconds=ticks) + diff --git a/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle new file mode 100644 index 0000000..105e497 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle @@ -0,0 +1,88 @@ +# Default matplotlib settings for this toolkit. + +# Text +text.color: white + +# Set custom colors. All colors are in web style hex format. +axes.prop_cycle: cycler('color', ['702F8A', 'E63187', '00A9E0', 'FFCF04', '6CC24E', 'EB671C', '00A499', 'D50032', '24135F', '6D104E', '006DA8', 'D06A13', '5D822D', 'F0AC1B', '1C3660', 'BC204B', '8F72B0', 'FCD16D', '8DB9CA', 'EE7837', 'AFC1A2', 'B72B77', 'A0D2C9', 'E6484D']) + +# Face settings +axes.facecolor: black +axes.edgecolor: white + +# Style spines +axes.linewidth: 0.8 +axes.spines.top: False +axes.spines.left: True +axes.spines.right: False +axes.spines.bottom: True + +# Set line styling for line plots +lines.linewidth: 1 +lines.solid_capstyle: round +lines.dash_capstyle: round + +# Grid style +axes.axisbelow: True +axes.grid: true +axes.grid.axis: both +grid.color: 958B82 +grid.linestyle: -- +grid.linewidth: 0.5 + +# Setting font sizes and spacing +axes.labelsize: medium +axes.labelweight: semibold +axes.labelcolor: white +axes.ymargin: 0.1 +font.family: sans-serif +font.sans-serif: Segoe UI +font.size: 10 +xtick.labelsize: medium +xtick.labelcolor: white +xtick.major.pad: 3.5 +ytick.labelsize: medium +ytick.labelcolor: white +ytick.major.pad: 3.5 + +# date formatter +date.autoformatter.day: %b-%d +date.autoformatter.hour: %b-%d %H +date.autoformatter.microsecond: %M:%S.%f +date.autoformatter.minute: %d %H:%M +date.autoformatter.month: %b +date.autoformatter.second: %H:%M:%S +date.autoformatter.year: %Y + +# Title +axes.titlelocation: left +axes.titlepad: 6 +axes.titlesize: large +axes.titleweight: bold + +# Remove major and minor ticks except for on the x-axis. +xtick.color: white +xtick.major.size: 3 +xtick.minor.size: 2 +ytick.color: white +ytick.major.size: 3 +ytick.minor.size: 2 + +# Set spacing for figure and also DPI. +figure.subplot.left: 0.08 +figure.subplot.right: 0.95 +figure.subplot.bottom: 0.07 +figure.figsize: 12, 5 +figure.dpi: 150 +figure.facecolor: black + +# Properties for saving the figure. Ensure a high DPI when saving so we have a good resolution. +savefig.dpi: 300 +savefig.facecolor: black +savefig.bbox: tight +savefig.pad_inches: 0.2 + +# Legend Styling +legend.framealpha: 0 +legend.frameon: False +legend.facecolor: inherit \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py new file mode 100644 index 0000000..db2b582 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -0,0 +1,50 @@ +from .widgets import ( + BHoMBaseWidget, + PackingOptions, + CalendarWidget, + CheckboxSelection, + MultiBoxSelection, + CmapSelector, + ColourPicker, + DropDownSelection, + FigureContainer, + ScrollableListBox, + PathSelector, + RadioSelection, + ValidatedEntryBox, +) +from .windows import ( + DirectoryFileSelector, + LandingPage, + ProcessingWindow, + WarningBox, +) + +from .theming import ( + TclTheme, + ThemeManager, + LIGHT, + DARK, +) + +__all__ = [ + "BHoMBaseWidget", + "PackingOptions", + "CalendarWidget", + "CheckboxSelection", + "MultiBoxSelection", + "CmapSelector", + "ColourPicker", + "DropDownSelection", + "FigureContainer", + "ScrollableListBox", + "PathSelector", + "RadioSelection", + "ValidatedEntryBox", + "DirectoryFileSelector", + "LandingPage", + "ProcessingWindow", + "WarningBox", + "TclTheme", + "ThemeManager" +] 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 new file mode 100644 index 0000000..8658066 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -0,0 +1,664 @@ +"""Base themed Tk window used by BHoM toolkit GUI 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 os +import matplotlib as mpl + +# Centralized matplotlib backend selection: +# - Allow override via `MPLBACKEND` environment variable (e.g. set to 'Agg' for headless CI). +# - Default to 'TkAgg' for Tkinter embedding; fallback to 'Agg' if the requested backend is unavailable. +backend = os.environ.get("MPLBACKEND") +if not backend: + backend = "TkAgg" +try: + mpl.use(backend, force=True) +except Exception: + mpl.use("Agg", force=True) + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button +import python_toolkit +from python_toolkit.bhom_tkinter.theming.theme import ThemeManager + +class BHoMBaseWindow(tk.Tk): + """ + A reusable default root window template for tkinter applications. + Includes a branded banner, content area, and optional action buttons. + """ + + def __init__( + self, + 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 = "auto", + widgets: Optional[List[BHoMBaseWidget]] = None, + top_most: bool = True, + fullscreen: bool = False, + buttons_side: Literal["left", "right"] = "right", + grid_dimensions: Optional[tuple[int, int]] = None, + **kwargs + ): + """ + Initialize the default root window. + + Args: + title (str): Window and banner title text. + logo_path (Path, optional): Path to logo image file. + icon_path (Path, optional): Path to window icon file (.ico recommended on Windows). + min_width (int): Minimum window width. + min_height (int): Minimum window height. + width (int, optional): Fixed width (overrides dynamic sizing). + height (int, optional): Fixed height (overrides dynamic sizing). + resizable (bool): Whether window can be resized. + center_on_screen (bool): Center window on screen. + show_submit (bool): Show submit button. + submit_text (str): Text for submit button. + submit_command (callable, optional): Command for submit button. + show_close (bool): Show close button. + close_text (str): Text for close button. + close_command (callable, optional): Command for close button. + on_close_window (callable, optional): Command when X is pressed. + theme_path (Path, optional): Path to custom TCL theme file. If None, uses default style.tcl. + theme_mode (str): Theme mode - "light", "dark", or "auto" to detect from system (default: "auto"). + 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. + **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 + if self.top_most: + self.attributes("-topmost", True) + + 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" + ) + 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 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__": + + + ### TEST SIMPLE + + from python_toolkit.bhom_tkinter.widgets import Label, Button + + test = BHoMBaseWindow( + title="Test Window", + theme_mode="light", + ) + + test.widgets.append(Label(test.content_frame, text="Hello, World!")) + test.widgets.append(Button(test.content_frame, text="Click Me", command=lambda: print("Button Clicked!"), helper_text="This is a button.", item_title="Button Widget Title")) + + test.build() + test.mainloop() + print(test.get()) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py new file mode 100644 index 0000000..db0139e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py @@ -0,0 +1 @@ +from .theme import TclTheme, ThemeManager, LIGHT, DARK \ No newline at end of file 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 new file mode 100644 index 0000000..acedbf7 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -0,0 +1,778 @@ +# Dark and Light Mode Theme for Python Toolkit +# Professional themes inspired by modern IDEs +# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif + +# ============================================================================ +# DARK MODE THEME +# ============================================================================ + +# Split theme file generated from bhom_style.tcl +namespace eval ttk::theme::bhom_dark { + variable colors + array set colors { + -bg "#1e1e1e" + -fg "#ffffff" + -dark "#2d2d2d" + -darker "#252526" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#c4d600" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#3d3d3d" + -border-light "#555555" + -disabled-bg "#2d2d2d" + -disabled-fg "#666666" + -inputbg "#2d2d2d" + -inputfg "#ffffff" + -hover-bg "#424242" + -active-bg "#383838" + -text-secondary "#999999" + } + + ttk::style theme create bhom_dark -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -anchor center \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 12 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 8 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-hover-bg) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-primary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-secondary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - lime green from app.css + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-tertiary) \ + -foreground "#000000" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#9fad00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#9fad00" \ + pressed "#9fad00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-success) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Larger checkbutton variant used by CheckboxSelection widget + ttk::style layout Checkbox.TCheckbutton { + Checkbutton.padding -sticky nswe -children { + Checkbutton.indicator -side left -sticky {} + Checkbutton.label -side left -sticky w + } + } + + ttk::style configure Checkbox.TCheckbutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorrelief flat \ + -indicatorsize 18 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) + + ttk::style map Checkbox.TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Larger radiobutton variant used by RadioSelection widget + ttk::style layout Radio.TRadiobutton { + Radiobutton.padding -sticky nswe -children { + Radiobutton.indicator -side left -sticky {} + Radiobutton.label -side left -sticky w + } + } + + ttk::style configure Radio.TRadiobutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorsize 15 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorbackground $colors(-inputbg) \ + -indicatorforeground $colors(-inputbg) \ + -upperbordercolor $colors(-border) \ + -lowerbordercolor $colors(-border) + + ttk::style map Radio.TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorforeground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -upperbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] \ + -lowerbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - clearer track and larger thumb + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-border) \ + -bordercolor $colors(-border-light) \ + -slidercolor $colors(-primary) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Colour picker scale variant - stronger contrast and larger grab handle + ttk::style configure ColourPicker.Horizontal.TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -slidercolor $colors(-primary-light) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 24 + + ttk::style map ColourPicker.Horizontal.TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + 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 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# ============================================================================ +# LIGHT MODE THEME +# ============================================================================ + +# 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 *selectBackground "#1b6ec2" +option add *selectForeground "#ffffff" +option add *activeBackground "#2a2d2e" +option add *activeForeground "#ffffff" +option add *highlightColor "#1b6ec2" +option add *highlightBackground "#1e1e1e" +option add *disabledForeground "#666666" +option add *insertBackground "#ffffff" +option add *troughColor "#2d2d2d" +option add *borderWidth 1 +option add *relief flat + +# Listbox specific - matches design theme +option add *Listbox.background "#2d2d2d" +option add *Listbox.foreground "#ffffff" +option add *Listbox.selectBackground "#1b6ec2" +option add *Listbox.selectForeground "#ffffff" +option add *Listbox.font {{Segoe UI} 10} +option add *Listbox.borderWidth 1 +option add *Listbox.relief flat +option add *Listbox.highlightThickness 1 +option add *Listbox.highlightColor "#3d3d3d" +option add *Listbox.highlightBackground "#3d3d3d" + +# Text widget specific +option add *Text.background "#2d2d2d" +option add *Text.foreground "#ffffff" +option add *Text.insertBackground "#ffffff" +option add *Text.selectBackground "#1b6ec2" +option add *Text.selectForeground "#ffffff" +option add *Text.font {{Segoe UI} 10} +option add *Text.borderWidth 1 +option add *Text.relief flat +option add *Text.highlightThickness 1 +option add *Text.highlightColor "#1b6ec2" + +# Canvas specific +option add *Canvas.background "#1e1e1e" +option add *Canvas.highlightThickness 0 + +# Menu specific +option add *Menu.background "#2d2d2d" +option add *Menu.foreground "#ffffff" +option add *Menu.activeBackground "#1b6ec2" +option add *Menu.activeForeground "#ffffff" +option add *Menu.activeBorderWidth 0 +option add *Menu.borderWidth 1 +option add *Menu.relief flat +option add *Menu.font {{Segoe UI} 10} + +# Toplevel/window specific +option add *Toplevel.background "#1e1e1e" + +# Message widget +option add *Message.background "#1e1e1e" +option add *Message.foreground "#ffffff" +option add *Message.font {{Segoe UI} 10} 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 new file mode 100644 index 0000000..c6973f4 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -0,0 +1,777 @@ +# Dark and Light Mode Theme for Python Toolkit +# Professional themes inspired by modern IDEs +# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif + +# ============================================================================ +# LIGHT MODE THEME +# ============================================================================ + +# Split theme file generated from bhom_style.tcl +namespace eval ttk::theme::bhom_light { + variable colors + array set colors { + -bg "#ffffff" + -fg "#1a1a1a" + -dark "#f3f3f3" + -darker "#ececec" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#a4a900" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#d0d0d0" + -border-light "#e5e5e5" + -disabled-bg "#f3f3f3" + -disabled-fg "#999999" + -inputbg "#ffffff" + -inputfg "#1a1a1a" + -hover-bg "#e0e0e0" + -active-bg "#ececec" + -text-secondary "#666666" + } + + ttk::style theme create bhom_light -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -anchor center \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 12 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 8 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-hover-bg) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-primary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -foreground [list \ + active "#ffffff" \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-secondary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - muted yellow for light mode + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-tertiary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#8a8a00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#8a8a00" \ + pressed "#8a8a00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-success) \ + -foreground "#ffffff" \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Larger checkbutton variant used by CheckboxSelection widget + ttk::style layout Checkbox.TCheckbutton { + Checkbutton.padding -sticky nswe -children { + Checkbutton.indicator -side left -sticky {} + Checkbutton.label -side left -sticky w + } + } + + ttk::style configure Checkbox.TCheckbutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorrelief flat \ + -indicatorsize 18 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) + + ttk::style map Checkbox.TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Larger radiobutton variant used by RadioSelection widget + ttk::style layout Radio.TRadiobutton { + Radiobutton.padding -sticky nswe -children { + Radiobutton.indicator -side left -sticky {} + Radiobutton.label -side left -sticky w + } + } + + ttk::style configure Radio.TRadiobutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorsize 15 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorbackground $colors(-inputbg) \ + -indicatorforeground $colors(-inputbg) \ + -upperbordercolor $colors(-border) \ + -lowerbordercolor $colors(-border) + + ttk::style map Radio.TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorforeground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -upperbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] \ + -lowerbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - clearer track and larger thumb + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-border-light) \ + -bordercolor $colors(-border) \ + -slidercolor $colors(-primary) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Colour picker scale variant - stronger contrast and larger grab handle + ttk::style configure ColourPicker.Horizontal.TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border) \ + -slidercolor $colors(-primary-light) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 24 + + ttk::style map ColourPicker.Horizontal.TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + 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 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# 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 *selectBackground "#1b6ec2" +option add *selectForeground "#ffffff" +option add *activeBackground "#e0e0e0" +option add *activeForeground "#1a1a1a" +option add *highlightColor "#1b6ec2" +option add *highlightBackground "#ffffff" +option add *disabledForeground "#999999" +option add *insertBackground "#1a1a1a" +option add *troughColor "#f3f3f3" +option add *borderWidth 1 +option add *relief flat + +# Listbox specific - matches design theme +option add *Listbox.background "#ffffff" +option add *Listbox.foreground "#1a1a1a" +option add *Listbox.selectBackground "#1b6ec2" +option add *Listbox.selectForeground "#ffffff" +option add *Listbox.font {{Segoe UI} 10} +option add *Listbox.borderWidth 1 +option add *Listbox.relief flat +option add *Listbox.highlightThickness 1 +option add *Listbox.highlightColor "#d0d0d0" +option add *Listbox.highlightBackground "#d0d0d0" + +# Text widget specific +option add *Text.background "#ffffff" +option add *Text.foreground "#1a1a1a" +option add *Text.insertBackground "#1a1a1a" +option add *Text.selectBackground "#1b6ec2" +option add *Text.selectForeground "#ffffff" +option add *Text.font {{Segoe UI} 10} +option add *Text.borderWidth 1 +option add *Text.relief flat +option add *Text.highlightThickness 1 +option add *Text.highlightColor "#1b6ec2" + +# Canvas specific +option add *Canvas.background "#ffffff" +option add *Canvas.highlightThickness 0 + +# Menu specific +option add *Menu.background "#f3f3f3" +option add *Menu.foreground "#1a1a1a" +option add *Menu.activeBackground "#1b6ec2" +option add *Menu.activeForeground "#ffffff" +option add *Menu.activeBorderWidth 0 +option add *Menu.borderWidth 1 +option add *Menu.relief flat +option add *Menu.font {{Segoe UI} 10} + +# Toplevel/window specific +option add *Toplevel.background "#ffffff" + +# Message widget +option add *Message.background "#ffffff" +option add *Message.foreground "#1a1a1a" +option add *Message.font {{Segoe UI} 10} diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py new file mode 100644 index 0000000..4143869 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from pathlib import Path +import python_toolkit + +@dataclass +class TclTheme: + name: str + path: Path + logo_path: Path|None = None + icon_path: Path|None = None + dark_theme: bool = False + + def __post_init__(self): + if not self.path.exists(): + raise FileNotFoundError(f"Theme file not found at {self.path}") + if self.logo_path and not self.logo_path.exists(): + raise FileNotFoundError(f"Logo file not found at {self.logo_path}") + if self.icon_path and not self.icon_path.exists(): + raise FileNotFoundError(f"Icon file not found at {self.icon_path}") + +LIGHT = TclTheme( + name="light", + path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom_tkinter" / "theming" / "bhom_light_theme.tcl", + logo_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "BHoM_Logo.png", + icon_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "bhom_icon.png", + dark_theme=False +) + +DARK = TclTheme( + name="dark", + path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom_tkinter" / "theming" / "bhom_dark_theme.tcl", + logo_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "BHoM_Logo.png", + icon_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "bhom_icon.png", + dark_theme=True + ) + +class ThemeManager: + def __init__(self, theme_mode: str = "light"): + self.theme_mode = theme_mode + self.themes = { + "light": LIGHT, + "dark": DARK, + "auto": DARK if self._is_windows_dark_mode() else LIGHT, + "default": LIGHT + } + + def register_theme(self, theme: TclTheme) -> None: + """Register a custom theme.""" + self.themes[theme.name] = theme + + def set_theme_mode(self, mode: str) -> None: + """Set the current theme mode.""" + if mode not in self.themes: + raise ValueError(f"Invalid theme mode: {mode}") + self.theme_mode = mode + + def add_new_theme(self, name: str, path: Path, logo_path: Path|None = None, icon_path: Path|None = None, dark_theme: bool = False) -> None: + """Add a new theme by providing its details.""" + new_theme = TclTheme(name=name, path=path, logo_path=logo_path, icon_path=icon_path, dark_theme=dark_theme) + self.register_theme(new_theme) + + @property + def name(self) -> str: + return self.get_theme().name + + @name.setter + def name(self, value: str) -> None: + if value not in self.themes: + raise ValueError(f"Invalid theme name: {value}") + self.set_theme_mode(value) + + @property + def path(self) -> Path: + return self.get_theme().path + + @path.setter + def path(self, value: Path) -> None: + theme = self.get_theme() + theme.path = value + + @property + def logo_path(self) -> Path|None: + return self.get_theme().logo_path + + @logo_path.setter + def logo_path(self, value: Path|None) -> None: + theme = self.get_theme() + theme.logo_path = value + + @property + def icon_path(self) -> Path|None: + return self.get_theme().icon_path + + @icon_path.setter + def icon_path(self, value: Path|None) -> None: + theme = self.get_theme() + theme.icon_path = value + + @property + def dark_theme(self) -> bool: + return self.get_theme().dark_theme + + @dark_theme.setter + def dark_theme(self, value: bool) -> None: + theme = self.get_theme() + theme.dark_theme = value + + def get_theme(self, theme_mode: str | None = None) -> TclTheme: + if theme_mode is None: + theme_mode = self.theme_mode + if theme_mode not in self.themes: + raise ValueError(f"Invalid theme mode: {theme_mode}") + return self.themes[theme_mode] + + def _is_windows_dark_mode(self) -> bool: + + try: + + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + ) + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + + return value == 0 # 0 means dark mode, 1 means light mode + + except FileNotFoundError: + # Key may not exist on older Windows versions + return False + + except ImportError: + # winreg is only available on Windows + return False \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py new file mode 100644 index 0000000..7327d53 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py @@ -0,0 +1,37 @@ +from ._packing_options import PackingOptions +from ._grid_options import GridOptions +from ._widgets_base import BHoMBaseWidget +from .widget_calendar import CalendarWidget +from .check_box_selection import CheckboxSelection +from .cmap_selector import CmapSelector +from .colour_picker import ColourPicker +from .drop_down_selection import DropDownSelection +from .figure_container import FigureContainer +from .list_box import ScrollableListBox +from .check_box_selection import CheckboxSelection as MultiBoxSelection +from .path_selector import PathSelector +from .radio_selection import RadioSelection +from .validated_entry_box import ValidatedEntryBox +from .button import Button +from .label import Label +from .spinbox import Spinbox + +__all__ = [ + "BHoMBaseWidget", + "PackingOptions", + "GridOptions", + "CalendarWidget", + "CheckboxSelection", + "MultiBoxSelection", + "CmapSelector", + "ColourPicker", + "DropDownSelection", + "FigureContainer", + "ScrollableListBox", + "PathSelector", + "RadioSelection", + "ValidatedEntryBox", + "Button", + "Label", + "Spinbox", +] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_build_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_build_options.py new file mode 100644 index 0000000..7891a6a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_build_options.py @@ -0,0 +1,17 @@ +"""Typed build options shared by BHoM Tkinter widgets.""" + +from dataclasses import dataclass, asdict +from typing import Any, Literal + +@dataclass +class BuildOptions: + + """Container for widget build options with type hints.""" + + def to_dict(self) -> dict: + """Convert the dataclass to a dictionary, excluding `None` values. + + Returns: + dict: Build options filtered to keys with concrete values. + """ + return {k: v for k, v in asdict(self).items() if v is not None} \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py new file mode 100644 index 0000000..5bdd9c5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py @@ -0,0 +1,31 @@ +"""Typed gridding options shared by BHoM Tkinter widgets.""" + +from dataclasses import dataclass, asdict +from typing import Any, Literal + +from python_toolkit.bhom_tkinter.widgets._build_options import BuildOptions + +@dataclass +class GridOptions(BuildOptions): + + """Container for `grid` geometry keyword arguments with type hints.""" + + column: int = 0 + columnspan: int = 1 + in_: Any = None + ipadx: float | str = 0.0 + ipady: float | str = 0.0 + padx: float | str | tuple[float | str, float | str] = 0.0 + pady: float | str | tuple[float | str, float | str] = 0.0 + row: int = 0 + rowspan: int = 1 + sticky: Literal['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw', ''] = '' + + def to_dict(self) -> dict: + """Convert the dataclass to a dictionary, excluding `None` values. + + Returns: + dict: Gridding options filtered to keys with concrete values. + """ + return {k: v for k, v in asdict(self).items() if v is not None} + diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py new file mode 100644 index 0000000..8115569 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py @@ -0,0 +1,35 @@ +"""Typed packing options shared by BHoM Tkinter widgets.""" + +from dataclasses import dataclass, asdict +from typing import Any, Literal + +from python_toolkit.bhom_tkinter.widgets._build_options import BuildOptions + +@dataclass +class PackingOptions(BuildOptions): + """Container for `pack` geometry keyword arguments with type hints.""" + + after: Any = None + anchor: Literal['nw', 'n', 'ne', 'w', 'center', 'e', 'sw', 's', 'se'] = 'center' + before: Any = None + expand: bool | Literal[0, 1] = 0 + fill: Literal['none', 'x', 'y', 'both'] = 'none' + side: Literal['left', 'right', 'top', 'bottom'] = 'top' + ipadx: float | str = 0.0 + ipady: float | str = 0.0 + padx: float | str | tuple[float | str, float | str] = 0.0 + pady: float | str | tuple[float | str, float | str] = 0.0 + in_: Any = None + + def to_dict(self) -> dict: + """Convert the dataclass to a dictionary, excluding `None` values. + + Returns: + dict: Packing options filtered to keys with concrete values. + """ + return {k: v for k, v in asdict(self).items() if v is not None} + +if __name__ == "__main__": + + d = PackingOptions(side='left', fill='x', expand=True, padx=5, pady=5, anchor='w') + print(d.to_dict()) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py new file mode 100644 index 0000000..8dc3c52 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py @@ -0,0 +1,363 @@ +"""Abstract base widget primitives used across BHoM Tkinter components.""" + +from abc import ABC, abstractmethod +from typing import cast + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Literal, Union, Tuple, Callable + +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.widgets._build_options import BuildOptions +from python_toolkit.bhom_tkinter.widgets._grid_options import GridOptions + +from uuid import uuid4 + +class BHoMBaseWidget(ttk.Frame, ABC): + """ + Base class for all widgets in the BHoM Tkinter toolkit. + Provides common structure and functionality for consistent design. + """ + + def __init__( + self, + parent: ttk.Frame, + id: Optional[str] = None, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + alignment: Literal['left', 'center', 'right'] = 'left', + fill_extents: bool = False, + custom_validation: Optional[Callable[[object], Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]]] = None, + disable_validation: bool = False, + build_options: BuildOptions = PackingOptions(), + **kwargs): + """ + Initialize the widget base. + + Args: + parent: Parent widget + id: Optional unique identifier for the widget + item_title: Optional header text shown at the top of the widget frame. + helper_text: Optional helper text shown above the entry box. + alignment: Horizontal alignment for built-in text elements. + fill_extents: If `True`, built-in title/helper labels fill horizontal + extent (`fill='x'`). If `False`, labels use natural width. + custom_validation: Optional callable used to extend widget validation. + Receives the current widget value and must return + `(is_valid, message, severity)`. + disable_validation: When `True`, all validation returns valid. + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.item_title = item_title + self.helper_text = helper_text + self.build_options = build_options + self.alignment: Literal['left', 'center', 'right'] = self._normalise_alignment(alignment) + self.fill_extents = self._normalise_bool(fill_extents) + self.custom_validation = custom_validation + self.disable_validation = bool(disable_validation) + + if id is None: + self.id = str(uuid4()) + else: + self.id = id + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Subtitle.TLabel") + try: + title_font = ttk.Style(self).lookup("Subtitle.TLabel", "font") + if title_font: + self.title_label.configure(font=title_font) + except Exception: + pass + if self.fill_extents: + self.title_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) + else: + self.title_label.pack(side="top", anchor=self._pack_anchor) + self._apply_text_alignment(self.title_label) + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + try: + helper_font = ttk.Style(self).lookup("Caption.TLabel", "font") + if helper_font: + self.helper_label.configure(font=helper_font) + except Exception: + pass + if self.fill_extents: + self.helper_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) + else: + self.helper_label.pack(side="top", anchor=self._pack_anchor) + self._apply_text_alignment(self.helper_label) + + # Container frame for embedded content (not title/helper) + self.content_frame = ttk.Frame(self) + self.content_frame.pack(side="top", fill=tk.BOTH, expand=True) + + @property + def _pack_anchor(self) -> Literal['w', 'center', 'e']: + """Return the pack anchor string for the current alignment. + + Returns: + str: Pack anchor token (`w`, `center`, or `e`). + """ + return cast(Literal['w', 'center', 'e'], { + "left": "w", + "center": "center", + "right": "e", + }[self.alignment]) + + @property + def _text_anchor(self) -> Literal['w', 'center', 'e']: + """Return the text anchor string for the current alignment. + + Returns: + str: Text anchor token (`w`, `center`, or `e`). + """ + return cast(Literal['w', 'center', 'e'], { + "left": "w", + "center": "center", + "right": "e", + }[self.alignment]) + + @property + def _text_justify(self) -> Literal['left', 'center', 'right']: + """Return the Tk justify token for the current alignment. + + Returns: + str: Justification token (`left`, `center`, or `right`). + """ + return cast(Literal['left', 'center', 'right'], self.alignment) + + @property + def _grid_sticky(self) -> Literal['w', '', 'e']: + """Return the grid sticky token for the current alignment. + + Returns: + str: Grid sticky token (`w`, empty string for centered, or `e`). + """ + return cast(Literal['w', '', 'e'], { + "left": "w", + "center": "", + "right": "e", + }[self.alignment]) + + def _normalise_alignment(self, alignment: Optional[str]) -> Literal['left', 'center', 'right']: + """Normalise alignment input and fallback safely to `left`. + + Args: + alignment: Candidate alignment value. + + Returns: + Literal['left', 'center', 'right']: Normalised alignment. + """ + candidate = str(alignment or "left").strip().lower() + if candidate not in {"left", "center", "right"}: + return "left" + return cast(Literal['left', 'center', 'right'], candidate) + + def _normalise_bool(self, value: object) -> bool: + """Normalise bool-like input with safe defaults. + + Args: + value: Candidate boolean input. + + Returns: + bool: Parsed boolean value. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + candidate = value.strip().lower() + if candidate in {"1", "true", "yes", "y", "on"}: + return True + if candidate in {"0", "false", "no", "n", "off", ""}: + return False + return bool(value) + + def _apply_text_alignment(self, widget: tk.Widget) -> None: + """Apply current alignment settings to a text-capable Tk widget. + + Args: + widget: Tk/ttk widget that may support `anchor` and/or `justify`. + """ + try: + configure_keys = set(widget.configure().keys()) + except Exception: + return + + options = {} + if "anchor" in configure_keys: + options["anchor"] = self._text_anchor + if "justify" in configure_keys: + options["justify"] = self._text_justify + + if options: + try: + widget.configure(**options) + except Exception: + pass + + def align_child_text(self, widget: tk.Widget, alignment: Optional[Literal['left', 'center', 'right']] = None) -> None: + """Apply left/center/right text alignment to a child widget. + + Args: + widget: Child text widget to align. + alignment: Optional override alignment for this call. + """ + previous_alignment = self.alignment + if alignment is not None: + self.alignment = self._normalise_alignment(alignment) + + self._apply_text_alignment(widget) + + if alignment is not None: + self.alignment = previous_alignment + + def set_alignment(self, alignment: Literal['left', 'center', 'right']) -> None: + """Set widget-wide alignment and refresh built-in labels. + + Args: + alignment: Horizontal alignment (`left`, `center`, or `right`). + """ + self.alignment = self._normalise_alignment(alignment) + + for label_name in ("title_label", "helper_label"): + label = getattr(self, label_name, None) + if label is not None: + label.pack_configure(anchor=self._pack_anchor) + self._apply_text_alignment(label) + + def set_fill_extents(self, fill_extents: bool) -> None: + """Set whether built-in title/helper labels fill horizontal extent. + + Args: + fill_extents: `True` for `fill='x'`, else natural-width packing. + """ + self.fill_extents = self._normalise_bool(fill_extents) + + for label_name in ("title_label", "helper_label"): + label = getattr(self, label_name, None) + if label is None: + continue + label.pack_configure(fill=(tk.X if self.fill_extents else "none")) + label.pack_configure(anchor=self._pack_anchor) + self._apply_text_alignment(label) + + @abstractmethod + def get(self): + """Get the current value of the widget.""" + raise NotImplementedError("Subclasses must implement the get() method.") + + @abstractmethod + def set(self, value): + """Set the value of the widget. + + Args: + value: Value to apply to the widget state. + """ + raise NotImplementedError("Subclasses must implement the set() method.") + + @abstractmethod + def validate(self) -> Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current widget value. + + Returns: + bool: True if the current value is valid, False otherwise. + Optional[str]: Error message if the value is invalid, None otherwise. + Optional[Literal['info', 'warning', 'error']]: Severity level of the validation result, or None if valid. + """ + raise NotImplementedError("Subclasses must implement the validate() method.") + + def apply_validation( + self, + base_result: Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]] + ) -> Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Apply global validation switches and optional custom validation. + + Subclasses should call this from their `validate()` implementation, + passing built-in validation output as `base_result`. + + Args: + base_result: Built-in widget validation result in the form + `(is_valid, message, severity)`. + + Returns: + Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + Final validation result after applying disable/custom rules. + """ + if self.disable_validation: + return True, None, None + + is_valid, message, severity = base_result + if not is_valid: + return is_valid, message, severity or "error" + + if self.custom_validation is None: + return is_valid, message, severity + + try: + custom_result = self.custom_validation(self.get()) + except Exception as ex: + return False, f"Custom validation failed: {ex}", "error" + + if not isinstance(custom_result, tuple) or len(custom_result) != 3: + return False, "Custom validation must return (is_valid, message, severity).", "error" + + custom_valid, custom_message, custom_severity = custom_result + if not custom_valid: + return False, custom_message, custom_severity or "error" + + if custom_message is not None or custom_severity is not None: + return True, custom_message, custom_severity + + return is_valid, message, severity + + def build( + self, + **kwargs + ): + """Place the widget in its parent container. + + Args: + build_type: Geometry manager strategy (`pack`, `grid`, or `place`). + **kwargs: Additional geometry manager options. + """ + if isinstance(self.build_options, PackingOptions): + self.pack(**self.build_options.to_dict()) + + elif isinstance(self.build_options, GridOptions): + self.grid(**self.build_options.to_dict()) + + else: + raise NotImplementedError(f"Unsupported build options type: {type(self.build_options)}") + + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + from python_toolkit.bhom_tkinter.widgets._grid_options import GridOptions + + from python_toolkit.bhom_tkinter.widgets.button import Button + from python_toolkit.bhom_tkinter.widgets.label import Label + + #gridding test + + root = BHoMBaseWindow(grid_dimensions=(3, 3)) + + grid_options = GridOptions(sticky="n", padx=5, pady=5) + + for i in range(3): + for j in range(3): + sub = grid_options.__class__(**{**grid_options.to_dict(), "row": i, "column": j}) + label = Label(root.content_frame, text=f"Row {i} - Column {j}", build_options=sub) + + root.widgets.append(label) + + root.build() + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py new file mode 100644 index 0000000..1c57523 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py @@ -0,0 +1,87 @@ +"""Simple button widget with action callback following BHoM toolkit patterns.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +from python_toolkit.bhom.analytics import CONSOLE_LOGGER + + +class Button(BHoMBaseWidget): + """A minimal button widget that invokes a callback when clicked. + + - `get()` returns the number of times the button has been clicked. + - `set()` can update the button label when passed a string. + """ + + def __init__( + self, + parent, + text: str = "Click", + command: Optional[Callable[[], None]] = None, + width: int = 25, + style: Optional[str] = None, + **kwargs, + ): + super().__init__(parent, **kwargs) + + self._user_command = command + self.button = ttk.Button( + self.content_frame, + text=text, + command=self._on_click, + width=width, + style=style, + + ) + self.button.pack(side="top", anchor=self._pack_anchor) + + def _on_click(self): + """Internal click handler increments counter and calls user callback.""" + if self._user_command: + try: + self._user_command() + except Exception as e: + CONSOLE_LOGGER.error(f"Unhandled exception when trying to perform custom command: {e}", exc_info=True) + + + def get(self): + """Return None as nothing to get.""" + return None + + def set(self, value): + """No set method.""" + pass + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Button has no user-editable state, so validation is always valid unless overridden.""" + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def demo_action(): + + #test fail + + a = 1 / 0 + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + widget = Button( + parent_frame, + text="Press me", + command=demo_action, + item_title="Demo Button", + helper_text="A minimal clickable button.", + build_options=PackingOptions(anchor="n", padx=20, pady=20), + alignment="center", + ) + widget.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py new file mode 100644 index 0000000..e17bbe2 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py @@ -0,0 +1,210 @@ +"""Checkbox selection widget with multi-select support and state helpers.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, List, Callable, Tuple, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button + +class CheckboxSelection(BHoMBaseWidget): + """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" + + def __init__( + self, + parent, + fields=None, + command: Optional[Callable[[List[str]], None]] = None, + defaults: Optional[List[str]] = None, + orient="vertical", + max_per_line=None, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + min_selections: Optional[int] = None, + max_selections: Optional[int] = None, + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + fields (list, optional): List of selectable fields. + command (callable, optional): Called with list of selected values when changed. + defaults (list, optional): List of default selected values. + orient (str): Either "vertical" or "horizontal". + max_per_line (int, optional): Maximum items per row/column before wrapping. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the checkboxes. + min_selections (int, optional): Minimum number of selections required. + max_selections (int, optional): Maximum number of selections allowed. + **kwargs: Additional Frame options. + """ + super().__init__(parent, item_title=item_title, helper_text=helper_text, **kwargs) + + self.fields = [str(field) for field in (fields or [])] + self.command = command + self.orient = orient.lower() + self.max_per_line = max_per_line + self.value_vars = {} # Dictionary mapping field names to BooleanVars + self._buttons = [] + self._field_buttons = {} + self.min_selections = min_selections + self.max_selections = max_selections + + # Sub-frame for checkbox controls + self.buttons_frame = ttk.Frame(self.content_frame) + self.buttons_frame.pack(side="top", fill="x", expand=True) + + self._build_buttons() + + if defaults: + self.set(defaults) + + def _build_buttons(self): + """Create checkboxes from current fields.""" + for button in self._buttons: + button.destroy() + self._buttons.clear() + self._field_buttons.clear() + self.value_vars.clear() + + for index, field in enumerate(self.fields): + var = tk.BooleanVar(value=False) + self.value_vars[field] = var + + button = ttk.Checkbutton( + self.buttons_frame, + text=field, + variable=var, + style="Checkbox.TCheckbutton", + command=lambda f=field: self._on_select_field(f), + ) + self.align_child_text(button) + + if self.max_per_line and self.max_per_line > 0: + if self.orient == "horizontal": + row = index // self.max_per_line + column = index % self.max_per_line + else: + row = index % self.max_per_line + column = index // self.max_per_line + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + elif self.orient == "horizontal": + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + else: + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + self._buttons.append(button) + self._field_buttons[field] = button + + def _on_select_field(self, field): + """Handle checkbox selection change.""" + if self.command: + self.command(self.get()) + + def get(self) -> List[str]: + """Return a list of currently selected values. + + Returns: + List[str]: Selected field labels. + """ + return [field for field, var in self.value_vars.items() if var.get()] + + def set(self, value: List[str]): + """Set the selected values. + + Args: + value: Field names to mark as selected. + """ + values = [str(v) for v in (value or [])] + for field, var in self.value_vars.items(): + var.set(field in values) + + def select_all(self): + """Select all checkboxes.""" + for var in self.value_vars.values(): + var.set(True) + if self.command: + self.command(self.get()) + + def deselect_all(self): + """Deselect all checkboxes.""" + for var in self.value_vars.values(): + var.set(False) + if self.command: + self.command(self.get()) + + def toggle_all(self): + """Toggle all checkbox states.""" + for var in self.value_vars.values(): + var.set(not var.get()) + if self.command: + self.command(self.get()) + + def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): + """Replace the available fields and rebuild the widget. + + Args: + fields: New available field names. + defaults: Optional field names to preselect after rebuild. + """ + self.fields = [str(field) for field in (fields or [])] + self._build_buttons() + + if defaults: + self.set(defaults) + + def pack(self, **kwargs): + """Pack the widget with the given options. + + Args: + **kwargs: Pack geometry manager options. + """ + super().pack(**kwargs) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current selection against min/max constraints. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid selection. + """ + selected_count = len(self.get()) + if self.min_selections is not None and selected_count < self.min_selections: + return getattr(self, "apply_validation")((False, f"Select at least {self.min_selections} options.", "error")) + if self.max_selections is not None and selected_count > self.max_selections: + return getattr(self, "apply_validation")((False, f"Select no more than {self.max_selections} options.", "error")) + return getattr(self, "apply_validation")((True, None, None)) + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def on_selection(values): + """Print selected values in the standalone example.""" + print(f"Selected: {values}") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + widget = CheckboxSelection( + parent_frame, + fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], + command=on_selection, + defaults=["Option B", "Option D"], + orient="vertical", + max_per_line=6, + min_selections=2, + item_title="Choose Options", + helper_text="Select one or more options below:", + build_options=PackingOptions(padx=20, pady=20) + ) + widget.build() + + # Add control buttons for demonstration + control_frame = ttk.Frame(parent_frame) + control_frame.pack(padx=20, pady=10) + + Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) + Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py new file mode 100644 index 0000000..410b7bb --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -0,0 +1,320 @@ +"""Colormap selector widget with embedded matplotlib preview.""" + +from typing import Dict, List, Optional, Literal +from tkinter import ttk +import tkinter as tk +import matplotlib as mpl +from python_toolkit.plot.cmap_sample import cmap_sample_plot +from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + +class CmapSelector(BHoMBaseWidget): + """ + A widget for selecting and previewing a matplotlib colormap. + """ + + CATEGORICAL_CMAPS = [ + "Accent", + "Dark2", + "Paired", + "Pastel1", + "Pastel2", + "Set1", + "Set2", + "Set3", + "tab10", + "tab20", + "tab20b", + "tab20c", + ] + + CONTINUOUS_CMAPS = [ + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + "turbo", + "Blues", + "Greens", + "Greys", + "Oranges", + "Purples", + "Reds", + "YlGn", + "YlGnBu", + "YlOrBr", + "YlOrRd", + "coolwarm", + "seismic", + "Spectral", + "RdYlBu", + "RdYlGn", + "twilight", + "twilight_shifted", + "hsv", + ] + + def __init__( + self, + parent: ttk.Frame, + colormaps: Optional[List[str]] = None, + cmap_set: str = "all", + cmap_bins: int = 256, + default_cmap: Optional[str] = None, + plot_size: tuple[int, int] = (400, 50), + dropdown_position: Literal["n", "e", "s", "w"] = "n", + **kwargs + ) -> None: + """ + Initialize the CmapSelector widget. + + Args: + parent: Parent widget + colormaps: Optional explicit list of colormap names to include. + If provided, preset set selection is disabled. + cmap_set: Preset colormap set to use when colormaps is None. + Allowed values: "all", "continuous", "categorical". + default_cmap: Optional default colormap to select. + dropdown_position: Position of the dropdown relative to the plot. + "n" = above, "s" = below, "w" = left, "e" = right. + Defaults to "w". + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + #set custom cmap args + self.cmap_bins = cmap_bins + self.default_cmap = default_cmap + self.plot_size = plot_size + + # Create frame for cmap selection content + self.cmap_frame = ttk.Frame(self.content_frame) + self.cmap_frame.pack(side="top", fill="both", expand=True, anchor=self._pack_anchor) + + self.colormap_var = tk.StringVar(value="viridis") + self._all_colormaps = self._get_all_colormaps() + self._preset_map: Dict[str, List[str]] = { + "all": self._all_colormaps, + "continuous": self._filter_available(self.CONTINUOUS_CMAPS), + "categorical": self._filter_available(self.CATEGORICAL_CMAPS), + } + self._uses_explicit_colormaps = colormaps is not None + + self.cmap_frame.columnconfigure(0, weight=1) + self.cmap_frame.rowconfigure(0, weight=1) + + content = ttk.Frame(self.cmap_frame, width=plot_size[0], height=plot_size[1]) + content.grid(row=0, column=0, padx=0, pady=4, sticky=self._grid_sticky) + content.grid_propagate(False) + + self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) + + pos = dropdown_position.lower() + is_horizontal = pos in ("w", "e") + pack_side_combo = {"n": tk.TOP, "s": tk.BOTTOM, "w": tk.LEFT, "e": tk.RIGHT}[pos] + pack_side_figure = {"n": tk.TOP, "s": tk.TOP, "w": tk.LEFT, "e": tk.LEFT}[pos] + + combo_padx = (0, 4) if is_horizontal else 0 + combo_pady = (8, 4) if not is_horizontal else 0 + + header = ttk.Frame(content) + header.pack(side=pack_side_combo, anchor=self._pack_anchor, padx=combo_padx, pady=combo_pady) + + self.cmap_combobox = ttk.Combobox( + header, + textvariable=self.colormap_var, + state="readonly", + justify=self._text_justify, + ) + self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=0) + self.cmap_combobox.bind("<>", self._on_cmap_selected) + + fill_mode = tk.Y if is_horizontal else tk.X + self.figure_widget = FigureContainer( + content, + width=plot_size[0], + height=plot_size[1], + build_options=PackingOptions(side=pack_side_figure, anchor=self._pack_anchor, fill=fill_mode, padx=0, pady=(0, 8)), + ) + self.figure_widget.build() + + if self._uses_explicit_colormaps: + current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) + else: + current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) + + self._populate_cmap_list(current_colormaps) + self._select_default_cmap(current_colormaps) + + def _get_all_colormaps(self) -> List[str]: + """Return all registered colormap names, including reversed variants. + + Returns: + List[str]: Sorted list of available colormap names. + """ + # Base names available in this matplotlib build + base_names = set(mpl.colormaps()) + + # Include reversed variants (name_r) next to each base map + all_names = set(base_names) + for name in list(base_names): + if not name.endswith("_r"): + all_names.add(f"{name}_r") + + return sorted(all_names) + + def _filter_available(self, names: List[str]) -> List[str]: + """Filter a candidate list to names available in the current matplotlib build. + + Args: + names: Candidate colormap names. + + Returns: + List[str]: Candidate names available in the current environment. + """ + available = set(self._all_colormaps) + return [name for name in names if name in available] + + def _with_reversed(self, names: List[str]) -> List[str]: + """Return colormap names with reversed variants next to base maps. + + Args: + names: Base colormap names. + + Returns: + List[str]: Ordered list with available `_r` variants inserted. + """ + available = set(self._all_colormaps) + selected: List[str] = [] + for name in names: + if name in available and name not in selected: + selected.append(name) + if not name.endswith("_r"): + reversed_name = f"{name}_r" + if reversed_name in available and reversed_name not in selected: + selected.append(reversed_name) + return selected + + def _preset_colormaps(self, cmap_set: str) -> List[str]: + """Resolve a preset colormap set name to a colormap list. + + Args: + cmap_set: Preset set key. + + Returns: + List[str]: Colormap names for the requested preset. + """ + key = (cmap_set or "all").lower() + return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) + + def _populate_cmap_list(self, colormaps: List[str]) -> None: + """Replace the combobox options with the provided colormap names.""" + self.cmap_combobox["values"] = tuple(colormaps) + + def _select_default_cmap(self, colormaps: List[str]) -> None: + """Select an initial colormap and render its preview.""" + if not colormaps: + self.figure_widget.clear() + self.colormap_var.set("") + return + + self.colormap_var.set(self.default_cmap if self.default_cmap in colormaps else colormaps[0]) + self._update_cmap_sample() + + def _on_cmap_selected(self, event=None) -> None: + """Handle combobox selection changes.""" + self._update_cmap_sample() + + def _update_cmap_sample(self, *args) -> None: + """Update the colormap sample plot. + + Args: + *args: Unused callback arguments from Tk traces/events. + """ + cmap_name = self.colormap_var.get() + if not cmap_name: + self.figure_widget.clear() + return + + fig = cmap_sample_plot(cmap_name, figsize=(self.plot_size[0]/100, self.plot_size[1]/100), bins = self.cmap_bins) + self.figure_widget.embed_figure(fig) + + def get_selected_cmap(self) -> Optional[str]: + """Return the currently selected colormap name, or `None` if empty. + + Returns: + Optional[str]: Current colormap name. + """ + cmap_name = self.colormap_var.get() + return cmap_name if cmap_name else None + + def get(self) -> Optional[str]: + """Get the currently selected colormap name. + + Returns: + Optional[str]: Current colormap name. + """ + return self.get_selected_cmap() + + def set(self, value: Optional[str]): + """Set the selected colormap by name. + + Args: + value: Colormap name to select. Clears selection when invalid. + """ + if value and value in self.cmap_combobox["values"]: + self.colormap_var.set(value) + self._update_cmap_sample() + else: + self.figure_widget.clear() + self.colormap_var.set("") + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current colormap selection. + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid selection. + """ + selected_cmap = self.get_selected_cmap() + if selected_cmap is None: + return self.apply_validation((False, "No colormap selected.", "error")) + if selected_cmap not in self.cmap_combobox["values"]: + return self.apply_validation((False, f"Selected colormap '{selected_cmap}' is not available.", "error")) + return self.apply_validation((True, None, None)) + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + root = BHoMBaseWindow() + parent_container = root.content_frame + + cmap_selector = CmapSelector( + parent_container, + cmap_set="all", + item_title="Colormap Selector", + helper_text="Select a colormap from the list.", + build_options=PackingOptions(fill='both', expand=True), + cmap_bins=6, + plot_size=(400, 50), + alignment="left", + ) + + cmap_selector_2 = CmapSelector( + parent_container, + cmap_set="categorical", + item_title="Categorical Colormap Selector", + helper_text="Select a categorical colormap from the list.", + build_options=PackingOptions(fill='both', expand=True), + cmap_bins=6, + plot_size=(50, 25), + alignment="right", + ) + + + + cmap_selector.build() + cmap_selector_2.build() + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py new file mode 100644 index 0000000..b0f498e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -0,0 +1,268 @@ +"""Colour picker widget with themed popup and live RGB/hex preview.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Callable, Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button + +class ColourPicker(BHoMBaseWidget): + """A simple colour picker widget where the swatch opens a themed colour dialog.""" + + def __init__( + self, + parent: ttk.Frame, + default_colour: str = "#ffffff", + swatch_width: int = 48, + swatch_height: int = 28, + command: Optional[Callable[[str], None]] = None, + **kwargs, + ) -> None: + """ + Args: + parent: Parent widget. + default_colour: Initial colour value in hex format. + swatch_width: Width of the clickable colour swatch in pixels. + swatch_height: Height of the clickable colour swatch in pixels. + command: Optional callback called with selected hex colour. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.command = command + self.colour_var = tk.StringVar(value=default_colour) + self.swatch_width = max(1, int(swatch_width)) + self.swatch_height = max(1, int(swatch_height)) + self._picker_window: Optional[tk.Toplevel] = None + self._popup_preview: Optional[tk.Canvas] = None + self._popup_swatch: Optional[int] = None + self._popup_hex_var: Optional[tk.StringVar] = None + self._popup_red_var: Optional[tk.IntVar] = None + self._popup_green_var: Optional[tk.IntVar] = None + self._popup_blue_var: Optional[tk.IntVar] = None + + controls = ttk.Frame(self.content_frame) + controls.pack(side="top", anchor=self._pack_anchor) + + self.preview = tk.Canvas( + controls, + width=self.swatch_width, + height=self.swatch_height, + highlightthickness=1, + cursor="hand2", + ) + self.preview.pack() + self._swatch = self.preview.create_rectangle(0, 0, self.swatch_width, self.swatch_height, outline="#666666") + self.preview.bind("", lambda _event: self._select_colour()) + + self._update_preview(default_colour) + + def _select_colour(self) -> None: + """Open the themed colour picker popup.""" + if self._picker_window and self._picker_window.winfo_exists(): + self._picker_window.focus_force() + return + + current_colour = self.get() + red, green, blue = self._hex_to_rgb(current_colour) + + root_window = self.winfo_toplevel() + window = tk.Toplevel(root_window) + window.title("Select Colour") + window.transient(root_window) + window.resizable(False, False) + window.protocol("WM_DELETE_WINDOW", self._close_picker) + + self._picker_window = window + self._popup_red_var = tk.IntVar(value=red) + self._popup_green_var = tk.IntVar(value=green) + self._popup_blue_var = tk.IntVar(value=blue) + self._popup_hex_var = tk.StringVar(value=current_colour) + + container = ttk.Frame(window, padding=10) + container.pack(fill=tk.BOTH, expand=True) + + self._build_slider_row(container, "R", self._popup_red_var, 0) + self._build_slider_row(container, "G", self._popup_green_var, 1) + self._build_slider_row(container, "B", self._popup_blue_var, 2) + + hex_row = ttk.Frame(container) + hex_row.grid(row=3, column=0, sticky="ew", pady=(8, 4)) + Label(hex_row, text="Hex").pack(side=tk.LEFT) + hex_entry = ttk.Entry(hex_row, textvariable=self._popup_hex_var, width=10) + hex_entry.pack(side=tk.LEFT, padx=(8, 0)) + hex_entry.bind("", lambda _event: self._on_hex_entered()) + + preview_row = ttk.Frame(container) + preview_row.grid(row=4, column=0, sticky="w", pady=(4, 8)) + Label(preview_row, text="Preview").pack(side=tk.LEFT) + self._popup_preview = tk.Canvas(preview_row, width=48, height=28, highlightthickness=1) + self._popup_preview.pack(side=tk.LEFT, padx=(8, 0)) + self._popup_swatch = self._popup_preview.create_rectangle(0, 0, 48, 28, outline="#666666") + + button_row = ttk.Frame(container) + button_row.grid(row=5, column=0, sticky="e") + Button(button_row, text="Cancel", command=self._close_picker).pack(side=tk.LEFT, padx=(0, 6)) + Button(button_row, text="Apply", command=self._apply_popup_colour).pack(side=tk.LEFT) + + container.columnconfigure(0, weight=1) + + self._on_rgb_changed() + window.grab_set() + hex_entry.focus_set() + + def _update_preview(self, colour: str) -> None: + """Render the selected colour in the preview swatch.""" + self.preview.itemconfig(self._swatch, fill=colour) + + def _build_slider_row(self, parent: ttk.Frame, label_text: str, value_var: tk.IntVar, row: int) -> None: + row_frame = ttk.Frame(parent) + row_frame.grid(row=row, column=0, sticky="ew", pady=2) + + Label(row_frame, text=label_text).pack(side=tk.LEFT) + slider = ttk.Scale( + row_frame, + from_=0, + to=255, + orient=tk.HORIZONTAL, + style="ColourPicker.Horizontal.TScale", + command=lambda value, var=value_var: self._on_slider_move(var, value), + ) + slider.set(value_var.get()) + slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(8, 8)) + value_label = Label(row_frame, textvariable=value_var, width=4, anchor="e") + value_label.pack(side=tk.LEFT) + + def _on_slider_move(self, value_var: tk.IntVar, value: str) -> None: + value_var.set(int(float(value))) + self._on_rgb_changed() + + def _on_rgb_changed(self) -> None: + if not (self._popup_red_var and self._popup_green_var and self._popup_blue_var and self._popup_hex_var): + return + + colour = self._rgb_to_hex(self._popup_red_var.get(), self._popup_green_var.get(), self._popup_blue_var.get()) + self._popup_hex_var.set(colour) + self._update_popup_preview(colour) + + def _on_hex_entered(self) -> None: + if not self._popup_hex_var: + return + + red, green, blue = self._hex_to_rgb(self._popup_hex_var.get()) + if self._popup_red_var and self._popup_green_var and self._popup_blue_var: + self._popup_red_var.set(red) + self._popup_green_var.set(green) + self._popup_blue_var.set(blue) + self._popup_hex_var.set(self._rgb_to_hex(red, green, blue)) + self._update_popup_preview(self._popup_hex_var.get()) + + def _update_popup_preview(self, colour: str) -> None: + if self._popup_preview and self._popup_swatch is not None: + self._popup_preview.itemconfig(self._popup_swatch, fill=colour) + + def _apply_popup_colour(self) -> None: + if not self._popup_hex_var: + return + + selected = self._rgb_to_hex(*self._hex_to_rgb(self._popup_hex_var.get())) + self.set(selected) + if self.command: + self.command(selected) + self._close_picker() + + def _close_picker(self) -> None: + if self._picker_window and self._picker_window.winfo_exists(): + try: + self._picker_window.grab_release() + except tk.TclError: + pass + self._picker_window.destroy() + + self._picker_window = None + self._popup_preview = None + self._popup_swatch = None + self._popup_hex_var = None + self._popup_red_var = None + self._popup_green_var = None + self._popup_blue_var = None + + @staticmethod + def _rgb_to_hex(red: int, green: int, blue: int) -> str: + red = max(0, min(255, int(red))) + green = max(0, min(255, int(green))) + blue = max(0, min(255, int(blue))) + return f"#{red:02x}{green:02x}{blue:02x}" + + @staticmethod + def _hex_to_rgb(colour: str) -> tuple[int, int, int]: + value = (colour or "").strip().lstrip("#") + if len(value) == 3: + value = "".join(ch * 2 for ch in value) + if len(value) != 6: + return 255, 255, 255 + try: + return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) + except ValueError: + return 255, 255, 255 + + def get(self) -> str: + """Return the currently selected hex colour. + + Returns: + str: Selected colour in hex format. + """ + return self.colour_var.get() + + def set(self, value: str) -> None: + """Set the current colour and refresh the preview swatch. + + Args: + value: Colour value in hex format. + """ + self.colour_var.set(value) + self._update_preview(value) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current colour value. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid colour. + """ + + colour = self.get() + if not colour: + return self.apply_validation((False, "No colour selected.", "error")) + try: + self._hex_to_rgb(colour) + return self.apply_validation((True, None, None)) + except ValueError: + return self.apply_validation((False, f"Invalid colour value: '{colour}'.", "error")) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + parent_container = root.content_frame + + def on_colour_changed(colour: str) -> None: + """Print selected colour in the standalone example.""" + print(f"Selected colour: {colour}") + + colour_picker = ColourPicker( + parent_container, + command=on_colour_changed, + item_title="Colour Picker", + helper_text="Pick a colour for plotting.", + alignment="center", + build_options=PackingOptions(padx=10, pady=10), + ) + colour_picker.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py new file mode 100644 index 0000000..aa326ce --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py @@ -0,0 +1,156 @@ +"""Dropdown selection widget built from ttk.Combobox.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, List, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class DropDownSelection(BHoMBaseWidget): + """A reusable dropdown selection widget built from a list of options.""" + + def __init__( + self, + parent, + options: Optional[List[str]] = None, + command: Optional[Callable[[str], None]] = None, + default: Optional[str] = None, + width: int = 20, + state: str = "normal", + value_var: Optional[tk.StringVar] = None, + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + options (list, optional): List of selectable options. + command (callable, optional): Called with selected value when changed. + default (str, optional): Default selected value. + width (int): Width of the dropdown widget in characters. + state (str): Combobox state ("normal", "readonly", or "disabled"). + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the dropdown. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.options = [str(option) for option in (options or [])] + self.command = command + self.value_var = value_var or tk.StringVar() + + # Create the combobox in the content frame + self.combobox = ttk.Combobox( + self.content_frame, + textvariable=self.value_var, + values=self.options, + width=width, + state=state, + justify=self._text_justify, + ) + self.combobox.pack(side="top", anchor=self._pack_anchor, fill="x") + + # Bind selection event + self.combobox.bind("<>", self._on_select) + + # Set default value + if default is not None and default in self.options: + self.set(default) + elif self.options: + self.value_var.set(self.options[0]) + + def _on_select(self, event=None): + """Handle selection change event. + + Args: + event: Tkinter event (optional). + """ + if self.command: + self.command(self.get()) + + def get(self) -> str: + """Return the currently selected value. + + Returns: + str: Selected option value. + """ + return self.value_var.get() + + def set(self, value: str): + """Set the selected value. + + Args: + value: Option value to select. + """ + if value in self.options: + self.value_var.set(value) + else: + raise ValueError(f"Value '{value}' not in available options: {self.options}") + + def set_options(self, options: List[str], default: Optional[str] = None): + """Replace the available options and optionally set a new default. + + Args: + options: New list of selectable options. + default: Optional value to select after updating options. + """ + self.options = [str(option) for option in (options or [])] + self.combobox['values'] = self.options + + if default is not None and default in self.options: + self.set(default) + elif self.options: + self.value_var.set(self.options[0]) + else: + self.value_var.set("") + + def get_selected_index(self) -> int: + """Return the index of the currently selected option. + + Returns: + int: Index of selected option, or -1 if not found. + """ + try: + return self.options.index(self.get()) + except ValueError: + return -1 + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current selection. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid selection. + """ + selected = self.get() + if not selected: + return getattr(self, "apply_validation")((False, "No option selected.", "error")) + if selected not in self.options: + return getattr(self, "apply_validation")((False, f"Selected option '{selected}' is not available.", "error")) + return getattr(self, "apply_validation")((True, None, None)) + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def on_selection(value): + """Print selected value in the standalone example.""" + print(f"Selected: {value}") + + root = BHoMBaseWindow(theme_mode="light") + parent_frame = root.content_frame + + widget = DropDownSelection( + parent_frame, + options=["Option A", "Option B", "Option C", "Option D", "Option E"], + command=on_selection, + default="Option C", + width=30, + item_title="Choose an Option", + helper_text="Select one option from the dropdown list.", + alignment="center", + build_options=PackingOptions(padx=20, pady=20) + ) + widget.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py new file mode 100644 index 0000000..ad0282e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -0,0 +1,378 @@ +"""Container widget for embedding matplotlib figures or image content.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Optional, Any, Literal +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +import matplotlib.pyplot as plt +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +import matplotlib as mpl + + +class FigureContainer(BHoMBaseWidget): + """ + A reusable widget for embedding matplotlib figures and images. + """ + + def __init__( + self, + parent: ttk.Frame, + auto_size: bool = False, + rigid_width: Optional[int] = None, + rigid_height: Optional[int] = None, + **kwargs + ) -> None: + """ + Initialize the FigureContainer widget. + + Args: + parent: Parent widget + auto_size: If `True`, fit content once to current frame size. + rigid_width: Optional fixed target width (pixels) for content sizing. + rigid_height: Optional fixed target height (pixels) for content sizing. + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.auto_size = bool(auto_size) + self.rigid_width = int(rigid_width) if rigid_width is not None else None + self.rigid_height = int(rigid_height) if rigid_height is not None else None + + self.figure: Optional[Figure] = None + self.image: Optional[Any] = None + self.image_file: Optional[str] = None + self._original_pil_image: Optional[Any] = None + + self.canvas: Optional[FigureCanvasTkAgg] = None + self.image_label: Optional[ttk.Label] = None + self._fit_after_id: Optional[str] = None + self._fit_attempts: int = 0 + + if self.image: + self.embed_image(self.image) + + elif self.figure: + self.embed_figure(self.figure) + + elif self.image_file: + self.embed_image_file(self.image_file) + + def _clear_children(self) -> None: + """Destroy any child widgets hosted by the content frame only.""" + if self._fit_after_id is not None: + try: + self.after_cancel(self._fit_after_id) + except Exception: + pass + self._fit_after_id = None + self._fit_attempts = 0 + for widget in self.content_frame.winfo_children(): + widget.destroy() + self.canvas = None + self.image_label = None + + def _resolve_target_size(self) -> tuple[int, int]: + """Resolve sizing target from rigid args or, optionally, frame dimensions.""" + if self.rigid_width is not None and self.rigid_height is not None: + return self.rigid_width, self.rigid_height + + self.update_idletasks() + + if self.auto_size: + frame_width = max(self.content_frame.winfo_width(), self.content_frame.winfo_reqwidth()) + frame_height = max(self.content_frame.winfo_height(), self.content_frame.winfo_reqheight()) + return frame_width, frame_height + + # No frame-derived sizing by default. + frame_width = self.rigid_width or 0 + frame_height = self.rigid_height or 0 + return frame_width, frame_height + + def _close_held_figure(self) -> None: + """Close any currently held matplotlib figure to release resources.""" + if self.figure is not None: + try: + plt.close(self.figure) + except Exception: + pass + self.figure = None + + def _resolved_background(self) -> str: + """Resolve a background colour suitable for embedded Tk canvas widgets. + + Returns: + str: Resolved background colour string. + """ + try: + bg = ttk.Style().lookup("TFrame", "background") + if bg: + return bg + except Exception: + pass + + try: + return self.winfo_toplevel().cget("bg") + except Exception: + return "white" + + def embed_figure(self, figure: Figure) -> None: + """Embed a matplotlib figure in the container, replacing existing content. + + Args: + figure: Matplotlib Figure object to embed. + """ + + self._close_held_figure() + self._clear_children() + + self.figure = figure + self.canvas = FigureCanvasTkAgg(figure, master=self.content_frame) + + # Set canvas background to match the frame background for transparency + bg_color = self._resolved_background() + self.canvas.get_tk_widget().configure(bg=bg_color, highlightthickness=0) + + self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + if self.auto_size or self.rigid_width is not None or self.rigid_height is not None: + self._fit_figure_once() + else: + self.canvas.draw_idle() + + def embed_image(self, image: tk.PhotoImage) -> None: + """ + Embed a Tk image in the figure container. + Note: For automatic scaling, use embed_image_file() instead with PIL support. + + Args: + image: Tk PhotoImage object to embed + """ + self._close_held_figure() + self._clear_children() + + self.image = image + self._original_pil_image = None + + # Create label to display the image + self.image_label = Label(self.content_frame, image=image) + self.image_label.pack(fill=tk.BOTH, expand=True) + + def embed_image_file(self, file_path: str) -> None: + """ + Load and embed an image file, scaled to fit the container. + + Args: + file_path: Path to image file + """ + self.image_file = file_path + self._close_held_figure() + self._clear_children() + + try: + from PIL import Image, ImageTk + + # Load with PIL for better scaling + pil_image = Image.open(file_path) + self._original_pil_image = pil_image + + # Create label + self.image_label = Label(self.content_frame) + self.image_label.pack(fill=tk.BOTH, expand=True) + + # Scale and display + if self.auto_size or self.rigid_width is not None or self.rigid_height is not None: + self._scale_image_to_fit_once() + else: + # Default path: preserve original image dimensions, no frame-based fit. + self.image = ImageTk.PhotoImage(self._original_pil_image) + self.image_label.set(self.image) + + except ImportError: + # Fallback to basic PhotoImage without scaling + image = tk.PhotoImage(file=file_path) + self.image = image + self.image_label = Label(self.content_frame, image=image) + self.image_label.pack(fill=tk.BOTH, expand=True) + + def _fit_figure_once(self) -> None: + """Size the figure to the current frame once and stop resizing afterwards.""" + if self.figure is None or self.canvas is None: + return + + frame_width, frame_height = self._resolve_target_size() + + # If only one rigid dimension is provided, derive the other from current figure aspect. + if (self.rigid_width is not None) != (self.rigid_height is not None): + try: + dpi = float(self.figure.get_dpi()) + if dpi <= 0: + dpi = 100.0 + current_w_in, current_h_in = self.figure.get_size_inches() + aspect = (current_w_in / current_h_in) if current_h_in else 1.0 + if self.rigid_width is not None: + frame_width = self.rigid_width + frame_height = int(round(self.rigid_width / aspect)) if aspect else self.rigid_width + else: + frame_height = self.rigid_height or 0 + frame_width = int(round(frame_height * aspect)) + except Exception: + pass + + if frame_width <= 1 or frame_height <= 1: + if not self.auto_size: + self._fit_after_id = None + return + self._fit_attempts += 1 + if self._fit_attempts > 25: + self._fit_after_id = None + return + self._fit_after_id = self.after(20, self._fit_figure_once) + return + + try: + dpi = float(self.figure.get_dpi()) + if dpi <= 0: + dpi = 100.0 + self.figure.set_size_inches(frame_width / dpi, frame_height / dpi, forward=True) + self.canvas.draw_idle() + except Exception: + self.canvas.draw_idle() + finally: + self._fit_after_id = None + self._fit_attempts = 0 + + def _scale_image_to_fit_once(self): + """Scale the image to fit within the content frame while maintaining aspect ratio.""" + if self._original_pil_image is None: + return + + # Get current frame dimensions + frame_width, frame_height = self._resolve_target_size() + + # Skip if frame not yet sized + if frame_width <= 1 or frame_height <= 1: + if not self.auto_size: + self._fit_after_id = None + return + self._fit_attempts += 1 + if self._fit_attempts > 25: + self._fit_after_id = None + return + self._fit_after_id = self.after(20, self._scale_image_to_fit_once) + return + + try: + from PIL import Image, ImageTk + + # Calculate scaling factor to fit + img_width, img_height = self._original_pil_image.size + scale_width = frame_width / img_width + scale_height = frame_height / img_height + scale = min(scale_width, scale_height) + + # Calculate new dimensions + new_width = int(img_width * scale) + new_height = int(img_height * scale) + + # Resize image + resized = self._original_pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Convert to PhotoImage and update label + self.image = ImageTk.PhotoImage(resized) + if self.image_label: + # `image_label` is a BHoM Label wrapper; use wrapper API so + # the inner ttk.Label gets updated and image references persist. + self.image_label.set(self.image) + self._fit_after_id = None + self._fit_attempts = 0 + except Exception: + pass # Silently handle scaling errors + + def clear(self) -> None: + """Clear the figure container.""" + self._close_held_figure() + self._clear_children() + self.image = None + self.image_label = None + self._original_pil_image = None + + def get(self): + """Return the currently embedded figure or image. + + Returns: + Optional[Any]: Embedded figure/image, or `None` when empty. + """ + if self.figure is not None: + return self.figure + elif self.image is not None: + return self.image + else: + return None + + def set(self, value): + """Set the content of the figure container. + + Args: + value: `Figure`, `PhotoImage`, or image file path string. + """ + if isinstance(value, Figure): + self.embed_figure(value) + elif isinstance(value, tk.PhotoImage): + self.embed_image(value) + elif isinstance(value, str): + self.embed_image_file(value) + else: + raise ValueError("Unsupported value type for FigureContainer. Must be Figure, PhotoImage, or file path string.") + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current content of the figure container. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid content. + """ + if self.figure is not None: + return self.apply_validation((True, None, None)) + if self.image is not None: + return self.apply_validation((True, None, None)) + return self.apply_validation((False, "FigureContainer is empty. Please embed a figure or image.", "error")) + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + parent_container = root.content_frame + + # Create figure container + figure_container = FigureContainer( + parent=parent_container, + item_title="Figure Container", + helper_text="This widget can embed matplotlib figures or images.", + build_options=PackingOptions(padx=10, pady=10, fill='both', expand=True) + ) + figure_container.build() + + style = "python_toolkit.bhom_dark" + + # Create and embed the initial matplotlib figure + with plt.style.context(style): + fig_initial, ax_initial = plt.subplots(figsize=(5, 4), dpi=80) + ax_initial.plot([1, 2, 3, 4], [1, 4, 2, 3], marker='o') + ax_initial.set_title("Initial Plot") + ax_initial.set_xlabel("X") + ax_initial.set_ylabel("Y") + figure_container.embed_figure(fig_initial) + + def push_new_plot() -> None: + """Replace the existing plot with a new one after a delay.""" + image_path = r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\assets\BHoM_Logo.png" + figure_container.embed_image_file(image_path) + + # Push a new plot after 10 seconds + root.after(4_000, push_new_plot) + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py new file mode 100644 index 0000000..244c610 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py @@ -0,0 +1,162 @@ +from tkinter import ttk +import tkinter as tk +from typing import Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Label(BHoMBaseWidget): + """Default BHoM label widget. + + This is a Frame-based widget (inherits `BHoMBaseWidget`) which contains + an inner `ttk.Label` stored at `self.label`. Using a Frame wrapper allows + built-in `item_title` and `helper_text` and consistent alignment handling + across BHoM widgets. + """ + + def __init__( + self, + parent, + text: str = "", + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + alignment: Optional[str] = None, + image=None, + style=None, + justify=None, + wraplength=None, + anchor=None, + width=None, + compound=None, + textvariable=None, + foreground=None, + font=None, + **kwargs): + def resolve(explicit_value, key: str, default=None): + fallback = kwargs.pop(key, default) + return explicit_value if explicit_value is not None else fallback + + base_options = { + "item_title": resolve(item_title, "item_title"), + "helper_text": resolve(helper_text, "helper_text"), + "alignment": resolve(alignment, "alignment", "left") or "left", + } + + label_options = { + "text": resolve(text, "text", ""), + "image": resolve(image, "image"), + "style": resolve(style, "style"), + "justify": resolve(justify, "justify"), + "wraplength": resolve(wraplength, "wraplength"), + "anchor": resolve(anchor, "anchor"), + "width": resolve(width, "width"), + "compound": resolve(compound, "compound"), + "textvariable": resolve(textvariable, "textvariable"), + "foreground": resolve(foreground, "foreground"), + "font": resolve(font, "font"), + } + label_options = {key: value for key, value in label_options.items() if value is not None} + + # Initialize frame base without label-specific options to avoid passing + # unknown options like '-image' to ttk.Frame + super().__init__( + parent, + **base_options, + **kwargs, + ) + + self.text = label_options.get("text", "") + image_ref = label_options.get("image") + if image_ref is not None: + # Keep a Python-side reference so Tk image objects are not garbage-collected. + self._image_ref = image_ref + style_name = label_options.get("style") + if "font" not in label_options and style_name: + try: + style_font = ttk.Style(self).lookup(style_name, "font") + if style_font: + label_options["font"] = style_font + except Exception: + pass + # Create inner ttk.Label with the collected options. + # If a stale Tk image handle is passed from a previous failed run, + # recover by dropping the image and creating a text-only label. + try: + self.label = ttk.Label(self.content_frame, **label_options) + except tk.TclError as ex: + message = str(ex).lower() + if "image" in label_options and "image" in message and "doesn't exist" in message: + safe_options = {k: v for k, v in label_options.items() if k != "image"} + self.label = ttk.Label(self.content_frame, **safe_options) + else: + raise + self.align_child_text(self.label) + # Allow the inner label to expand horizontally so parent frames + # using grid/pack with `fill='x'` will cause this label to fill + # the available width (useful for option rows that should stretch). + self.label.pack(side="top", anchor=self._pack_anchor, fill="x", expand=True) + + def get(self) -> str: + """Return the current label text.""" + try: + # Prefer text if present, otherwise return any image reference + text = self.label.cget("text") + if text: + return str(text) + # Fall back to any stored image reference + return getattr(self, "_image_ref", "") + except Exception: + return "" + + def set(self, value): + """Set the label text or image.""" + if isinstance(value, str): + self.text = value + self.label.configure(text=self.text, image="") + if hasattr(self, "_image_ref"): + delattr(self, "_image_ref") + else: + # Assume it's an image object + self._image_ref = value + self.label.configure(image=self._image_ref, text="") + + + def update_text(self, new_text: str): + """Backward-compatible method name used previously to update label text.""" + self.set(new_text) + + # Provide a generic `update` alias for convenience (matches previous API), + # while remaining compatible with tkinter's parameterless `update()`. + def update(self, new_text: Optional[str] = None): + if new_text is None: + super().update() + return + self.update_text(new_text) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current content of the label. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid content. + """ + + #always true + return getattr(self, "apply_validation")((True, None, None)) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + label_widget = Label(parent_frame, text="Hello, World!", build_options=PackingOptions(anchor="n", padx=10, pady=10), alignment="right") + label_widget.build() + + label_widget2 = Label(parent_frame, text="Warning: This is a BHoM warning label.", build_options=PackingOptions(anchor="n", padx=10, pady=10), alignment="left", style="Warning.TLabel") + label_widget2.build() + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py new file mode 100644 index 0000000..72478d7 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -0,0 +1,287 @@ +"""Scrollable listbox widget with optional selection helper controls.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import filedialog, ttk +from typing import Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button + +class ScrollableListBox(BHoMBaseWidget): + """A reusable listbox widget with auto-hiding scrollbar.""" + + def __init__( + self, + parent: ttk.Frame, + items=None, + selectmode=tk.MULTIPLE, + height=None, + width=None, + show_selection_controls=False, + **kwargs): + """ + Args: + parent (ttk.Frame): The parent widget. + items (list, optional): List of items to populate the listbox. + selectmode (str): Selection mode for the listbox (SINGLE, MULTIPLE, etc.). + height (int, optional): Height of the listbox. Defaults to number of items. + width (int, optional): Width of the listbox in characters. If None, listbox expands to fill available space. + show_selection_controls (bool): Show Select All and Deselect All buttons. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.items = items or [] + self._cached_options = [str(item) for item in self.items] + self._cached_selection: list[str] = [] + self._cached_selection_indices: tuple[int, ...] = () + if height is None: + height = len(self.items) if self.items else 5 + + # Create scrollbar + self.scrollbar = ttk.Scrollbar(self.content_frame) + + self.content_frame.columnconfigure(0, weight=1) + self.content_frame.columnconfigure(1, weight=1) + self.content_frame.rowconfigure(0, weight=1) + self.content_frame.rowconfigure(1, weight=1) + + # Create listbox + listbox_options = { + "selectmode": selectmode, + "height": height, + "yscrollcommand": self.scrollbar.set, + "exportselection": False, + } + if width is not None: + listbox_options["width"] = width + + self.listbox = tk.Listbox(self.content_frame, **listbox_options) + self.listbox.grid(row=0, column=0, columnspan=2, sticky="nsew") + self.scrollbar.config(command=self.listbox.yview) + + # Populate with items + for item in self.items: + self.listbox.insert(tk.END, item) + + # Auto-hide scrollbar when not needed + self.listbox.bind("", self._on_configure) + self.listbox.bind("<>", self._on_selection_change) + self._sync_cache_from_widget() + self._on_configure() + + if show_selection_controls: + + select_widget = Button(self.content_frame, text="Select All", command=self.select_all) + select_widget.grid(row=1, column=0, sticky="ns", padx=(0, 4), pady=(8, 0)) + self.select_all_button = select_widget.button + + deselect_widget = Button(self.content_frame, text="Deselect All", command=self.deselect_all) + deselect_widget.grid(row=1, column=1, sticky="ns", padx=(4, 0), pady=(8, 0)) + self.deselect_all_button = deselect_widget.button + + def _on_configure(self, event=None): + """Hide scrollbar if all items fit in the visible area.""" + if self.listbox.size() <= int(self.listbox.cget("height")): + self.scrollbar.grid_forget() + else: + self.scrollbar.grid(row=0, column=1, sticky="ns") + + def _on_selection_change(self, _event=None): + """Track selection changes so values remain available after teardown.""" + self._sync_cache_from_widget() + + def _is_listbox_alive(self) -> bool: + """Return whether the underlying Tk listbox command still exists.""" + try: + return bool(self.listbox.winfo_exists()) + except Exception: + return False + + def _sync_cache_from_widget(self) -> None: + """Synchronize cached options and selections from the live listbox.""" + if not self._is_listbox_alive(): + return + self._cached_options = [self.listbox.get(i) for i in range(self.listbox.size())] + self.items = list(self._cached_options) + self._cached_selection_indices = tuple(self.listbox.curselection()) + self._cached_selection = [self.listbox.get(i) for i in self._cached_selection_indices] + + def set_selections(self, items): + """Set the selection to the specified items. + + Args: + items: Item values to select. + """ + self.listbox.selection_clear(0, tk.END) + for index, item in enumerate(self.get_options()): + if item in items: + self.listbox.selection_set(index) + self._sync_cache_from_widget() + + def get_selection(self): + """Return a list of selected items. + + Returns: + list: Selected item values. + """ + if not self._is_listbox_alive(): + return list(self._cached_selection) + selected_indices = self.listbox.curselection() + return [self.listbox.get(i) for i in selected_indices] + + def get_selection_indices(self): + """Return tuple of selected indices. + + Returns: + tuple: Indices of selected entries. + """ + if not self._is_listbox_alive(): + return self._cached_selection_indices + return self.listbox.curselection() + + def select_all(self): + """Select all items in the listbox.""" + self.listbox.selection_set(0, tk.END) + self._sync_cache_from_widget() + + def deselect_all(self): + """Clear all selected items in the listbox.""" + self.listbox.selection_clear(0, tk.END) + self._sync_cache_from_widget() + + def insert(self, index, item): + """Insert an item at the specified index. + + Args: + index: Position at which to insert. + item: Item value to insert. + """ + self.listbox.insert(index, item) + self._sync_cache_from_widget() + self._on_configure() + + def delete(self, index): + """Delete an item at the specified index. + + Args: + index: Position of item to delete. + """ + self.listbox.delete(index) + self._sync_cache_from_widget() + self._on_configure() + + def clear(self): + """Clear all items from the listbox.""" + self.listbox.delete(0, tk.END) + self._cached_options = [] + self._cached_selection = [] + self._cached_selection_indices = () + self.items = [] + self._on_configure() + + def pack(self, **kwargs): + """Pack the widget with the given options. + + Args: + **kwargs: Pack geometry manager options. + """ + super().pack(**kwargs) + self._on_configure() # Ensure scrollbar visibility is updated when packed + + def set(self, value): + """Set the currently selected item. + + Args: + value: Item value to select. If `None`, clears all selection. + """ + if not self._is_listbox_alive(): + self._cached_selection = [] if value is None else [str(value)] + self._cached_selection_indices = () + return + + self.listbox.selection_clear(0, tk.END) + if value is None: + self._sync_cache_from_widget() + return + + options = self.get_options() + selected_value = str(value) + if selected_value in options: + index = options.index(selected_value) + self.listbox.selection_set(index) + self.listbox.activate(index) + self.listbox.see(index) + self._sync_cache_from_widget() + + def get(self): + """Get all currently selected items. + + Returns: + list[str]: Selected item values, or an empty list when nothing is selected. + """ + return self.get_selection() + + def get_options(self): + """Get all options currently displayed in the listbox. + + Returns: + list[str]: Current listbox options in display order. + """ + if not self._is_listbox_alive(): + return list(self._cached_options) + return [self.listbox.get(i) for i in range(self.listbox.size())] + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current listbox state. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid state. + """ + # In this simple implementation, all states are valid, so we return True. + return self.apply_validation((True, None, None)) + + def set_options(self, options): + """Replace the listbox items with new options. + + Args: + options: Iterable of item values to display. + """ + self.clear() + for item in options: + self.listbox.insert(tk.END, item) + self._sync_cache_from_widget() + self._on_configure() + + def _sync_items_from_listbox(self) -> None: + """Synchronize cached items with the listbox contents.""" + self._sync_cache_from_widget() + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow(theme_mode="light") + parent_container = root.content_frame + + items = [f"Item {i}" for i in range(1, 21)] + root.widgets.append(ScrollableListBox( + parent_container, + items=items, + height=10, + show_selection_controls=True, + item_title="List Box", + helper_text="Select items from the list.", + build_options=PackingOptions(padx=10, pady=10) + )) + root.widgets[-1].build() + + + root.mainloop() + + print("Selected items:", root.widgets[-1].get()) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py new file mode 100644 index 0000000..2f0164c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py @@ -0,0 +1,148 @@ +"""Path selection widget for file or directory browsing.""" + +import tkinter as tk +from tkinter import filedialog, ttk +from pathlib import Path +from typing import Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button + +class PathSelector(BHoMBaseWidget): + """A reusable path/file selector widget with a button and a readonly entry.""" + + def __init__( + self, + parent: ttk.Frame, + button_text="Browse...", + filetypes=None, + command=None, + initialdir=None, + entry_width=40, + button_width=25, + mode="file", + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + button_text (str): The text to display on the button. + filetypes (list, optional): List of filetypes for the dialog. + command (callable, optional): Called with the file path after selection. + initialdir (str, optional): Initial directory for the file dialog. + mode (str): Either "file" or "directory" to select files or directories. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the entry box. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + self.path_var = tk.StringVar() + self.command = command + self.mode = mode + self.initialdir = initialdir + self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] + self.display_name = tk.StringVar() + self.entry = ttk.Entry(self.content_frame, textvariable=self.display_name, width=entry_width) + self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) + + # Use Button wrapper but expose inner ttk.Button for backward compatibility + button_widget = Button(self.content_frame, text=button_text, command=self._on_click, width=button_width) + button_widget.pack(side=tk.LEFT) + self.button = button_widget.button + + def _on_click(self): + if self.mode == "directory": + path = filedialog.askdirectory( + initialdir=self.initialdir + ) + else: + path = filedialog.askopenfilename( + filetypes=self.filetypes, + initialdir=self.initialdir + ) + if path: + selected_path = Path(path) + self.path_var.set(str(selected_path)) + if self.mode == "directory": + self.display_name.set(str(selected_path)) + else: + self.display_name.set(selected_path.name) + if self.command: + self.command(str(selected_path)) + + def get(self) -> str: + """Return the currently selected file path. + + Returns: + str: Selected file or directory path. + """ + return self.path_var.get() + + def set(self, value: Optional[str]): + """Set the file path in the entry. + + Args: + value: File or directory path to display. + """ + if not value: + self.path_var.set("") + self.display_name.set("") + return + + selected_path = Path(value) + self.path_var.set(str(selected_path)) + if self.mode == "directory": + self.display_name.set(str(selected_path)) + + else: + self.display_name.set(selected_path.name) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected path. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid path selection. + """ + selected_path = self.get().strip() + if not selected_path: + return self.apply_validation((False, "No path selected.", "error")) + + path = Path(selected_path) + if self.mode == "directory": + if not path.is_dir(): + return self.apply_validation((False, f"Directory does not exist: {selected_path}", "error")) + else: + if not path.is_file(): + return self.apply_validation((False, f"File does not exist: {selected_path}", "error")) + + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + parent_container = root.content_frame + + def on_file_selected(path): + """Print selected path in the standalone example.""" + print(f"Selected: {path}") + + path_selector = PathSelector( + parent_container, + button_text="Select File", + filetypes=[("All Files", "*.*")], + command=on_file_selected, + item_title="Path Selector", + helper_text="Select a file from your system.", + build_options=PackingOptions(padx=20, pady=20), + button_width=15, + entry_width=50, + alignment="center" + ) + path_selector.build() + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py new file mode 100644 index 0000000..e03a2d5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py @@ -0,0 +1,198 @@ +"""Single-select radio-style widget built from ttk Radiobuttons.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class RadioSelection(BHoMBaseWidget): + """A reusable radio selection widget built from a list of fields.""" + + def __init__( + self, + parent, + fields=None, + command=None, + default=None, + orient="vertical", + options_fill_extents: bool = False, + max_per_line=None, + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + fields (list, optional): List of selectable fields. + command (callable, optional): Called with selected value when changed. + default (str, optional): Default selected value. + orient (str): Either "vertical" or "horizontal". + options_fill_extents (bool): If `True`, radio option rows expand to + fill their available width in the options frame. + max_per_line (int, optional): Maximum items per row/column before wrapping. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the entry box. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.fields = [str(field) for field in (fields or [])] + self.command = command + self.orient = orient.lower() + self.options_fill_extents = self._normalise_bool(options_fill_extents) + self.max_per_line = max_per_line + self.value_var = tk.StringVar() + self._buttons = [] + + # Sub-frame for radio button controls + self.buttons_frame = ttk.Frame(self.content_frame) + self.buttons_frame.pack(side="top", fill="x", expand=True) + + self._build_buttons() + + if default is not None: + self.set(default) + elif self.fields: + self.value_var.set(self.fields[0]) + + def _build_buttons(self): + """Create radio buttons from current fields.""" + for button in self._buttons: + button.destroy() + self._buttons.clear() + for index in range(self.buttons_frame.grid_size()[0] + 1): + self.buttons_frame.grid_columnconfigure(index, weight=0) + + for index, field in enumerate(self.fields): + sticky = self._grid_sticky + button = ttk.Radiobutton( + self.buttons_frame, + text=field, + variable=self.value_var, + value=field, + style="Radio.TRadiobutton", + command=lambda f=field: self._select_field(f), + ) + self.align_child_text(button) + + if self.max_per_line and self.max_per_line > 0: + if self.orient == "horizontal": + row = index // self.max_per_line + column = index % self.max_per_line + else: + row = index % self.max_per_line + column = index // self.max_per_line + button.grid( + row=row, + column=column, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(column, weight=1) + elif self.orient == "horizontal": + button.grid( + row=0, + column=index, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(index, weight=1) + else: + button.grid( + row=index, + column=0, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(0, weight=1) + self._buttons.append(button) + + def _select_field(self, field): + """Handle radio button selection.""" + if self.command: + self.command(self.get()) + + def get(self): + """Return the currently selected value. + + Returns: + str: Currently selected field label. + """ + return self.value_var.get() + + def set(self, value): + """Set the selected value if it exists in fields. + + Args: + value: Field label to select. + """ + value = str(value) + if value in self.fields: + self.value_var.set(value) + + def set_fields(self, fields, default=None): + """Replace the available fields and rebuild the widget. + + Args: + fields: New available field names. + default: Optional default field to select. + """ + self.fields = [str(field) for field in (fields or [])] + self._build_buttons() + + if default is not None: + self.set(default) + elif self.fields: + self.value_var.set(self.fields[0]) + else: + self.value_var.set("") + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected radio option. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid selection. + """ + selected = self.get() + if not selected: + return self.apply_validation((False, "No option selected.", "error")) + if selected not in self.fields: + return self.apply_validation((False, f"Selected option '{selected}' is not available.", "error")) + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def on_selection(value): + """Print selected option in the standalone example.""" + print(f"Selected: {value}") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + widget = RadioSelection( + parent_frame, + fields=["Option A", "Option B", "Option C"], + command=on_selection, + default="Option B", + alignment="center", + options_fill_extents=True, + orient="horizontal", + max_per_line=3, + item_title="Choose an Option", + helper_text="Select one of the options below:", + build_options=PackingOptions(pady=20) + ) + widget.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py new file mode 100644 index 0000000..8154c32 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py @@ -0,0 +1,179 @@ +"""Spinbox widget for numeric or list-based stepped input.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, List, Union, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Spinbox(BHoMBaseWidget): + """A spinbox widget supporting numeric ranges or explicit value lists.""" + + def __init__( + self, + parent, + values: Optional[Union[List[str], List[int], List[float]]] = None, + from_: Optional[Union[int, float]] = None, + to: Optional[Union[int, float]] = None, + increment: Union[int, float] = 1, + default: Optional[Union[str, int, float]] = None, + command: Optional[Callable[[str], None]] = None, + width: int = 10, + wrap: bool = False, + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + values (list, optional): Explicit list of string values to step through. + If provided, ``from_``, ``to`` and ``increment`` are ignored. + from_ (int | float, optional): Minimum value for numeric mode. + to (int | float, optional): Maximum value for numeric mode. + increment (int | float): Step size for numeric mode. Defaults to 1. + default (str | int | float, optional): Initial value. + command (callable, optional): Called with the current value (str) on change. + width (int): Width of the entry in characters. + wrap (bool): Whether stepping wraps around at the limits. + item_title (str, optional): Optional header text. + helper_text (str, optional): Optional helper text. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.command = command + self._value_var = tk.StringVar() + + # Determine the native type for get() coercion + if values and len(values) > 0: + self._value_type = type(values[0]) + elif from_ is not None: + self._value_type = type(from_) + else: + self._value_type = str + + spinbox_kwargs: dict = dict( + textvariable=self._value_var, + width=width, + wrap=wrap, + ) + + if values: + spinbox_kwargs["values"] = [str(v) for v in values] + else: + if from_ is not None: + spinbox_kwargs["from_"] = from_ + if to is not None: + spinbox_kwargs["to"] = to + spinbox_kwargs["increment"] = increment + + self.spinbox = ttk.Spinbox(self.content_frame, **spinbox_kwargs) + self.spinbox.pack(side="top", anchor=self._pack_anchor) + + self._value_var.trace_add("write", self._on_change) + + if default is not None: + self.set(default) + elif values: + self._value_var.set(str(values[0])) + elif from_ is not None: + self._value_var.set(str(from_)) + + def _on_change(self, *_): + """Fire the command callback when the value changes.""" + if self.command: + self.command(self.get()) + + def get(self) -> Union[str, int, float]: + """Return the current value cast to its original type. + + Returns: + str | int | float: Current spinbox value in its original type. + """ + raw = self._value_var.get() + try: + return self._value_type(raw) + except (ValueError, TypeError): + return raw + + def get_int(self) -> int: + """Return the current value as an integer. + + Returns: + int: Current spinbox value. + + Raises: + ValueError: If the value cannot be converted to int. + """ + return int(self.get()) + + def get_float(self) -> float: + """Return the current value as a float. + + Returns: + float: Current spinbox value. + + Raises: + ValueError: If the value cannot be converted to float. + """ + return float(self.get()) + + def set(self, value: Union[str, int, float]): + """Set the spinbox value. + + Args: + value: New value to display. + """ + self._value_var.set(str(value)) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate that the current value is non-empty. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + ``(is_valid, message, severity)``. + """ + if not str(self.get()).strip(): + return getattr(self, "apply_validation")((False, "A value is required.", "error")) + return getattr(self, "apply_validation")((True, None, None)) + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def on_change(value): + print(f"Value: {value}") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + # Numeric spinbox + numeric = Spinbox( + parent_frame, + from_=0, + to=100, + increment=5, + default=25, + command=on_change, + item_title="Numeric", + helper_text="Pick a value between 0 and 100", + build_options=PackingOptions(padx=20, pady=(20, 8)), + width = 1, + ) + numeric.build() + + # List-based spinbox + list_spin = Spinbox( + parent_frame, + values=["Small", "Medium", "Large", "X-Large"], + default="Medium", + command=on_change, + item_title="Size", + helper_text="Step through available sizes", + build_options=PackingOptions(padx=20, pady=(0, 20)), + width=100 + ) + list_spin.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py new file mode 100644 index 0000000..e735c6e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -0,0 +1,455 @@ +"""Validated entry widget supporting typed value and constraint checks.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Optional, Callable, Any, Union, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +_TkVar = Union[tk.StringVar, tk.IntVar, tk.DoubleVar, tk.BooleanVar] + +class ValidatedEntryBox(BHoMBaseWidget): + """ + A reusable entry box component with built-in validation for different data types. + + Supports validation for: + - String (required/optional, min/max length) + - Integer (min/max value) + - Float (min/max value) + - Custom validation via callback + """ + + def __init__( + self, + parent, + variable: Optional[_TkVar] = None, + default_value: Optional[Union[str, int, float, bool]] = None, + width: int = 15, + value_type: type = str, + min_value: Optional[Union[int, float]] = None, + max_value: Optional[Union[int, float]] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + required: bool = True, + custom_validator: Optional[Callable[[Any], tuple[bool, str]]] = None, + on_validate: Optional[Callable[[bool], None]] = None, + **kwargs + ) -> None: + """ + Initialize the ValidatedEntryBox. + + Args: + parent: Parent + item_title: Optional header text shown at the top of the widget frame + requirements_text: Optional helper text shown above the entry box + variable: StringVar, IntVar, DoubleVar or BooleanVar to bind to the entry + (creates a StringVar if not provided). + width: Width of the entry widget + value_type: Type to validate against (str, int, float, bool). + Inferred automatically when an IntVar, DoubleVar or BooleanVar is passed. + min_value: Minimum value for numeric types + max_value: Maximum value for numeric types + min_length: Minimum length for string type + max_length: Maximum length for string type + required: Whether the field is required + custom_validator: Custom validation function that returns (is_valid, error_message) + on_validate: Callback function called after validation with validation result + """ + super().__init__(parent, **kwargs) + + # Infer value_type from the variable kind when not explicitly set + if variable is not None and value_type is str: + if isinstance(variable, tk.IntVar): + value_type = int + elif isinstance(variable, tk.DoubleVar): + value_type = float + elif isinstance(variable, tk.BooleanVar): + value_type = bool + + self.value_type = value_type + self.min_value = min_value + self.max_value = max_value + self.min_length = min_length + self.max_length = max_length + self.required = required + self.custom_validator = custom_validator + self.on_validate = on_validate + self.default_value = default_value + + # Store the external variable (any tkinter var type) + self._external_var: Optional[_TkVar] = variable + + # ttk.Entry only works with StringVar — always use one internally + self.variable = tk.StringVar(value="") + + if variable is not None: + if isinstance(variable, tk.StringVar): + # Use it directly instead of bridging + self.variable = variable + self._external_var = None + else: + # Seed the StringVar from the external var's current value + self.variable.set(str(variable.get())) + # Write back to external var when the StringVar changes + self.variable.trace_add("write", self._sync_to_external) + + # Create frame for entry and success indicator + self.entry_frame = ttk.Frame(self.content_frame) + self.entry_frame.pack(side="top", fill="x", anchor=self._pack_anchor) + + # Create entry widget + self.entry = ttk.Entry( + self.entry_frame, + textvariable=self.variable, + width=width, + justify=self._text_justify, + ) + self.entry.pack(side="left", fill="x", expand=True) + + # Create success indicator label at end of entry + self.success_label = Label( + self.entry_frame, + text=" ", + foreground="#4bb543", + width=2, + alignment=self.alignment, + ) + self.success_label.pack(side="left", padx=(5, 0)) + + # Create error label below entry with fixed height to prevent layout shifts + self.error_label = Label( + self.content_frame, + text=" ", + style="Caption.TLabel", + alignment=self.alignment, + ) + self.error_label.pack(side="top", fill="x", anchor=self._pack_anchor) + + # Bind validation events + self.entry.bind("", lambda _: self.validate()) + self.entry.bind("", lambda _: self.validate()) + + if default_value is not None: + self.set(default_value) + + def _sync_to_external(self, *_) -> None: + """Write the current StringVar text back to the external typed variable.""" + if self._external_var is None: + return + raw = self.variable.get() + try: + if isinstance(self._external_var, tk.IntVar): + self._external_var.set(int(raw)) + elif isinstance(self._external_var, tk.DoubleVar): + self._external_var.set(float(raw)) + elif isinstance(self._external_var, tk.BooleanVar): + self._external_var.set(raw.lower() in ("1", "true", "yes")) + except (ValueError, TypeError): + pass # Ignore mid-edit invalid states + + def get(self) -> str: + """Get the current value as a string. + + Returns: + str: Trimmed entry value. + """ + return self.variable.get().strip() + + def get_value(self) -> Optional[Union[str, int, float, bool]]: + """Get the current value converted to the specified type. + + Returns: + Optional[Union[str, int, float, bool]]: Parsed value, or ``None`` when empty/invalid. + """ + value_str = self.get() + if not value_str: + return None + + try: + if self.value_type == int: + return int(value_str) + elif self.value_type == float: + return float(value_str) + elif self.value_type == bool: + return value_str.lower() in ("1", "true", "yes") + else: + return value_str + except (ValueError, TypeError): + return None + + def set(self, value: Union[str, int, float, bool]) -> None: + """Set the entry value. + + Args: + value: Value to display in the entry. + """ + self.variable.set(str(value)) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """ + Validate the current entry value. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid input. + """ + if self.disable_validation: + self._show_success() + self._call_validate_callback(True) + return self.apply_validation((True, None, None)) + + value_str = self.get() + base_result: tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]] + + # Check if required + if self.required and not value_str: + self._show_error("Required") + self._call_validate_callback(False) + base_result = (False, "Required", "error") + return self.apply_validation(base_result) + + # If not required and empty, it's valid + if not self.required and not value_str: + self._show_success() + self._call_validate_callback(True) + base_result = (True, None, None) + return self.apply_validation(base_result) + + # Type-specific validation + if self.value_type == str: + is_valid = self._validate_string(value_str) + elif self.value_type == int: + is_valid = self._validate_int(value_str) + elif self.value_type == float: + is_valid = self._validate_float(value_str) + elif self.value_type == bool: + is_valid = self._validate_bool(value_str) + else: + self._show_error(f"Unsupported type: {self.value_type}") + self._call_validate_callback(False) + base_result = (False, f"Unsupported type: {self.value_type}", "error") + return self.apply_validation(base_result) + + if is_valid: + base_result = (True, None, None) + else: + message = self.error_label.get().strip() + base_result = (False, message if message else "Validation failed.", "error") + + final_result = self.apply_validation(base_result) + final_valid, final_message, final_severity = final_result + + if final_valid: + self._show_success() + self._call_validate_callback(True) + else: + if final_message: + self._show_error(final_message) + else: + self._show_error("Validation failed.") + self._call_validate_callback(False) + + return final_result + + def _validate_string(self, value: str) -> bool: + """Validate string value. + + Args: + value: String value to validate. + + Returns: + bool: `True` when valid, otherwise `False`. + """ + # Check length constraints + if self.min_length is not None and len(value) < self.min_length: + self._show_error(f"Minimum length: {self.min_length}") + self._call_validate_callback(False) + return False + + if self.max_length is not None and len(value) > self.max_length: + self._show_error(f"Maximum length: {self.max_length}") + self._call_validate_callback(False) + return False + + # Custom validation + if self.custom_validator: + is_valid, error_msg = self.custom_validator(value) + if not is_valid: + self._show_error(error_msg) + self._call_validate_callback(False) + return False + + self._show_success() + self._call_validate_callback(True) + return True + + def _validate_int(self, value_str: str) -> bool: + """Validate integer value. + + Args: + value_str: Raw entry text to parse as integer. + + Returns: + bool: `True` when valid, otherwise `False`. + """ + try: + value = int(value_str) + except ValueError: + self._show_error("Must be a valid integer") + self._call_validate_callback(False) + return False + + # Check range constraints + if self.min_value is not None and value < self.min_value: + self._show_error(f"Must be >= {self.min_value}") + self._call_validate_callback(False) + return False + + if self.max_value is not None and value > self.max_value: + self._show_error(f"Must be <= {self.max_value}") + self._call_validate_callback(False) + return False + + # Custom validation + if self.custom_validator: + is_valid, error_msg = self.custom_validator(value) + if not is_valid: + self._show_error(error_msg) + self._call_validate_callback(False) + return False + + self._show_success() + self._call_validate_callback(True) + return True + + def _validate_float(self, value_str: str) -> bool: + """Validate float value. + + Args: + value_str: Raw entry text to parse as float. + + Returns: + bool: `True` when valid, otherwise `False`. + """ + try: + value = float(value_str) + except ValueError: + self._show_error("Must be a valid number") + self._call_validate_callback(False) + return False + + # Check range constraints + if self.min_value is not None and value < self.min_value: + if self.max_value is not None: + self._show_error(f"Must be between {self.min_value} and {self.max_value}") + else: + self._show_error(f"Must be >= {self.min_value}") + self._call_validate_callback(False) + return False + + if self.max_value is not None and value > self.max_value: + if self.min_value is not None: + self._show_error(f"Must be between {self.min_value} and {self.max_value}") + else: + self._show_error(f"Must be <= {self.max_value}") + self._call_validate_callback(False) + return False + + # Custom validation + if self.custom_validator: + is_valid, error_msg = self.custom_validator(value) + if not is_valid: + self._show_error(error_msg) + self._call_validate_callback(False) + return False + + self._show_success() + self._call_validate_callback(True) + return True + + def _validate_bool(self, value_str: str) -> bool: + """Validate boolean value (accepts 1/0, true/false, yes/no). + + Args: + value_str: Raw entry text to interpret as boolean. + + Returns: + bool: ``True`` when valid, otherwise ``False``. + """ + if value_str.lower() not in ("1", "0", "true", "false", "yes", "no"): + self._show_error("Must be true/false, yes/no, or 1/0") + self._call_validate_callback(False) + return False + + if self.custom_validator: + parsed = value_str.lower() in ("1", "true", "yes") + is_valid, error_msg = self.custom_validator(parsed) + if not is_valid: + self._show_error(error_msg) + self._call_validate_callback(False) + return False + + self._show_success() + self._call_validate_callback(True) + return True + + def _show_error(self, message: str) -> None: + """Display error message.""" + self.error_label.set(message) + self.error_label.label.configure(foreground="#ff4444") + self.success_label.set(" ") + + def _show_success(self) -> None: + """Display success indicator.""" + self.error_label.set(" ") + self.success_label.set("✓") + + def clear_error(self) -> None: + """Clear the error message.""" + self.error_label.set(" ") + self.success_label.set(" ") + + def _call_validate_callback(self, is_valid: bool) -> None: + """Call the validation callback if provided.""" + if self.on_validate: + self.on_validate(is_valid) + + def set_alignment(self, alignment: Literal['left', 'center', 'right']) -> None: + """Set widget alignment and refresh entry + labels accordingly.""" + super().set_alignment(alignment) + self.entry.configure(justify=self._text_justify) + self.entry_frame.pack_configure(anchor=self._pack_anchor) + self.error_label.set_alignment(self.alignment) + self.error_label.pack_configure(anchor=self._pack_anchor) + self.success_label.set_alignment(self.alignment) + + + +if __name__ == "__main__": + # Test the ValidatedEntryBox + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow(title="Validated Entry Box Test") + parent_container = getattr(root, "content_frame", root) + + def on_validate(is_valid): + """Print validation state in the standalone example.""" + print(f"Validation result: {is_valid}") + + entry_box = ValidatedEntryBox( + parent_container, + item_title="Integer Field", + helper_text="Enter an integer from 0 to 100", + value_type=int, + min_value=0, + max_value=100, + on_validate=on_validate, + build_options=PackingOptions(padx=20, pady=20) + ) + entry_box.build() + + root.mainloop() \ No newline at end of file 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 new file mode 100644 index 0000000..cc46d6c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -0,0 +1,233 @@ +"""Calendar date-picker widget with optional year selector.""" + +import tkinter as tk +from typing import Optional, Literal +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +import calendar +import datetime + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.drop_down_selection import DropDownSelection + +class CalendarWidget(BHoMBaseWidget): + """Render a month grid and allow date selection.""" + + def __init__( + self, + parent: ttk.Frame, + def_year: int = 2026, + def_month: int = 1, + def_day: int = 1, + show_year_selector: bool = True, + year_min: int = 1900, + year_max: int = 2100, + day_button_width: int = 4, + day_button_padx: int = 1, + day_button_pady: int = 1, + day_button_text_alignment: Literal["left", "center", "right"] = "center", + **kwargs): + + super().__init__(parent, **kwargs) + + self.year = def_year + self.month = def_month + self.show_year_selector = show_year_selector + self.year_min = year_min + self.year_max = year_max + self.day_button_width = max(1, int(day_button_width)) + self.day_button_padx = int(day_button_padx) + self.day_button_pady = int(day_button_pady) + alignment_candidate = str(day_button_text_alignment).strip().lower() + if alignment_candidate not in {"left", "center", "right"}: + alignment_candidate = "center" + self.day_button_text_alignment = alignment_candidate + self.day_button_style = f"CalendarDay.{id(self)}.TButton" + + anchor_map = { + "left": "w", + "center": "center", + "right": "e", + } + 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", fill="x") + + 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.set_day(def_day) + self.redraw() + + def year_selector(self): + """Build the year dropdown selector.""" + years = [str(year) for year in range(self.year_min, self.year_max + 1)] + self.year_dropdown = DropDownSelection( + self.month_frame, + options=years, + default=str(self.year), + command=lambda val: self.set_year(val), + state="readonly", + ) + self.year_dropdown.pack(side="left", padx=4, pady=4) + + def month_selector(self): + """Build the month dropdown selector.""" + self.months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + self.month_dropdown = DropDownSelection( + self.month_frame, + options=self.months, + default=self.months[self.month - 1], + command=lambda val: self.set_month(val), + state="readonly", + ) + self.month_dropdown.pack(side="left", padx=4, pady=4) + + def set_year(self, value): + """Update the selected year and redraw the calendar. + + Args: + value: The selected year as a string. + """ + self.year = int(value) + self.redraw() + + def set_month(self, value): + """Update the selected month and redraw the calendar. + + Args: + value: The selected month name as a string. + """ + self.month = self.months.index(value) + 1 + self.redraw() + + def redraw(self): + """Rebuild the month grid buttons for the current month and year.""" + for child in self.cal_frame.winfo_children(): + child.destroy() + + for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): + label = Label(self.cal_frame, text=day) + self.align_child_text(label) + label.grid(row=0, column=col, sticky="nsew") + + cal = calendar.monthcalendar(self.year, self.month) + + for row, week in enumerate(cal): + for col, day in enumerate(week): + text = "" if day == 0 else day + state = "normal" if day > 0 else "disabled" + cell_widget = Button( + self.cal_frame, + text=str(text) if text != "" else "", + command=(lambda d=day: self.set_day(d)), + width=self.day_button_width, + ) + cell_widget.button.configure(style=self.day_button_style, state=state) + cell_widget.grid( + row=row+1, + column=col, + sticky="nsew", + padx=self.day_button_padx, + pady=self.day_button_pady, + ) + + def set_day(self, num): + """Set the selected day and refresh the date summary label. + + Args: + num: Day of month to mark as selected. + """ + 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) + + def get_date(self): + """Return the selected date as a `datetime.date` instance. + + Returns: + datetime.date: Currently selected date. + """ + return datetime.date(self.year, self.month, self.day) + + def get(self): + """Return the selected date value. + + Returns: + datetime.date: Currently selected date. + """ + return datetime.date(self.year, self.month, self.day) + + def set(self, value: datetime.date): + """Set the selected date from a `datetime.date` value. + + Args: + value: Date to apply to the widget. + """ + self.year = value.year + self.month = value.month + self.day = value.day + self.redraw() + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected date. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for an invalid date. + """ + try: + datetime.date(self.year, self.month, self.day) + return self.apply_validation((True, None, None)) + except ValueError as ex: + return self.apply_validation((False, f"Invalid date: {ex}", "error")) + + def pack(self, **kwargs): + """Pack the widget and ensure the calendar grid is rendered. + + Args: + **kwargs: Pack geometry manager options. + """ + super().pack(**kwargs) + self.redraw() + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow(min_height=500, min_width=400, theme_mode="light") + root.title("Calendar Widget Test") + + # Example without year selector + cal_widget1 = CalendarWidget( + root.content_frame, + def_year=2024, + def_month=6, + def_day=15, + day_button_width=2, + day_button_padx=2, + day_button_pady=2, + day_button_text_alignment="center", + item_title="Select a Date", + helper_text="Choose a date from the calendar below.", + build_options=PackingOptions(padx=20, pady=20) + ) + cal_widget1.build() + + root.mainloop() 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 new file mode 100644 index 0000000..c372f4b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -0,0 +1,11 @@ +from .directory_file_selector import DirectoryFileSelector +from .landing_page import LandingPage +from .processing_window import ProcessingWindow +from .warning_box import WarningBox + +__all__ = [ + "DirectoryFileSelector", + "LandingPage", + "ProcessingWindow", + "WarningBox", +] 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 new file mode 100644 index 0000000..67e9cab --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -0,0 +1,119 @@ +"""Window for selecting multiple files by extension from a directory tree.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from pathlib import Path +from typing import Iterable, List, Optional + +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 + +class DirectoryFileSelector(BHoMBaseWindow): + """Display matching files and return the user's multi-selection.""" + + def __init__( + self, + directory: Path, + file_types: Iterable[str], + selection_label: str = "file(s)", + **kwargs, + ) -> None: + self.directory = Path(directory) + self.file_types = self._normalise_file_types(file_types) + self.selection_label = selection_label + self._cancelled = False + self.selected_files: List[Path] = [] + + self.files = self._discover_files() + self.display_items = [self._display_value(file) for file in self.files] + self.file_lookup = dict(zip(self.display_items, self.files)) + self.file_selector_listbox: Optional[ScrollableListBox] = None + + super().__init__( + title=f"Select {selection_label}", + min_width=600, + min_height=400, + submit_text="OK", + submit_command=self._on_submit, + close_text="Cancel", + close_command=self._on_cancel, + on_close_window=self._on_cancel, + **kwargs, + ) + + def build(self): + """Build the list-based file selection UI.""" + + instruction_label = Label( + self.content_frame, + text=f"Select the {self.selection_label} to analyse.", + ) + instruction_label.pack(anchor="w", pady=(0, 10)) + + if self.file_selector_listbox is None: + self.file_selector_listbox = ScrollableListBox( + id="file_selector_listbox", + parent=self.content_frame, + items=self.display_items, + selectmode=tk.MULTIPLE, + height=12, + show_selection_controls=True, + build_options=PackingOptions(fill='both', expand=True), + ) + self.widgets.append(self.file_selector_listbox) + + super().build() + + def _normalise_file_types(self, file_types: Iterable[str]) -> List[str]: + normalised = [] + for file_type in file_types: + suffix = str(file_type).strip() + if not suffix: + continue + if not suffix.startswith("."): + suffix = f".{suffix}" + normalised.append(suffix.lower()) + return normalised + + def _discover_files(self) -> List[Path]: + if not self.directory.exists(): + return [] + + matches = [] + for path in self.directory.rglob("*"): + if path.is_file() and path.suffix.lower() in self.file_types: + matches.append(path) + return sorted(matches) + + def _display_value(self, path: Path) -> str: + try: + return str(path.relative_to(self.directory)) + except ValueError: + return str(path) + + def _on_submit(self): + """Handle OK button - capture selection before window closes.""" + selected = self.file_selector_listbox.get_selection() if self.file_selector_listbox else [] + self.selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] + self.destroy_root() + + def _on_cancel(self): + """Handle Cancel button or window close.""" + self._cancelled = True + self.destroy_root() + +if __name__ == "__main__": + + print(Path(__file__).resolve().parent, flush=True) + # Example usage + selector = DirectoryFileSelector( + directory=Path(__file__).resolve().parent, + file_types=[".py", ".txt"], + selection_label="scripts and text files", + ) + + selector.mainloop() + for file in selector.selected_files: + print(file) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py new file mode 100644 index 0000000..a2b0045 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py @@ -0,0 +1,125 @@ +"""Landing page window with configurable header, message text, and custom actions.""" + +from tkinter import ttk +from typing import Optional, Callable +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.label import Label +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + +class LandingPage(BHoMBaseWindow): + """ + A reusable landing page GUI with configurable title, message, and buttons. + Uses BHoMBaseWindow as the base template. + """ + + def __init__( + self, + title: str = "Landing Page", + header: Optional[str] = None, + message: Optional[str] = None, + sub_title: Optional[str] = None, + **kwargs, + ): + """ + Initializes the landing page GUI. + + Args: + title (str): Window and header title text. + message (str, optional): Commentary/message text to display. + """ + self.header = header + self.message = message + self.sub_title = sub_title + self.custom_buttons_frame: Optional[ttk.Frame] = None + + super().__init__( + title=title, + **kwargs, + ) + + def build(self): + """Build landing-page content using the base window's content area.""" + if self.header: + header_label = Label( + self.content_frame, + text=self.header, + style="Headline.TLabel", + alignment="left", + build_options=PackingOptions(side="top", anchor="w", pady=(0, 10)), + ) + header_label.build() + + if self.message: + message_label = Label( + self.content_frame, + text=self.message, + style="Body.TLabel", + justify="left", + alignment="left", + build_options=PackingOptions(side="top", anchor="w", pady=(0, 10)), + ) + message_label.build() + + if self.sub_title: + sub_title_label = Label( + self.content_frame, + text=self.sub_title, + style="Caption.TLabel", + alignment="left", + build_options=PackingOptions(side="top", anchor="w", pady=(0, 10)), + ) + sub_title_label.build() + + self.custom_buttons_frame = ttk.Frame(self.content_frame) + self.custom_buttons_frame.pack(fill="x", pady=(0, 20)) + + super().build() + + def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Button: + """ + Add a custom button to the landing page. + + Args: + text (str): Button text. + command (callable): Function to call when button is clicked. + **kwargs: Additional ttk.Button options. + + Returns: + ttk.Button: The created button widget. + """ + if self.custom_buttons_frame is None: + self.custom_buttons_frame = ttk.Frame(self.content_frame) + self.custom_buttons_frame.pack(fill="x", pady=(0, 20)) + + button_widget = Button(self.custom_buttons_frame, text=text, command=command, **kwargs) + button_widget.pack(pady=5, fill="x") + # Recalculate window size after adding button + self.refresh_sizing() + # Return inner ttk.Button for compatibility + return button_widget.button + + +if __name__ == "__main__": + + #simple example of using the landing page + def on_button_click(): + """Handle demo button clicks in the standalone example.""" + print("Button clicked!") + + def on_button_click_2(): + """Handle demo button clicks in the standalone example.""" + print("Second button clicked!") + + landing_page = LandingPage( + title="Welcome to the BHoM Toolkit", + header="Welcome to the BHoM Toolkit", + message="This is a landing page example. You can add custom buttons below.", + sub_title="Please click the button to proceed.", + show_close=True, + show_submit=False, + ) + landing_page.add_custom_button(text="Click Me", command=on_button_click) + landing_page.add_custom_button(text="Click Me 2", command=on_button_click_2) + landing_page.mainloop() \ No newline at end of file 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 new file mode 100644 index 0000000..b958dfa --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -0,0 +1,201 @@ +import tkinter as tk +from tkinter import ttk +import os + +import time +import threading + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + +class ProcessingWindow(BHoMBaseWindow): + """A simple processing window with animated indicator.""" + + def __init__(self, title="Processing", message="Processing...", *args, **kwargs): + """ + Args: + title (str): Window title. + message (str): Message to display. + """ + super().__init__( + title=title, + min_width=300, + min_height=150, + width=400, + height=200, + theme_mode="auto", + show_close=False, + show_submit=False, + *args, + **kwargs + ) + + self.title(title) + self.attributes("-topmost", True) + self.resizable(False, False) + + # Container + container = ttk.Frame(self, padding=20) + container.pack(fill="both", expand=True) + + # Message label (to calculate size) + self.message_label = ttk.Label( + container, + text=message, + style="Title.TLabel", + justify="center", + wraplength=400 + ) + try: + title_font = ttk.Style(self).lookup("Title.TLabel", "font") + if title_font: + self.message_label.configure(font=title_font) + except Exception: + pass + self.message_label.pack(pady=(0, 20)) + + # Animation frame + animation_frame = ttk.Frame(container) + animation_frame.pack(expand=True) + + self.animation_label = ttk.Label( + animation_frame, + text="●", + style="Title.TLabel", + foreground="#0078d4" + ) + try: + title_font = ttk.Style(self).lookup("Title.TLabel", "font") + if title_font: + self.animation_label.configure(font=title_font) + except Exception: + 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}") + + + def start(self): + """Start the processing window and animation.""" + if self.is_running: + 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() + except Exception as e: + print("ProcessingWindow mainloop error:", e) + raise + + def start_with_worker(self, worker, args=(), kwargs=None): + """Start the GUI mainloop on this (main) thread and run `worker` in a background thread. + + The worker should not call Tkinter methods directly. When the worker finishes, + the window is closed via a call scheduled on the Tk event loop. + """ + if kwargs is None: + kwargs = {} + + if self.is_running: + return + self.is_running = True + + def run_worker(): + try: + worker(*args, **kwargs) + finally: + try: + self.after(0, self.stop) + except Exception: + pass + + t = threading.Thread(target=run_worker, daemon=True) + t.start() + + try: + self._animate() + self.mainloop() + except Exception as e: + print("ProcessingWindow mainloop error:", e) + raise + + def keep_alive(self): + """Call this repeatedly to process animation updates. Returns False when done.""" + if self.is_running and self.winfo_exists(): + self.update() + return True + return False + + 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 + + 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 + self.after(200, self._animate) + + 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): + time.sleep(0.1) + try: + processing.after(0, processing.update_message, f"Step {i+1}/50") + except Exception: + pass + + processing.start_with_worker(worker) 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 new file mode 100644 index 0000000..c92e8f1 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -0,0 +1,123 @@ +"""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.widgets._packing_options import PackingOptions + + +class WarningBox(BHoMBaseWindow): + """Show categorized messages using the shared BHoM window styling.""" + + def __init__( + self, + title: str= 'Warning', + warnings: list[str] | str | None = None, + errors: list[str] | str | None = None, + infos: list[str] | str | None = None, + **kwargs + ): + + self.warnings = self._normalise_messages(warnings) + self.errors = self._normalise_messages(errors) + self.infos = self._normalise_messages(infos) + + super().__init__( + title=title, + show_submit=False, + close_text="Continue", + min_width=250, + min_height=150, + **kwargs + ) + + def build(self): + """Render current messages into the content area.""" + self._render_messages() + super().build() + + def _normalise_messages(self, messages: list[str] | str | None) -> list[str]: + if messages is None: + return [] + if isinstance(messages, str): + return [messages] + return list(messages) + + def _render_messages(self): + for widget in self.content_frame.winfo_children(): + widget.destroy() + + for message in self.errors: + error_label = Label( + self.content_frame, + text=message, + style="Error.TLabel", + wraplength=400, + justify="left", + alignment="left", + build_options=PackingOptions(anchor="w", pady=(0, 5)), + ) + error_label.build() + for message in self.warnings: + warning_label = Label( + self.content_frame, + text=message, + style="Warning.TLabel", + wraplength=400, + justify="left", + alignment="left", + build_options=PackingOptions(anchor="w", pady=(0, 5)), + ) + warning_label.build() + for message in self.infos: + info_label = Label( + self.content_frame, + text=message, + style="Caption.TLabel", + wraplength=400, + justify="left", + alignment="left", + build_options=PackingOptions(anchor="w", pady=(0, 5)), + ) + info_label.build() + + def update_messages(self): + """Clear and re-render all messages in the warning box.""" + self._render_messages() + self.refresh_sizing() + + def add_error_message(self, message: str): + """Add an error message to the warning box. + + Args: + message: Error text to append. + """ + self.errors.append(message) + self.update_messages() + + def add_warning_message(self, message: str): + """Add a warning message to the warning box. + + Args: + message: Warning text to append. + """ + self.warnings.append(message) + self.update_messages() + + def add_info_message(self, message: str): + """Add an informational message to the warning box. + + Args: + message: Informational text to append. + """ + self.infos.append(message) + self.update_messages() + + +if __name__ == "__main__": + root = WarningBox(title="Validation Error", warnings="This is a warning message to alert the user about something important.") + + for message in ["This is the first error message.", "This is the second error message."]: + root.add_error_message(message) + + root.add_info_message("This is some additional information for the user.") + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py new file mode 100644 index 0000000..8907921 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -0,0 +1,81 @@ +from matplotlib import pyplot as plt +from matplotlib.colors import Colormap, Normalize +from matplotlib.figure import Figure +import numpy as np +from typing import Union, Optional + +plt.rcParams["figure.max_open_warning"] = 0 + +def cmap_sample_plot( + cmap: Union[str, Colormap], + bounds: Optional[tuple] = None, + figsize: tuple = (9, 1), + bins: int = 256 +) -> Figure: + + """ + Generate a sample plot for a given colormap. + + Args: + cmap: Either a colormap string (e.g., 'viridis') or a custom Colormap object + bounds: Optional tuple of (vmin, vmax) for normalization. If None, uses (0, 1) + figsize: Figure size as (width, height) + bins: Number of discrete bins for the gradient. Defaults to 256. + Returns: + Matplotlib Figure object + """ + + # Set bounds + if bounds is None: + bounds = (0, 1) + + vmin, vmax = bounds + + # Create a gradient image with appropriate range. + # Use endpoint=False to avoid an emphasized terminal color column at the + # right edge when rasterized into a small preview. + gradient_row = np.linspace(vmin, vmax, bins, endpoint=False) + gradient = np.vstack([gradient_row, gradient_row]) + + # Create the figure and axis + fig, ax = plt.subplots(figsize=figsize) + fig.patch.set_alpha(0) + fig.patch.set_facecolor("none") + fig.subplots_adjust(left=0, right=1, bottom=0, top=1) + ax.patch.set_alpha(0) + ax.set_facecolor("none") + ax.set_position([0, 0, 1, 1]) + ax.margins(x=0, y=0) + + # Create normalization if custom colormap or custom bounds + norm = Normalize(vmin=vmin, vmax=vmax) + + # Display the gradient with the specified colormap + ax.imshow( + gradient, + aspect="auto", + cmap=cmap, + norm=norm, + interpolation="nearest", + resample=False, + ) + + # Remove axes for a cleaner look + ax.set_axis_off() + + return fig + +if __name__ == "__main__": + # Example 1: Using a preset colormap string + fig1 = cmap_sample_plot('viridis') + + # Example 2: Using a custom colormap with bounds + fig2 = cmap_sample_plot('plasma', bounds=(0, 100), bins=6) + + # Example 3: Creating and using a custom colormap + from matplotlib.colors import ListedColormap + custom_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728'] + custom_cmap = ListedColormap(custom_colors) + fig3 = cmap_sample_plot(custom_cmap, bounds=(0, 3)) + + plt.show() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index 17b41f0..9eb2c6f 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -35,187 +35,191 @@ def diurnal( Additional keyword arguments to pass to the matplotlib plotting function. legend (bool, optional): If True, show the legend. Defaults to True. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: A matplotlib Axes object. """ - if ax is None: - ax = plt.gca() if not isinstance(series.index, pd.DatetimeIndex): raise ValueError("Series passed is not datetime indexed.") show_legend = kwargs.pop("legend", True) - - # NOTE - no checks here for missing days, weeks, or months, it should be evident from the plot - - # obtain plotting parameters - minmax_range = kwargs.pop("minmax_range", [0.0001, 0.9999]) - if minmax_range[0] > minmax_range[1]: - 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]: - raise ValueError("quantile_range must be within minmax_range.") - quantile_alpha = kwargs.pop("quantile_alpha", 0.3) - - color = kwargs.pop("color", "slategray") - - # resample to hourly to ensuure hour alignment - # TODO - for now we only resample to hourly, but this could be made more flexible by allowing any subset of period - series = series.resample("h").mean() - - # remove nan/inf - series = series.replace([-np.inf, np.inf], np.nan).dropna() - - # Remove outliers - series = series[ - (series >= series.quantile(minmax_range[0])) - & (series <= series.quantile(minmax_range[1])) - ] - - # group data - if period == "daily": - group = series.groupby(series.index.hour) - target_idx = range(24) - major_ticks = target_idx[::3] - minor_ticks = target_idx - major_ticklabels = [f"{i:02d}:00" for i in major_ticks] - elif period == "weekly": - group = series.groupby([series.index.dayofweek, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(7), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::3] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.day_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - elif period == "monthly": - group = series.groupby([series.index.month, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(1, 13, 1), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::6] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.month_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - else: - raise ValueError("period must be one of 'daily', 'weekly', or 'monthly'") - - samples_per_timestep = group.count().mean() - ax.set_title( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile (≈{samples_per_timestep:0.0f} samples per timestep)", - ) - ) - - # Get values to plot - minima = group.min() - lower = group.quantile(quantile_range[0]) - median = group.median() - mean = 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], - axis=1, - keys=["minima", "lower", "median", "mean", "upper", "maxima"], - ).reindex(target_idx) - - # populate plot - for n, i in enumerate(range(len(df) + 1)[::24]): - if n == len(range(len(df) + 1)[::24]) - 1: - continue - # q-q - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - alpha=quantile_alpha, - color=color, - lw=None, - ec=None, - label=f"{quantile_range[0]:0.0%}-{quantile_range[1]:0.0%}ile" - if n == 0 - else "_nolegend_", - ) - # q-extreme - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24] - + [(df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - label="Range" if n == 0 else "_nolegend_", - ) - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - (df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24] - + [(df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - 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_", + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() + # NOTE - no checks here for missing days, weeks, or months, it should be evident from the plot + + # obtain plotting parameters + minmax_range = kwargs.pop("minmax_range", [0.0001, 0.9999]) + if minmax_range[0] > minmax_range[1]: + 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]: + raise ValueError("quantile_range must be within minmax_range.") + quantile_alpha = kwargs.pop("quantile_alpha", 0.3) + + color = kwargs.pop("color", "slategray") + + # resample to hourly to ensuure hour alignment + # TODO - for now we only resample to hourly, but this could be made more flexible by allowing any subset of period + series = series.resample("h").mean() + + # remove nan/inf + series = series.replace([-np.inf, np.inf], np.nan).dropna() + + # Remove outliers + series = series[ + (series >= series.quantile(minmax_range[0])) + & (series <= series.quantile(minmax_range[1])) + ] + + # group data + if period == "daily": + group = series.groupby(series.index.hour) + target_idx = range(24) + major_ticks = target_idx[::3] + minor_ticks = target_idx + major_ticklabels = [f"{i:02d}:00" for i in major_ticks] + elif period == "weekly": + group = series.groupby([series.index.dayofweek, series.index.hour]) + target_idx = pd.MultiIndex.from_product([range(7), range(24)]) + major_ticks = range(len(target_idx))[::12] + minor_ticks = range(len(target_idx))[::3] + major_ticklabels = [] + for i in target_idx: + if i[1] == 0: + major_ticklabels.append(f"{calendar.day_abbr[i[0]]}") + elif i[1] == 12: + major_ticklabels.append("") + elif period == "monthly": + group = series.groupby([series.index.month, series.index.hour]) + target_idx = pd.MultiIndex.from_product([range(1, 13, 1), range(24)]) + major_ticks = range(len(target_idx))[::12] + minor_ticks = range(len(target_idx))[::6] + major_ticklabels = [] + for i in target_idx: + if i[1] == 0: + major_ticklabels.append(f"{calendar.month_abbr[i[0]]}") + elif i[1] == 12: + major_ticklabels.append("") + else: + raise ValueError("period must be one of 'daily', 'weekly', or 'monthly'") + + samples_per_timestep = group.count().mean() + ax.set_title( + create_title( + kwargs.pop("title", None), + f"Average {period} diurnal profile (≈{samples_per_timestep:0.0f} samples per timestep)", + ) ) - # format axes - ax.set_xlim(0, len(df)) - ax.xaxis.set_major_locator(mticker.FixedLocator(major_ticks)) - ax.xaxis.set_minor_locator(mticker.FixedLocator(minor_ticks)) - ax.set_xticklabels( - major_ticklabels, - minor=False, - ha="left", - ) - if show_legend: - ax.legend( - bbox_to_anchor=(0.5, -0.2), - loc=8, - ncol=6, - borderaxespad=0, + # Get values to plot + minima = group.min() + lower = group.quantile(quantile_range[0]) + median = group.median() + mean = 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], + axis=1, + keys=["minima", "lower", "median", "mean", "upper", "maxima"], + ).reindex(target_idx) + + # populate plot + for n, i in enumerate(range(len(df) + 1)[::24]): + if n == len(range(len(df) + 1)[::24]) - 1: + continue + # q-q + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] + + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], + (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] + + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], + alpha=quantile_alpha, + color=color, + lw=None, + ec=None, + label=f"{quantile_range[0]:0.0%}-{quantile_range[1]:0.0%}ile" + if n == 0 + else "_nolegend_", + ) + # q-extreme + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] + + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], + (df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24] + + [(df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24][0]], + alpha=minmax_alpha, + color=color, + lw=None, + ec=None, + label="Range" if n == 0 else "_nolegend_", + ) + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] + + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], + (df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24] + + [(df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24][0]], + alpha=minmax_alpha, + color=color, + lw=None, + ec=None, + 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_", + ) + + # format axes + ax.set_xlim(0, len(df)) + ax.xaxis.set_major_locator(mticker.FixedLocator(major_ticks)) + ax.xaxis.set_minor_locator(mticker.FixedLocator(minor_ticks)) + ax.set_xticklabels( + major_ticklabels, + minor=False, + ha="left", ) + if show_legend: + ax.legend( + bbox_to_anchor=(0.5, -0.2), + loc=8, + ncol=6, + borderaxespad=0, + ) - ax.set_ylabel(series.name) + ax.set_ylabel(series.name) return ax @@ -235,6 +239,8 @@ def stacked_diurnals( Additional keyword arguments to pass to the matplotlib plotting function. colors (list[str], optional): A list of colors to use for the plots. Defaults to None, which uses the default diurnal color. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Figure: @@ -243,48 +249,51 @@ def stacked_diurnals( if len(datasets) <= 1: raise ValueError("stacked_diurnals requires at least two datasets.") + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + fig, axes = plt.subplots( + len(datasets), 1, figsize=(12, 2 * len(datasets)), sharex=True + ) - fig, axes = plt.subplots( - len(datasets), 1, figsize=(12, 2 * len(datasets)), sharex=True - ) - - for n, (ax, series) in enumerate(zip(axes, datasets)): - if "colors" in kwargs: - kwargs["color"] = kwargs["colors"][n] - diurnal(series, ax=ax, period=period, **kwargs) - ax.set_title(None) - ax.get_legend().remove() - ax.set_ylabel(textwrap.fill(ax.get_ylabel(), 20)) - - handles, labels = axes[-1].get_legend_handles_labels() - new_handles = [] - for handle in handles: - if isinstance(handle, mcollections.PolyCollection): - new_handles.append( - mpatches.Patch( - color="slategray", alpha=handle.get_alpha(), edgecolor=None + for n, (ax, series) in enumerate(zip(axes, datasets)): + if "colors" in kwargs: + kwargs["color"] = kwargs["colors"][n] + diurnal(series, ax=ax, period=period, **kwargs) + ax.set_title(None) + ax.get_legend().remove() + ax.set_ylabel(textwrap.fill(ax.get_ylabel(), 20)) + + handles, labels = axes[-1].get_legend_handles_labels() + new_handles = [] + for handle in handles: + if isinstance(handle, mcollections.PolyCollection): + new_handles.append( + mpatches.Patch( + color="slategray", alpha=handle.get_alpha(), edgecolor=None + ) ) - ) - if isinstance(handle, mlines.Line2D): - new_handles.append( - mlines.Line2D( - (0,), (0,), color="slategray", linestyle=handle.get_linestyle() + if isinstance(handle, mlines.Line2D): + new_handles.append( + mlines.Line2D( + (0,), (0,), color="slategray", linestyle=handle.get_linestyle() + ) ) - ) - plt.legend( - new_handles, labels, bbox_to_anchor=(0.5, -0.12), loc="upper center", ncol=4 - ) + plt.legend( + new_handles, labels, bbox_to_anchor=(0.5, -0.12), loc="upper center", ncol=4 + ) - fig.suptitle( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile" + "s" if len(datasets) > 1 else "", - ), - x=fig.subplotpars.left, - ha="left", - ) + fig.suptitle( + create_title( + kwargs.pop("title", None), + f"Average {period} diurnal profile" + "s" if len(datasets) > 1 else "", + ), + x=fig.subplotpars.left, + ha="left", + ) - plt.tight_layout() + plt.tight_layout() return fig diff --git a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py index a521d00..845fad8 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py @@ -30,17 +30,15 @@ def heatmap( The title of the plot. Defaults to None. mask (List[bool], optional): A list of booleans to mask the data. Defaults to None. - + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: The populated plt.Axes object. """ - validate_timeseries(series) - - if ax is None: - ax = plt.gca() - + + day_time_matrix = ( series.dropna() .to_frame() @@ -66,50 +64,55 @@ def heatmap( extend = kwargs.pop("extend", "neither") title = kwargs.pop("title", series.name) show_colorbar = kwargs.pop("show_colorbar", True) - - # Plot data - pcm = ax.pcolormesh( - x, - y, - z[:-1, :-1], - **kwargs, - ) - - ax.xaxis_date() - if len(set(series.index.year)) > 1: - date_formatter = mdates.DateFormatter("%b %Y") - else: - date_formatter = mdates.DateFormatter("%b") - ax.xaxis.set_major_formatter(date_formatter) - - ax.yaxis_date() - ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) - - ax.tick_params(labelleft=True, labelbottom=True) - plt.setp(ax.get_xticklabels(), ha="left") - - for spine in ["top", "bottom", "left", "right"]: - ax.spines[spine].set_visible(False) - - for i in ax.get_xticks(): - ax.axvline(i, color="w", ls=":", lw=0.5, alpha=0.5) - for i in ax.get_yticks(): - ax.axhline(i, color="w", ls=":", lw=0.5, alpha=0.5) - - if show_colorbar: - cb = plt.colorbar( - pcm, - ax=ax, - orientation="horizontal", - drawedges=False, - fraction=0.05, - aspect=100, - pad=0.075, - extend=extend, - label=series.name, + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + # Plot data + if ax is None: + ax = plt.gca() + + pcm = ax.pcolormesh( + x, + y, + z[:-1, :-1], + **kwargs, ) - cb.outline.set_visible(False) - ax.set_title(title) + ax.xaxis_date() + if len(set(series.index.year)) > 1: + date_formatter = mdates.DateFormatter("%b %Y") + else: + date_formatter = mdates.DateFormatter("%b") + ax.xaxis.set_major_formatter(date_formatter) + + ax.yaxis_date() + ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) + + ax.tick_params(labelleft=True, labelbottom=True) + plt.setp(ax.get_xticklabels(), ha="left") + + for spine in ["top", "bottom", "left", "right"]: + ax.spines[spine].set_visible(False) + + for i in ax.get_xticks(): + ax.axvline(i, color="w", ls=":", lw=0.5, alpha=0.5) + for i in ax.get_yticks(): + ax.axhline(i, color="w", ls=":", lw=0.5, alpha=0.5) + + if show_colorbar: + cb = plt.colorbar( + pcm, + ax=ax, + orientation="horizontal", + drawedges=False, + fraction=0.05, + aspect=100, + pad=0.075, + extend=extend, + label=series.name, + ) + cb.outline.set_visible(False) + + ax.set_title(title) return ax \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/histogram.py b/Python_Engine/Python/src/python_toolkit/plot/histogram.py index cc2ecb7..97a3b83 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/histogram.py +++ b/Python_Engine/Python/src/python_toolkit/plot/histogram.py @@ -30,6 +30,8 @@ def histogram( Whether to show the legend. Defaults to False. **kwargs: Additional keyword arguments to pass to plt.hist. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -39,16 +41,19 @@ def histogram( bins = np.linspace(series.values.min(), series.values.max(), 31) elif len(bins) <= 1: bins = np.linspace(series.values.min(), series.values.max(), 31) + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") - if ax is None: - ax = plt.gca() + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() - show_legend = kwargs.pop("show_legend", True) + show_legend = kwargs.pop("show_legend", True) - ax.hist(series.values, bins=bins, label = series.name, density=False, **kwargs) + ax.hist(series.values, bins=bins, label = series.name, density=False, **kwargs) - if show_legend: - ax.legend() + if show_legend: + ax.legend() return ax @@ -82,6 +87,8 @@ def monthly_proportional_histogram( Whether to show the legend. Defaults to False. **kwargs: Additional keyword arguments to pass to plt.bar. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -89,59 +96,62 @@ def monthly_proportional_histogram( """ validate_timeseries(series) - - if ax is None: - ax = plt.gca() - counts = timeseries_summary_monthly(series, bins, labels, density=True) - - if show_year_in_label: - counts.columns = [ - f"{year}\n{calendar.month_abbr[month]}" for year, month in counts.columns.values - ] - - counts.plot( - ax = ax, - kind = "bar", - stacked = True, - width = kwargs.pop("width", 1), - legend = False, - **kwargs - ) - - ax.set_xlim(-0.5, len(counts) - 0.5) - ax.set_ylim(0, 1) + style_context = kwargs.pop("style_context", "python_toolkit.bhom") - ax.set_xticklabels( - [calendar.month_abbr[int(i._text)] for i in ax.get_xticklabels()], - ha="center", - rotation=0, - ) + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() - for spine in ["top", "right", "left", "bottom"]: - ax.spines[spine].set_visible(False) - ax.yaxis.set_major_formatter(mticker.PercentFormatter(1)) - - if show_legend: - ax.legend( - bbox_to_anchor=(1, 1), - loc="upper left", - borderaxespad=0.0, - frameon=False, - ) + counts = timeseries_summary_monthly(series, bins, labels, density=True) - if show_labels: - for i, c in enumerate(ax.containers): - label_colors = [contrasting_colour(i.get_facecolor()) for i in c.patches] - labels = [ - f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c + if show_year_in_label: + counts.columns = [ + f"{year}\n{calendar.month_abbr[month]}" for year, month in counts.columns.values ] - ax.bar_label( - c, - labels=labels, - label_type="center", - color=label_colors[i], - fontsize="x-small", + + counts.plot( + ax = ax, + kind = "bar", + stacked = True, + width = kwargs.pop("width", 1), + legend = False, + **kwargs ) + ax.set_xlim(-0.5, len(counts) - 0.5) + ax.set_ylim(0, 1) + + ax.set_xticklabels( + [calendar.month_abbr[int(i._text)] for i in ax.get_xticklabels()], + ha="center", + rotation=0, + ) + + for spine in ["top", "right", "left", "bottom"]: + ax.spines[spine].set_visible(False) + ax.yaxis.set_major_formatter(mticker.PercentFormatter(1)) + + if show_legend: + ax.legend( + bbox_to_anchor=(1, 1), + loc="upper left", + borderaxespad=0.0, + frameon=False, + ) + + if show_labels: + for i, c in enumerate(ax.containers): + label_colors = [contrasting_colour(i.get_facecolor()) for i in c.patches] + labels = [ + f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c + ] + ax.bar_label( + c, + labels=labels, + label_type="center", + color=label_colors[i], + fontsize="x-small", + ) + return ax \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py index 0e205c2..8861ffa 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py @@ -28,6 +28,7 @@ def spatial_heatmap( highlight_pts: dict[str, tuple[int]] = None, show_legend_title: bool = True, clabels: bool = False, + style_context:str = "python_toolkit.bhom", ) -> Figure: """Plot a spatial map of a variable using a triangulation and associated values. @@ -67,6 +68,8 @@ def spatial_heatmap( A convenient flag to hide the legend and title. clabels (bool, optional): A flag to show contour labels. Defaults to False. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: Figure: A matplotlib Figure object. @@ -93,77 +96,78 @@ def spatial_heatmap( min(i.y.min() for i in triangulations), max(i.y.max() for i in triangulations), ] + + with plt.style.context(style_context): + fig, ax = plt.subplots(1, 1, figsize=(8, 8)) - fig, ax = plt.subplots(1, 1, figsize=(8, 8)) + ax.set_aspect("equal") + ax.axis("off") - ax.set_aspect("equal") - ax.axis("off") + ax.set_xlim(xlims) + ax.set_ylim(ylims) - ax.set_xlim(xlims) - ax.set_ylim(ylims) - - tcls = [] - for tri, zs in list(zip(*[triangulations, values])): - tcf = ax.tricontourf( - tri, zs, extend=extend, cmap=cmap, levels=levels, norm=norm - ) - # add contour lines - if contours is not None: - if not ( - all(i < np.amin(zs) for i in contours) - or all(i > np.amax(zs) for i in contours) - ): - if contour_widths is None: - contour_widths = [1.5] * len(contours) - if contour_colors is None: - contour_colors = ["k"] * len(contours) - if len(contour_colors) != len(contours) != len(contour_widths): - raise ValueError("contour vars must be same length") - tcl = ax.tricontour( - tri, - zs, - levels=contours, - colors=contour_colors, - linewidths=contour_widths, - ) - if clabels: - ax.clabel(tcl, inline=1, fontsize="small", colors=contour_colors) - tcls.append(tcl) - - if highlight_pts is not None: - if len(triangulations) > 1: - raise ValueError( - "Point highlighting is only possible for 1-length triangulations." - ) - pt_size = (xlims[1] - xlims[0]) / 5 - for k, v in highlight_pts.items(): - ax.scatter( - triangulations[0].x[v], triangulations[0].y[v], s=pt_size, c="red" - ) - ax.text( - triangulations[0].x[v] + (pt_size / 10), - triangulations[0].y[v], - k, - ha="left", - va="center", + tcls = [] + for tri, zs in list(zip(*[triangulations, values])): + tcf = ax.tricontourf( + tri, zs, extend=extend, cmap=cmap, levels=levels, norm=norm ) + # add contour lines + if contours is not None: + if not ( + all(i < np.amin(zs) for i in contours) + or all(i > np.amax(zs) for i in contours) + ): + if contour_widths is None: + contour_widths = [1.5] * len(contours) + if contour_colors is None: + contour_colors = ["k"] * len(contours) + if len(contour_colors) != len(contours) != len(contour_widths): + raise ValueError("contour vars must be same length") + tcl = ax.tricontour( + tri, + zs, + levels=contours, + colors=contour_colors, + linewidths=contour_widths, + ) + if clabels: + ax.clabel(tcl, inline=1, fontsize="small", colors=contour_colors) + tcls.append(tcl) + + if highlight_pts is not None: + if len(triangulations) > 1: + raise ValueError( + "Point highlighting is only possible for 1-length triangulations." + ) + pt_size = (xlims[1] - xlims[0]) / 5 + for k, v in highlight_pts.items(): + ax.scatter( + triangulations[0].x[v], triangulations[0].y[v], s=pt_size, c="red" + ) + ax.text( + triangulations[0].x[v] + (pt_size / 10), + triangulations[0].y[v], + k, + ha="left", + va="center", + ) - if show_legend_title: - # Plot colorbar - divider = make_axes_locatable(ax) - cax = divider.append_axes("right", size="5%", pad=0.1, aspect=20) + if show_legend_title: + # Plot colorbar + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.1, aspect=20) - cbar = plt.colorbar( - tcf, cax=cax # , format=mticker.StrMethodFormatter("{x:04.1f}") - ) - cbar.outline.set_visible(False) - cbar.set_label(colorbar_label) + cbar = plt.colorbar( + tcf, cax=cax # , format=mticker.StrMethodFormatter("{x:04.1f}") + ) + cbar.outline.set_visible(False) + cbar.set_label(colorbar_label) - for tcl in tcls: - cbar.add_lines(tcl) + for tcl in tcls: + cbar.add_lines(tcl) - ax.set_title(title, ha="left", va="bottom", x=0) + ax.set_title(title, ha="left", va="bottom", x=0) - plt.tight_layout() + plt.tight_layout() return fig diff --git a/Python_Engine/Python/src/python_toolkit/plot/timeseries.py b/Python_Engine/Python/src/python_toolkit/plot/timeseries.py index c0cedb0..3557778 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/timeseries.py +++ b/Python_Engine/Python/src/python_toolkit/plot/timeseries.py @@ -29,6 +29,8 @@ def timeseries( Set the y-limits. Defaults to None. **kwargs: Additional keyword arguments to pass to the plt.plot() function. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -36,22 +38,25 @@ def timeseries( """ validate_timeseries(series) - - if ax is None: - ax = plt.gca() - - ax.plot(series.index, series.values, **kwargs) ## example plot here - - # TODO - add cmap arg to color line by y value - - # https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html - - if xlims is None: - ax.set_xlim(series.index.min(), series.index.max()) - else: - ax.set_xlim(xlims) - if ylims is None: - ax.set_ylim(ax.get_ylim()) - else: - ax.set_ylim(ylims) + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() + + ax.plot(series.index, series.values, **kwargs) ## example plot here + + # TODO - add cmap arg to color line by y value - + # https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html + + if xlims is None: + ax.set_xlim(series.index.min(), series.index.max()) + else: + ax.set_xlim(xlims) + if ylims is None: + ax.set_ylim(ax.get_ylim()) + else: + ax.set_ylim(ylims) return ax \ 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 new file mode 100644 index 0000000..5586c8d --- /dev/null +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import matplotlib.pyplot as plt + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets import ( + Button, + CalendarWidget, + CheckboxSelection, + CmapSelector, + ColourPicker, + DropDownSelection, + FigureContainer, + Label, + MultiBoxSelection, + PackingOptions, + PathSelector, + RadioSelection, + ScrollableListBox, + ValidatedEntryBox, +) +from python_toolkit.bhom_tkinter.windows import ( + DirectoryFileSelector, + LandingPage, + ProcessingWindow, + WarningBox, +) + + +def _demo_callback(*_args, **_kwargs): + return None + + +def _show_brief(window: BHoMBaseWindow, milliseconds: int = 900) -> None: + window.after(milliseconds, window.destroy_root) + window.mainloop() + + +def run_widget_gallery(auto_close_ms: int | None = None) -> None: + root = BHoMBaseWindow( + title="BHoM Tkinter Widget Gallery", + min_width=1200, + min_height=900, + show_submit=False, + show_close=True, + close_text="Close Gallery", + ) + + parent = root.content_frame + alignments = ["left", "center", "right"] + + Label( + parent, + text="Label widget (left)", + item_title="Label", + helper_text="Basic BHoM Label wrapper", + alignment=alignments[0], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + Button( + parent, + text="Button widget", + command=_demo_callback, + item_title="Button", + helper_text="Simple action button", + alignment=alignments[1], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + ValidatedEntryBox( + parent, + item_title="ValidatedEntryBox", + helper_text="Integer 0..100", + value_type=int, + min_value=0, + max_value=100, + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + DropDownSelection( + parent, + item_title="DropDownSelection", + helper_text="Pick an option", + options=["A", "B", "C"], + default="B", + alignment=alignments[0], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + RadioSelection( + parent, + item_title="RadioSelection", + helper_text="Single select", + fields=["Red", "Green", "Blue"], + orient="horizontal", + max_per_line=3, + alignment=alignments[1], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + CheckboxSelection( + parent, + item_title="CheckboxSelection", + helper_text="Multi select", + fields=["One", "Two", "Three", "Four"], + defaults=["Two"], + max_per_line=4, + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + MultiBoxSelection( + parent, + item_title="MultiBoxSelection", + helper_text="Legacy alias variant", + fields=["North", "South", "East", "West"], + orient="horizontal", + max_per_line=2, + alignment=alignments[0], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + ScrollableListBox( + parent, + item_title="ScrollableListBox", + helper_text="List with selection controls", + items=[f"Item {i}" for i in range(1, 13)], + height=5, + show_selection_controls=True, + alignment=alignments[1], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + PathSelector( + parent, + item_title="PathSelector", + helper_text="Browse file path", + button_text="Browse", + mode="file", + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + ColourPicker( + parent, + item_title="ColourPicker", + helper_text="Pick a colour", + default_colour="#4A90E2", + alignment=alignments[0], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + CalendarWidget( + parent, + item_title="CalendarWidget", + helper_text="Date picker", + def_year=date.today().year, + def_month=date.today().month, + def_day=max(1, min(date.today().day, 28)), + show_year_selector=True, + alignment=alignments[1], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + figure_container = FigureContainer( + parent, + item_title="FigureContainer", + helper_text="Embedded matplotlib figure", + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ) + figure_container.build() + figure, axis = plt.subplots(figsize=(3.2, 1.2)) + axis.plot([0, 1, 2, 3], [1, 3, 2, 4]) + axis.set_title("Sample") + figure_container.embed_figure(figure) + + CmapSelector( + parent, + item_title="CmapSelector", + helper_text="Colormap preview", + cmap_set="continuous", + alignment=alignments[0], + build_options=PackingOptions(fill="x", pady=6), + ).build() + + if auto_close_ms is not None: + root.after(auto_close_ms, root.destroy_root) + root.mainloop() + + +def run_predefined_windows_demo() -> None: + landing = LandingPage( + title="LandingPage demo", + header="Landing page", + message="Smoke-checking predefined windows.", + sub_title="This window auto-closes.", + show_submit=False, + ) + landing.add_custom_button("No-op Action", _demo_callback) + _show_brief(landing) + + warning = WarningBox( + title="WarningBox demo", + warnings=["Sample warning message"], + errors=["Sample error message"], + infos=["Sample informational message"], + ) + _show_brief(warning) + + processing = ProcessingWindow(title="ProcessingWindow demo", message="Working...") + processing.start() + processing.after(900, processing.stop) + processing.mainloop() + + selector = DirectoryFileSelector( + directory=Path(__file__).resolve().parent, + file_types=[".py"], + selection_label="test files", + ) + _show_brief(selector) + + +if __name__ == "__main__": + run_widget_gallery() + run_predefined_windows_demo() + + +def test_widget_validation(): + """Ensure widget validation logic returns expected results.""" + # Integer field valid range + root = BHoMBaseWindow(title="Validation test") + parent = root.content_frame + + int_box = ValidatedEntryBox( + parent, + value_type=int, + min_value=0, + max_value=100, + required=True, + ) + int_box.build() + + int_box.set(50) + ok, msg, sev = int_box.validate() + assert ok is True and msg is None + + int_box.set(150) + ok, msg, sev = int_box.validate() + assert ok is False and sev == "error" + + # Required field empty + int_box.set("") + ok, msg, sev = int_box.validate() + assert ok is False and msg == "Required" + + # Non-required empty is valid + opt_box = ValidatedEntryBox(parent, value_type=str, required=False) + opt_box.build() + opt_box.set("") + ok, msg, sev = opt_box.validate() + assert ok is True + + # Custom validator overriding + def fail_validator(_v): + return False, "Custom failed" + + custom_box = ValidatedEntryBox(parent, value_type=str, required=True, custom_validator=fail_validator) + custom_box.build() + custom_box.set("abc") + ok, msg, sev = custom_box.validate() + assert ok is False and msg == "Custom failed" + + +def test_rebuild(): + """Verify that rebuild() re-places widgets and reflects mutations to self.widgets.""" + root = BHoMBaseWindow(title="Rebuild test") + parent = root.content_frame + + label_a = Label(parent, text="Widget A", build_options=PackingOptions(fill="x")) + label_b = Label(parent, text="Widget B", build_options=PackingOptions(fill="x")) + + root.widgets.extend([label_a, label_b]) + root.build() + + # Both widgets should be managed after build + managed_after_build = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_build) == 2 + + # Add a third widget and rebuild + label_c = Label(parent, text="Widget C", build_options=PackingOptions(fill="x")) + root.widgets.append(label_c) + root.rebuild() + + managed_after_rebuild = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_rebuild) == 3 + + # Remove a widget and rebuild — it should no longer be managed + root.widgets.remove(label_a) + root.rebuild() + + managed_after_remove = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_remove) == 2 + assert label_a not in [w for w in root.widgets] + + root.destroy_root() + diff --git a/Python_Engine/Python/tests/test_styling.py b/Python_Engine/Python/tests/test_styling.py new file mode 100644 index 0000000..a6b8e49 --- /dev/null +++ b/Python_Engine/Python/tests/test_styling.py @@ -0,0 +1,106 @@ +"""Typography style guide and verification sample. + +Run directly: + python tests/test_styling.py + +This opens a guide window with one label sample per typography style so it can +be used both as a visual reference and as a quick regression check. +""" + +from __future__ import annotations + +import os +from tkinter import ttk +from tkinter import font as tkfont + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.label import Label as BHoMLabel + + +def _style_rows() -> list[tuple[str, str]]: + return [ + ("Display.TLabel", "Display — 0123456789"), + ("LargeTitle.TLabel", "LargeTitle sample"), + ("Title.TLabel", "Title sample"), + ("Headline.TLabel", "Headline sample"), + ("Subtitle.TLabel", "Subtitle sample"), + ("Heading.TLabel", "Heading sample"), + ("Body.TLabel", "Body sample"), + ("TLabel", "TLabel sample"), + ("Caption.TLabel", "Caption sample"), + ("Small.TLabel", "Small sample"), + ("Success.TLabel", "Success sample"), + ("Warning.TLabel", "Warning sample"), + ("Error.TLabel", "Error sample"), + ("Info.TLabel", "Info sample"), + ] + + +def run_styling_guide( + theme_mode: str = "dark", + print_metrics: bool = True, + auto_close_ms: int | None = None, +) -> None: + os.environ.setdefault("BHOM_TK_DEBUG_STYLES", "1") + + window = BHoMBaseWindow( + title="Typography Style Guide", + theme_mode=theme_mode, + show_submit=False, + show_close=True, + top_most=False, + min_width=780, + min_height=700, + ) + + style = ttk.Style() + rows = _style_rows() + + BHoMLabel( + window.content_frame, + text="Typography Guide (BHoM Label wrapper)", + style="Title.TLabel", + ).pack(anchor="w", pady=(2, 10)) + + BHoMLabel( + window.content_frame, + text=f"Theme: {style.theme_use()}", + style="Caption.TLabel", + ).pack(anchor="w", pady=(0, 12)) + + guide_rows: list[tuple[str, BHoMLabel]] = [] + for style_name, sample_text in rows: + row = ttk.Frame(window.content_frame) + row.pack(fill="x", anchor="w", pady=2) + + style_tag = BHoMLabel(row, text=style_name, style="Caption.TLabel", width=22) + style_tag.pack(side="left", anchor="w", padx=(0, 10)) + + sample = BHoMLabel(row, text=sample_text, style=style_name) + sample.pack(side="left", anchor="w") + guide_rows.append((style_name, sample)) + + window.update_idletasks() + + if print_metrics: + print("theme", style.theme_use()) + print("--- typography styles ---") + for style_name, sample in guide_rows: + font_lookup = style.lookup(style_name, "font") or style.lookup("TLabel", "font") + parsed = tkfont.Font(root=window, font=font_lookup) + print( + style_name, + "lookup=", font_lookup, + "actual-size=", parsed.actual("size"), + "linespace=", parsed.metrics("linespace"), + "rendered-height=", sample.label.winfo_reqheight(), + ) + + if auto_close_ms is not None and auto_close_ms > 0: + window.after(auto_close_ms, window.destroy) + + window.mainloop() + + +if __name__ == "__main__": + run_styling_guide() diff --git a/Python_Engine/Python_Engine.csproj b/Python_Engine/Python_Engine.csproj index 6866e44..a39b436 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.0.0.0 + 9.1.0.0 ..\Build\ diff --git a/Python_Engine/Query/Directory.cs b/Python_Engine/Query/Directory.cs index 00e486e..e81943e 100644 --- a/Python_Engine/Query/Directory.cs +++ b/Python_Engine/Query/Directory.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -79,3 +79,4 @@ public static string DirectoryBaseEnvironment(this PythonVersion version) + diff --git a/Python_Engine/Query/EmbeddableURL.cs b/Python_Engine/Query/EmbeddableURL.cs index f96feda..20ebdd2 100644 --- a/Python_Engine/Query/EmbeddableURL.cs +++ b/Python_Engine/Query/EmbeddableURL.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -83,6 +83,7 @@ public static string EmbeddableURL(this PythonVersion version) { PythonVersion.v3_11_2, "https://www.python.org/ftp/python/3.11.2/python-3.11.2-amd64.exe" }, { PythonVersion.v3_11_3, "https://www.python.org/ftp/python/3.11.3/python-3.11.3-amd64.exe" },*/ { PythonVersion.v3_11, "https://www.python.org/ftp/python/3.11.4/python-3.11.4-amd64.exe" }, + { PythonVersion.v3_12, "https://www.python.org/ftp/python/3.12.3/python-3.12.3-amd64.exe" } }; return versions[version]; @@ -92,3 +93,4 @@ public static string EmbeddableURL(this PythonVersion version) + diff --git a/Python_Engine/Query/IsValidEnvironmentName.cs b/Python_Engine/Query/IsValidEnvironmentName.cs index fc846d7..16625a6 100644 --- a/Python_Engine/Query/IsValidEnvironmentName.cs +++ b/Python_Engine/Query/IsValidEnvironmentName.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -53,3 +53,4 @@ public static bool IsValidEnvironmentName(string name) + diff --git a/Python_Engine/Query/ToolkitName.cs b/Python_Engine/Query/ToolkitName.cs index 4b47418..c0088f6 100644 --- a/Python_Engine/Query/ToolkitName.cs +++ b/Python_Engine/Query/ToolkitName.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -40,3 +40,4 @@ public static string ToolkitName() + diff --git a/Python_Engine/Query/Version.cs b/Python_Engine/Query/Version.cs index 965c21f..7e2db22 100644 --- a/Python_Engine/Query/Version.cs +++ b/Python_Engine/Query/Version.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -86,3 +86,4 @@ public static PythonVersion Version(string pythonExecutable) } + diff --git a/Python_Engine/Query/VirtualEnvironment.cs b/Python_Engine/Query/VirtualEnvironment.cs index d9625c1..04edc94 100644 --- a/Python_Engine/Query/VirtualEnvironment.cs +++ b/Python_Engine/Query/VirtualEnvironment.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -98,3 +98,4 @@ public static PythonEnvironment VirtualEnvironment(string envName) + diff --git a/Python_oM/Enums/PythonVersion.cs b/Python_oM/Enums/PythonVersion.cs index 164d03a..5648d98 100644 --- a/Python_oM/Enums/PythonVersion.cs +++ b/Python_oM/Enums/PythonVersion.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -127,7 +127,10 @@ public enum PythonVersion v3_11_3,*/ [Description("3.11")] //3.11.4 v3_11, + [Description("3.12")] + v3_12, } } + diff --git a/Python_oM/PythonEnvironment.cs b/Python_oM/PythonEnvironment.cs index 63e1ddb..7740cb3 100644 --- a/Python_oM/PythonEnvironment.cs +++ b/Python_oM/PythonEnvironment.cs @@ -1,6 +1,6 @@ /* * This file is part of the Buildings and Habitats object Model (BHoM) - * Copyright (c) 2015 - 2025, the respective contributors. All rights reserved. + * 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. @@ -41,3 +41,4 @@ public class PythonEnvironment : IObject + diff --git a/Python_oM/Python_oM.csproj b/Python_oM/Python_oM.csproj index 11baa1d..2f9a012 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.0.0.0 + 9.1.0.0 ..\Build\