diff --git a/.github/workflows/check_pypi_build.yml b/.github/workflows/check_pypi_build.yml deleted file mode 100644 index 69971dcb..00000000 --- a/.github/workflows/check_pypi_build.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Check PyPI Build -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v1 - with: - python-version: '3.9' - architecture: x64 - - name: Install Dependencies - run: | - python -m pip install rdkit-pypi - python -m pip install networkx==2.1 - python -m pip install pycodestyle autopep8 - python -m pip install -r requirements.txt - python -m pip install -e . - python -m pip install build - - - name: Check Errors - run: | - python -m build \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3c0da652 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: Run Tests +on: + schedule: + - cron: '0 8 * * 1-5' + push: + branches: [ master ] + pull_request: + branches: [ master ] + + workflow_dispatch: + +jobs: + code-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v1 + with: + python-version: '3.9' + architecture: x64 + - name: Install Dependencies + run: | + python -m pip install rdkit-pypi + python -m pip install networkx==2.1 + python -m pip install pycodestyle autopep8 + python -m pip install -r requirements.txt + python -m pip install -e . + python -m pip install sphinx sphinx-rtd-theme m2r mistune==0.8.4 + + - name: Check Errors + run: | + pycodestyle --statistics --count --max-line-length=150 --show-source --exclude=interfaces/UI/libraries . + + build: + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + os: [ubuntu-latest, windows-latest, macos-latest] + + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash -el {0} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} Subtest + steps: + - uses: actions/checkout@v3 + - uses: conda-incubator/setup-miniconda@v2 + with: + auto-update-conda: true + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install openpyxl + python -m pip install -r requirements.txt + python -m pip install -e . + python -m pip install coverage + - name: Run Tests + run: | + coverage run --source=. --omit=interfaces/*,aimsim/__main__.py,aimsim/tasks/__init__.py,aimsim/ops/__init__.py,aimsim/chemical_datastructures/__init__.py,aimsim/utils/__init__.py,setup.py,tests/*,aimsim/__init__.py,aimsim/utils/plotting_scripts.py -m unittest discover + - name: Show Coverage + run: | + coverage report -m + + pypi: + name: Build and publish Python 🐍 distributions 📦 to PyPI + runs-on: ubuntu-latest + # only run if the tests pass + needs: build + # run only on pushes to master on AIMSim + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'VlachosGroup/AIMSim'}} + steps: + - uses: actions/checkout@master + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + verbose: true \ No newline at end of file diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml deleted file mode 100644 index 0ca0a6e4..00000000 --- a/.github/workflows/format_code.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Ensure Code Formatting -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v1 - with: - python-version: '3.9' - architecture: x64 - - name: Install Dependencies - run: | - python -m pip install rdkit-pypi - python -m pip install networkx==2.1 - python -m pip install pycodestyle autopep8 - python -m pip install -r requirements.txt - python -m pip install -e . - python -m pip install sphinx sphinx-rtd-theme m2r mistune==0.8.4 - - - name: Check Errors - run: | - pycodestyle --statistics --count --max-line-length=150 --show-source . \ No newline at end of file diff --git a/.github/workflows/mordred_check.yml b/.github/workflows/mordred_check.yml deleted file mode 100644 index 50ad6eef..00000000 --- a/.github/workflows/mordred_check.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Test mordred Install -on: - schedule: - - cron: '0 8 * * 1-5' - push: - branches: [ master ] - pull_request: - branches: [ master ] - - workflow_dispatch: - -jobs: - optional: - strategy: - fail-fast: false - matrix: - python-version: ['3.8'] - os: [ubuntu-latest, windows-latest, macos-latest] - - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash -el {0} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} Subtest - steps: - - uses: actions/checkout@v3 - - uses: conda-incubator/setup-miniconda@v2 - with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - - name: Attempt mordred Install and Tests - run: | - python -m pip install -e .[mordred] - python -m unittest -v \ No newline at end of file diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml deleted file mode 100644 index ece01a18..00000000 --- a/.github/workflows/run_tests.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Run Tests -on: - schedule: - - cron: '0 8 * * 1-5' - push: - branches: [ master ] - pull_request: - branches: [ master ] - - workflow_dispatch: - -jobs: - build: - strategy: - fail-fast: false - matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - os: [ubuntu-latest, windows-latest, macos-latest] - - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash -el {0} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} Subtest - steps: - - uses: actions/checkout@v3 - - uses: conda-incubator/setup-miniconda@v2 - with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install openpyxl - python -m pip install -r requirements.txt - python -m pip install -e . - python -m pip install coverage - - name: Run Tests - run: | - coverage run --source=. --omit=interfaces/*,aimsim/__main__.py,aimsim/tasks/__init__.py,aimsim/ops/__init__.py,aimsim/chemical_datastructures/__init__.py,aimsim/utils/__init__.py,setup.py,tests/*,aimsim/__init__.py,aimsim/utils/plotting_scripts.py -m unittest discover - - name: Show Coverage - run: | - coverage report -m diff --git a/.gitignore b/.gitignore index 84c3c381..775b4b06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # vscode configuration files -*.json +.vscode # intellij configuration files .idea/* diff --git a/AIMSim-demo.ipynb b/AIMSim-demo.ipynb index 0a08e044..80d4c212 100644 --- a/AIMSim-demo.ipynb +++ b/AIMSim-demo.ipynb @@ -8,6 +8,11 @@ "This notebook demonstrates the key uses of _AIMSim_ as a graphical user interface, command line tool, and scripting utility. For detailed explanations and to view the source code for _AIMSim_, visit our [documentation page](https://vlachosgroup.github.io/AIMSim/)." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, { "cell_type": "markdown", "metadata": {}, @@ -15,9 +20,9 @@ "## Installing _AIMSim_\n", "For users with Python already in use on their devices, it is _highly_ recommended to first create a virtual environment before installing _AIMSim_. This package has a large number of dependencies with only a handful of versions supported, so conflicts are likely unless a virtual environment is used.\n", "\n", - "For new Python users, the authors recommended installing anaconda navigator to manage dependencies for _AIMSim_ and make installation easier overall. Once anaconda navigator is ready, create a new environment with Python 3.7, open a terminal or command prompt in this environment, and follow the instructions below. \n", + "For new Python users, the authors recommended installing anaconda navigator to manage dependencies for _AIMSim_ and make installation easier overall. Once anaconda navigator is ready, create a new environment with Python 3.8 or newer, open a terminal or command prompt in this environment, and follow the instructions below. \n", "\n", - "We reccomend installing _AIMSim_ using the commands shown below (omit exclamation points and the %%capture, unless you are running in a Jupyter notebook):" + "We recommend installing _AIMSim_ using the commands shown below (omit exclamation points and the %%capture, unless you are running in a Jupyter notebook):" ] }, { @@ -195,6 +200,9 @@ "global_random_seed (int / str): # int or 'random'\n", " \n", "tasks:\n", + " get_extended_similarity_indices:\n", + " # Extended Similarity Indices has no options\n", + "\n", " compare_target_molecule:\n", " target_molecule_smiles (str):\n", " draw_molecule (bool): # If true, strucures of target, most and least similar molecules are displayed\n", diff --git a/README.md b/README.md index b718bc97..f33137a4 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,17 @@ When proposing a novel reaction it is essential for the practicing chemist to ev Many of the substrates appear similar to one another and thereby redundant, but in reality the core sulfone moiety and the use of the same coupling partner when evaluating functional group tolerance accounts for this apparent shortcoming. Also of note is the region of high similarity along the diagonal where the substrates often differ by a single halide heteratom or substitution pattern. ## Installing AIMSim -`AIMSim` can be installed with a single command: +`AIMSim` can be installed with a single command using Python's package manager `pip`: `pip install aimsim` -This command also installs the required dependencies. It is recommended to install `AIMSim` in a separate virtual environment. +This command also installs the required dependencies. +It is recommended to install `AIMSim` in a virtual environment with [`conda`](https://docs.conda.io/en/latest/) or Python's [`venv`](https://docs.python.org/3/library/venv.html). -_Optional:_ Previous versions of AIMSim provided direct support for the descriptors provided in the `mordred` package. Unforunately, `mordred` is no longer recieving updates and causes significant depdendency conflicts. Because of this, it is an _optional_ add-on to `AIMSim` that is only compatible with Python 3.8. To install with `mordred` support, use `pip install 'aimsim[mordred]'` (note the single quotes, necessary in `zsh`). - -Unit tests from previous versions of `AIMSim` have been kept but are not actively maintained due to the limitations above. Use `mordred` at your own risk. +### Note for mordred-descriptor +AIMSim v1 provided direct support for the descriptors provided in the `mordred` package but unfortunately the original `mordred` is now abandonware. +The **unofficial** [`mordredcommunity`](https://github.com/JacksonBurns/mordred-community) is now used in version 2.1 and newer to deliver the same features but with support for modern Python. ## Running AIMSim -`AIMSim` is compatible with Python 3.7 to 3.9. +`AIMSim` is compatible with Python 3.8 to 3.11. Start `AIMSim` with a graphical user interface: `aimsim` @@ -117,6 +118,10 @@ Developer: Himaghna Bhattacharjee, Vlachos Research Lab. ([LinkedIn](www.linkedi Developer: Jackson Burns, Don Watson Lab. ([Personal Site](https://www.jacksonwarnerburns.com/)) +## `AIMSim` in the Literature + - [Applications of Artificial Intelligence and Machine Learning Algorithms to Crystallization](https://doi.org/10.1021/acs.chemrev.2c00141) + - [Recent Advances in Machine-Learning-Based Chemoinformatics: A Comprehensive Review](https://doi.org/10.3390/ijms241411488) + ## Developer Notes Issues and Pull Requests are welcomed! To propose an addition to `AIMSim` open an issue and the developers will tag it as an _enhancement_ and start discussion. @@ -141,12 +146,12 @@ Be sure to bump the version in `__init__.py`. ## Citation If you use this code for scientific publications, please cite the following paper. -Bhattacharjee, H.҂; Burns, J.҂; Vlachos, D.G. (2021): AIMSim: An Accessible Cheminformatics Platform for Similarity Operations on Chemicals Datasets. ChemRxiv. Preprint. https://doi.org/10.26434/chemrxiv-2022-nw6f5 +Himaghna Bhattacharjee, Jackson Burns, Dionisios G. Vlachos, AIMSim: An accessible cheminformatics platform for similarity operations on chemicals datasets, Computer Physics Communications, Volume 283, 2023, 108579, ISSN 0010-4655, https://doi.org/10.1016/j.cpc.2022.108579. ## License This code is made available under the terms of the _MIT Open License_: -Copyright (c) 2020 Himaghna Bhattacharjee & Jackson Burns +Copyright (c) 2020-2027 Himaghna Bhattacharjee & Jackson Burns Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/aimsim/ops/similarity_measures.py b/aimsim/ops/similarity_measures.py index 4ee91ec2..44577da9 100644 --- a/aimsim/ops/similarity_measures.py +++ b/aimsim/ops/similarity_measures.py @@ -2,7 +2,6 @@ from functools import lru_cache import numpy as np from rdkit import DataStructs -from scipy.spatial.distance import cosine as scipy_cosine from aimsim.ops import Descriptor from aimsim.exceptions import InvalidConfigurationError @@ -10,238 +9,78 @@ SMALL_NUMBER = 1e-10 -class SimilarityMeasure: +# to aggregate all of the different similarity metrics, we use a `register` decorator +# and metaclass to keep track of all of them. +# +# When a new similarity measure is added, it should be decorated with all of the supported +# aliases, the type (if not discrete) and the formula required to convert it to a distance +# metric (i.e. 1 is furthest and o is closest). +# +# The metaclass will then automatically call the method properly when SimilarityMeasure +# is called, as well as export the method name to the global variables imported elsewhere. +registry = {} + +ALL_METRICS = [] +BINARY_METRICS = [] +UNIQUE_METRICS = [] +ALIAS_TO_FUNC = {} +ALIAS_TO_DISTANCE = {} +ALIAS_TO_TYPE = {} + + +def register(*args, type="discrete", to_distance=None): + def wrapper(func): + # first arg should be 'preferred' alias + func._register = (func, type, to_distance, *args) + return func + + return wrapper + + +class RegisteringType(type): + def __init__(cls, name, bases, attrs): + for key, val in attrs.items(): + registry_data = getattr(val, "_register", None) + if registry_data is None: + continue + + # iterate through all the accepted names for each metric + for alias in registry_data[3:]: + # save the alias to func mapping + ALIAS_TO_FUNC[alias] = registry_data[0] + # save the metric types + ALIAS_TO_TYPE[alias] = registry_data[1] + # save the distance conversion functions, where provided + if registry_data[2] is not None: + ALIAS_TO_DISTANCE[alias] = registry_data[2] + + # save only the first metric to the unique metrics list + UNIQUE_METRICS.append(registry_data[3]) + + # add all of the aliases to the metric list + ALL_METRICS.extend(registry_data[3:]) + + # add binary metrics to the appropriate list + if registry_data[1] == "discrete": + BINARY_METRICS.extend(registry_data[3:]) + + +class SimilarityMeasure(object, metaclass=RegisteringType): def __init__(self, metric): - if metric.lower() in ["l0_similarity"]: - self.metric = "l0_similarity" - self.type_ = "continuous" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in [ - "l1_similarity", - "manhattan_similarity", - "taxicab_similarity", - "city_block_similarity", - "snake_similarity", - ]: - self.metric = "l1_similarity" - self.type_ = "continuous" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["l2_similarity", "euclidean_similarity"]: - self.metric = "l2_similarity" - self.type_ = "continuous" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["cosine", "driver-kroeber", "ochiai"]: - self.metric = "cosine" - self.type_ = "discrete" - # angular distance - self.to_distance = lambda x: np.arccos(x) / np.pi - - elif metric.lower() in ["dice", "sorenson", "gleason"]: - self.metric = "dice" - self.type_ = "discrete" - # convert to jaccard for distance - self.to_distance = lambda x: 1 - x / (2 - x) - - elif metric.lower() in ["dice_2"]: - self.metric = "dice_2" - self.type_ = "discrete" - - elif metric.lower() in ["dice_3"]: - self.metric = "dice_3" - self.type_ = "discrete" - - elif metric.lower() in ["tanimoto", "jaccard-tanimoto"]: - self.metric = "tanimoto" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["simple_matching", "sokal-michener", "rand"]: - self.metric = "simple_matching" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["rogers-tanimoto"]: - self.metric = "rogers_tanimoto" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["russel-rao"]: - self.metric = "russel_rao" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["forbes"]: - self.metric = "forbes" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["simpson"]: - self.metric = "simpson" - self.type_ = "discrete" - - elif metric.lower() in ["braun-blanquet"]: - self.metric = "braun_blanquet" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["baroni-urbani-buser"]: - self.metric = "baroni_urbani_buser" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["kulczynski"]: - self.metric = "kulczynski" - self.type_ = "discrete" - - elif metric.lower() in ["sokal-sneath", "sokal-sneath_1"]: - self.metric = "sokal_sneath" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in [ - "sokal-sneath_2", - "sokal-sneath-2", - "symmetric_sokal_sneath", - "symmetric-sokal-sneath", - ]: - self.metric = "symmetric_sokal_sneath" - self.type_ = "discrete" - - elif metric.lower() in ["sokal-sneath-3", "sokal-sneath_3"]: - self.metric = "sokal_sneath_3" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["sokal-sneath-4", "sokal-sneath_4"]: - self.metric = "sokal_sneath_4" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["jaccard"]: - self.metric = "jaccard" - self.type_ = "discrete" - - elif metric.lower() in ["faith"]: - self.metric = "faith" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["michael"]: - self.metric = "michael" - self.type_ = "discrete" - - elif metric.lower() in ["mountford"]: - self.metric = "mountford" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["rogot-goldberg"]: - self.metric = "rogot_goldberg" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["hawkins-dotson"]: - self.metric = "hawkins_dotson" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["maxwell-pilliner"]: - self.metric = "maxwell_pilliner" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["harris-lahey"]: - self.metric = "harris_lahey" - self.type_ = "discrete" - - elif metric.lower() in ["consonni-todeschini-1", "consonni-todeschini_1"]: - self.metric = "consonni_todeschini_1" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["consonni-todeschini-2", "consonni-todeschini_2"]: - self.metric = "consonni_todeschini_2" - self.type_ = "discrete" - - elif metric.lower() in ["consonni-todeschini-3", "consonni-todeschini_3"]: - self.metric = "consonni_todeschini_3" - self.type_ = "discrete" - - elif metric.lower() in ["consonni-todeschini-4", "consonni-todeschini_4"]: - self.metric = "consonni_todeschini_4" - self.type_ = "discrete" - - elif metric.lower() in ["consonni-todeschini-5", "consonni-todeschini_5"]: - self.metric = "consonni_todeschini_5" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["austin-colwell"]: - self.metric = "austin_colwell" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["yule-1", "yule_1"]: - self.metric = "yule_1" - self.type_ = "discrete" - - elif metric.lower() in ["yule-2", "yule_2"]: - self.metric = "yule_2" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["fossum", "holiday-fossum", "holiday_fossum"]: - self.metric = "fossum" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["dennis", "holiday-dennis", "holiday_dennis"]: - self.metric = "dennis" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["cole-1", "cole_1"]: - self.metric = "cole_1" - self.type_ = "discrete" - - elif metric.lower() in ["cole-2", "cole_2"]: - self.metric = "cole_2" - self.type_ = "discrete" - - elif metric.lower() in ["dispersion", "choi"]: - self.metric = "dispersion" - self.type_ = "discrete" - - elif metric.lower() in ["goodman-kruskal", "goodman_kruskal"]: - self.metric = "goodman_kruskal" - self.type_ = "discrete" - - elif metric.lower() in ["pearson-heron", "pearson_heron"]: - self.metric = "pearson_heron" - self.type_ = "discrete" - self.to_distance = lambda x: 1 - x - - elif metric.lower() in ["sorgenfrei"]: - self.metric = "sorgenfrei" - self.type_ = "discrete" - - elif metric.lower() in ["cohen"]: - self.metric = "cohen" - self.type_ = "discrete" - - elif metric.lower() in ["peirce_1", "peirce-1"]: - self.metric = "peirce_1" - self.type_ = "discrete" - - elif metric.lower() in ["peirce_2", "peirce-2"]: - self.metric = "peirce_2" - self.type_ = "discrete" - - else: + lowercase_metric = metric.lower() + if lowercase_metric not in ALL_METRICS: raise ValueError(f"Similarity metric: {metric} is not implemented") + + # check if the chosen metric is a distance metric + self._is_distance = metric in ALIAS_TO_DISTANCE.keys() + # assign a function to convert to distance if it is not, otherwise + # pass through the value + self.to_distance = ALIAS_TO_DISTANCE.get(metric, lambda x: x) + + # check the registry dictionaries to get the remaining info + self.metric = lowercase_metric + self.type_ = ALIAS_TO_TYPE[lowercase_metric] + self.normalize_fn = {"shift_": 0.0, "scale_": 1.0} self.label_ = metric @@ -261,328 +100,64 @@ def __call__(self, mol1_descriptor, mol2_descriptor): raise ValueError( f"Molecule descriptor ({mol1_descriptor.label_}) has no active bits." ) - similarity_ = None - if self.metric == "l0_similarity": - try: - similarity_ = self._get_vector_norm_similarity( - mol1_descriptor, mol2_descriptor, ord=0 - ) - except ValueError as e: - raise e - elif self.metric == "l1_similarity": - try: - similarity_ = self._get_vector_norm_similarity( - mol1_descriptor, mol2_descriptor, ord=1 - ) - except ValueError as e: - raise e - - elif self.metric == "l2_similarity": - try: - similarity_ = self._get_vector_norm_similarity( - mol1_descriptor, mol2_descriptor, ord=2 - ) - except ValueError as e: - raise e - - elif self.metric == "austin_colwell": - try: - similarity_ = self._get_austin_colwell(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "baroni_urbani_buser": - try: - similarity_ = self._get_baroni_urbani_buser( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "braun_blanquet": - try: - similarity_ = self._get_braun_blanquet(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "cohen": - try: - similarity_ = self._get_cohen(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "cole_1": - try: - similarity_ = self._get_cole_1(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "cole_2": - try: - similarity_ = self._get_cole_2(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "consonni_todeschini_1": - try: - similarity_ = self._get_consonni_todeschini_1( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "consonni_todeschini_2": - try: - similarity_ = self._get_consonni_todeschini_2( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "consonni_todeschini_3": - try: - similarity_ = self._get_consonni_todeschini_3( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "consonni_todeschini_4": - try: - similarity_ = self._get_consonni_todeschini_4( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "consonni_todeschini_5": - try: - similarity_ = self._get_consonni_todeschini_5( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "cosine": - try: - similarity_ = self._get_cosine_similarity( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "dennis": - try: - similarity_ = self._get_dennis(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "dice": - try: - similarity_ = self._get_dice(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "dice_2": - try: - similarity_ = self._get_dice_2(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "dice_3": - try: - similarity_ = self._get_dice_3(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "dispersion": - try: - similarity_ = self._get_dispersion(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "faith": - try: - similarity_ = self._get_faith(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "forbes": - try: - similarity_ = self._get_forbes(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "fossum": - try: - similarity_ = self._get_fossum(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "goodman_kruskal": - try: - similarity_ = self._get_goodman_kruskal( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "harris_lahey": - try: - similarity_ = self._get_harris_lahey(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "hawkins_dotson": - try: - similarity_ = self._get_hawkins_dotson(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "jaccard": - try: - similarity_ = self._get_jaccard(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "kulczynski": - try: - similarity_ = self._get_kulczynski(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - elif self.metric == "maxwell_pilliner": - try: - similarity_ = self._get_maxwell_pilliner( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "michael": - try: - similarity_ = self._get_michael(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "mountford": - try: - similarity_ = self._get_mountford(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "pearson_heron": - try: - similarity_ = self._get_pearson_heron(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "peirce_1": - try: - similarity_ = self._get_peirce_1(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "peirce_2": - try: - similarity_ = self._get_peirce_2(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "rogers_tanimoto": - try: - similarity_ = self._get_rogers_tanimoto( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "rogot_goldberg": - try: - similarity_ = self._get_rogot_goldberg(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "russel_rao": - try: - similarity_ = self._get_russel_rao(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "simple_matching": - try: - similarity_ = self._get_simple_matching( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "simpson": - try: - similarity_ = self._get_simpson(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "sokal_sneath": - try: - similarity_ = self._get_sokal_sneath(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "symmetric_sokal_sneath": - try: - similarity_ = self._get_symmetric_sokal_sneath( - mol1_descriptor, mol2_descriptor - ) - except ValueError as e: - raise e - - elif self.metric == "sokal_sneath_3": - try: - similarity_ = self._get_sokal_sneath_3(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "sokal_sneath_4": - try: - similarity_ = self._get_sokal_sneath_4(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "sorgenfrei": - try: - similarity_ = self._get_sorgenfrei(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "tanimoto": - try: - similarity_ = DataStructs.TanimotoSimilarity( - mol1_descriptor.to_rdkit(), mol2_descriptor.to_rdkit() - ) - except ValueError as e: - raise ValueError( - "Tanimoto similarity is only useful for bit strings " - "generated from fingerprints. Consider using " - "other similarity measures for arbitrary vectors." - ) - elif self.metric == "yule_1": - try: - similarity_ = self._get_yule_1(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e - - elif self.metric == "yule_2": - try: - similarity_ = self._get_yule_2(mol1_descriptor, mol2_descriptor) - except ValueError as e: - raise e + value = None + func = ALIAS_TO_FUNC[self.metric] + try: + value = func(self, mol1_descriptor, mol2_descriptor) + except ValueError as e: + raise ValueError( + f"Unexpected error ocurred when calculating {self.metric:s} distance." + " Original Exception: " + str(e) + ) - else: - raise ValueError(f"{self.metric} could not be implemented") + return value + @register( + "tanimoto", + "jaccard-tanimoto", + to_distance=lambda x: 1 - x, + ) + def _get_tanimoto(self, mol1_descriptor, mol2_descriptor): + similarity_ = None + try: + similarity_ = DataStructs.TanimotoSimilarity( + mol1_descriptor.to_rdkit(), mol2_descriptor.to_rdkit() + ) + except ValueError as e: + raise ValueError( + "Tanimoto similarity is only useful for bit strings " + "generated from fingerprints. Consider using " + "other similarity measures for arbitrary vectors." + " Original Exception: " + str(e) + ) return similarity_ + @register("l0_similarity", type="continuous", to_distance=lambda x: 1 - x) + def _get_l0_similarity(self, mol1_descriptor, mol2_descriptor): + return self._get_vector_norm_similarity(mol1_descriptor, mol2_descriptor, 0) + + @register( + "l1_similarity", + "manhattan_similarity", + "taxicab_similarity", + "city_block_similarity", + "snake_similarity", + type="continuous", + to_distance=lambda x: 1 - x, + ) + def _get_l1_similarity(self, mol1_descriptor, mol2_descriptor): + return self._get_vector_norm_similarity(mol1_descriptor, mol2_descriptor, 1) + + @register( + "l2_similarity", + "euclidean_similarity", + type="continuous", + to_distance=lambda x: 1 - x, + ) + def _get_l2_similarity(self, mol1_descriptor, mol2_descriptor): + return self._get_vector_norm_similarity(mol1_descriptor, mol2_descriptor, 2) + def _get_vector_norm_similarity(self, mol1_descriptor, mol2_descriptor, ord): """Calculate the norm based similarity between two molecules. This is defined as: @@ -608,7 +183,8 @@ def _get_vector_norm_similarity(self, mol1_descriptor, mol2_descriptor, ord): except ValueError as e: raise ValueError( "Fingerprints are of unequal length and cannot be folded." - ) from e + " Original Exception: " + str(e) + ) norm_ = np.linalg.norm(arr1 - arr2, ord=ord) similarity_ = 1 / (1 + norm_) @@ -616,6 +192,7 @@ def _get_vector_norm_similarity(self, mol1_descriptor, mol2_descriptor, ord): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("austin_colwell", "austin-colwell", to_distance=lambda x: 1 - x) def _get_austin_colwell(self, mol1_descriptor, mol2_descriptor): """Calculate Austin-Colwell similarity between two molecules. This is defined for two binary arrays as: @@ -641,6 +218,7 @@ def _get_austin_colwell(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("baroni-urbani-buser", to_distance=lambda x: 1 - x) def _get_baroni_urbani_buser(self, mol1_descriptor, mol2_descriptor): """Calculate Baroni-Urbani-Buser similarity between two molecules. This is defined for two binary arrays as: @@ -669,6 +247,7 @@ def _get_baroni_urbani_buser(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("braun-blanquet", to_distance=lambda x: 1 - x) def _get_braun_blanquet(self, mol1_descriptor, mol2_descriptor): """Calculate braun-blanquet similarity between two molecules. This is defined for two binary arrays as: @@ -695,6 +274,7 @@ def _get_braun_blanquet(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("cohen") def _get_cohen(self, mol1_descriptor, mol2_descriptor): """Calculate Cohen similarity between two molecules. This is defined for two binary arrays as: @@ -726,6 +306,7 @@ def _get_cohen(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("cole_1", "cole-1") def _get_cole_1(self, mol1_descriptor, mol2_descriptor): """Calculate Cole(1) similarity between two molecules. This is defined for two binary arrays as: @@ -756,6 +337,7 @@ def _get_cole_1(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = p return self._normalize(similarity_) + @register("cole_2", "cole-2") def _get_cole_2(self, mol1_descriptor, mol2_descriptor): """Calculate Cole(2) similarity between two molecules. This is defined for two binary arrays as: @@ -786,6 +368,12 @@ def _get_cole_2(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = p return self._normalize(similarity_) + @register( + "consonni_todeschini_1", + "consonni-todeschini-1", + "consonni-todeschini_1", + to_distance=lambda x: 1 - x, + ) def _get_consonni_todeschini_1(self, mol1_descriptor, mol2_descriptor): """Calculate Consonni-Todeschini(1) similarity between two molecules. This is defined for two binary arrays as: @@ -811,6 +399,7 @@ def _get_consonni_todeschini_1(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("consonni_todeschini_2", "consonni-todeschini-2", "consonni-todeschini_2") def _get_consonni_todeschini_2(self, mol1_descriptor, mol2_descriptor): """Calculate Consonni-Todeschini(2) similarity between two molecules. This is defined for two binary arrays as: @@ -837,6 +426,7 @@ def _get_consonni_todeschini_2(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("consonni_todeschini_3", "consonni-todeschini-3", "consonni-todeschini_3") def _get_consonni_todeschini_3(self, mol1_descriptor, mol2_descriptor): """Calculate Consonni-Todeschini(3) similarity between two molecules. This is defined for two binary arrays as: @@ -862,6 +452,7 @@ def _get_consonni_todeschini_3(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("consonni_todeschini_4", "consonni-todeschini-4", "consonni-todeschini_4") def _get_consonni_todeschini_4(self, mol1_descriptor, mol2_descriptor): """Calculate Consonni-Todeschini(4) similarity between two molecules. This is defined for two binary arrays as: @@ -888,6 +479,12 @@ def _get_consonni_todeschini_4(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register( + "consonni_todeschini_5", + "consonni-todeschini-5", + "consonni-todeschini_5", + to_distance=lambda x: 1 - x, + ) def _get_consonni_todeschini_5(self, mol1_descriptor, mol2_descriptor): """Calculate Consonni-Todeschini(5) similarity between two molecules. This is defined for two binary arrays as: @@ -914,6 +511,12 @@ def _get_consonni_todeschini_5(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register( + "cosine", + "driver-kroeber", + "ochiai", + to_distance=lambda x: np.arccos(x) / np.pi, + ) def _get_cosine_similarity(self, mol1_descriptor, mol2_descriptor): a, b, c, _ = self._get_abcd(mol1_descriptor, mol2_descriptor) denominator = np.sqrt((a + b) * (a + c)) @@ -924,6 +527,7 @@ def _get_cosine_similarity(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("dennis", "holiday-dennis", "holiday_dennis", to_distance=lambda x: 1 - x) def _get_dennis(self, mol1_descriptor, mol2_descriptor): """Calculate Dennis similarity between two molecules. This is defined for two binary arrays as: @@ -954,6 +558,12 @@ def _get_dennis(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 3 * np.sqrt(p) / 2 return self._normalize(similarity_) + @register( + "dice", + "sorenson", + "gleason", + to_distance=lambda x: 1 - x / (2 - x), + ) def _get_dice(self, mol1_descriptor, mol2_descriptor): """Calculate Dice similarity between two molecules. This is defined for two binary arrays as: @@ -982,6 +592,7 @@ def _get_dice(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("dice_2") def _get_dice_2(self, mol1_descriptor, mol2_descriptor): """Calculate Dice(2) similarity between two molecules. This is defined for two binary arrays as: @@ -1010,6 +621,7 @@ def _get_dice_2(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("dice_3") def _get_dice_3(self, mol1_descriptor, mol2_descriptor): """Calculate Dice(3) similarity between two molecules. This is defined for two binary arrays as: @@ -1037,6 +649,7 @@ def _get_dice_3(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("dispersion", "choi") def _get_dispersion(self, mol1_descriptor, mol2_descriptor): """Calculate dispersion similarity in Choi et al (2012) between two molecules. This is defined for two binary arrays as: @@ -1064,6 +677,7 @@ def _get_dispersion(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1 / 2 return self._normalize(similarity_) + @register("faith", to_distance=lambda x: 1 - x) def _get_faith(self, mol1_descriptor, mol2_descriptor): """Calculate faith similarity between two molecules. This is defined for two binary arrays as: @@ -1089,6 +703,7 @@ def _get_faith(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("forbes", to_distance=lambda x: 1 - x) def _get_forbes(self, mol1_descriptor, mol2_descriptor): """Calculate forbes similarity between two molecules. This is defined for two binary arrays as: @@ -1119,6 +734,7 @@ def _get_forbes(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = p / a return self._normalize(similarity_) + @register("fossum", "holiday-fossum", "holiday_fossum", to_distance=lambda x: 1 - x) def _get_fossum(self, mol1_descriptor, mol2_descriptor): """Calculate Fossum similarity between two molecules. This is defined for two binary arrays as: @@ -1150,6 +766,7 @@ def _get_fossum(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = (p - 0.5) ** 2 / p return self._normalize(similarity_) + @register("goodman_kruskal", "goodman-kruskal") def _get_goodman_kruskal(self, mol1_descriptor, mol2_descriptor): """Calculate Goodman-Kruskal similarity between two molecules. This is defined for two binary arrays as: @@ -1178,6 +795,7 @@ def _get_goodman_kruskal(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("harris_lahey") def _get_harris_lahey(self, mol1_descriptor, mol2_descriptor): """Calculate Harris-Lahey similarity between two molecules. This is defined for two binary arrays as: @@ -1212,6 +830,7 @@ def _get_harris_lahey(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = p return self._normalize(similarity_) + @register("hawkins_dotson", to_distance=lambda x: 1 - x) def _get_hawkins_dotson(self, mol1_descriptor, mol2_descriptor): """Calculate Hawkins-Dotson similarity between two molecules. This is defined for two binary arrays as: @@ -1242,6 +861,7 @@ def _get_hawkins_dotson(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("jaccard") def _get_jaccard(self, mol1_descriptor, mol2_descriptor): """Calculate jaccard similarity between two molecules. This is defined for two binary arrays as: @@ -1268,6 +888,7 @@ def _get_jaccard(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("kulczynski") def _get_kulczynski(self, mol1_descriptor, mol2_descriptor): """Calculate kulczynski similarity between two molecules. This is defined for two binary arrays as: @@ -1294,6 +915,7 @@ def _get_kulczynski(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("michael") def _get_michael(self, mol1_descriptor, mol2_descriptor): """Calculate michael similarity between two molecules. This is defined for two binary arrays as: @@ -1321,6 +943,7 @@ def _get_michael(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("maxwell_pilliner", to_distance=lambda x: 1 - x) def _get_maxwell_pilliner(self, mol1_descriptor, mol2_descriptor): """Calculate Maxwell-Pilliner similarity between two molecules. This is defined for two binary arrays as: @@ -1353,6 +976,7 @@ def _get_maxwell_pilliner(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("mountford", to_distance=lambda x: 1 - x) def _get_mountford(self, mol1_descriptor, mol2_descriptor): """Calculate mountford similarity between two molecules. This is defined for two binary arrays as: @@ -1381,6 +1005,7 @@ def _get_mountford(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("pearson_heron", "pearson-heron") def _get_pearson_heron(self, mol1_descriptor, mol2_descriptor): """Calculate Pearson-Heron similarity between two molecules. This is defined for two binary arrays as: @@ -1416,6 +1041,7 @@ def _get_pearson_heron(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("peirce_1", "peirce-1") def _get_peirce_1(self, mol1_descriptor, mol2_descriptor): """Calculate Peirce(1) similarity between two molecules. This is defined for two binary arrays as: @@ -1445,6 +1071,7 @@ def _get_peirce_1(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("peirce_2", "peirce-2") def _get_peirce_2(self, mol1_descriptor, mol2_descriptor): """Calculate Peirce(2) similarity between two molecules. This is defined for two binary arrays as: @@ -1474,6 +1101,7 @@ def _get_peirce_2(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("rogers-tanimoto", to_distance=lambda x: 1 - x) def _get_rogers_tanimoto(self, mol1_descriptor, mol2_descriptor): """Calculate rogers-tanimoto similarity between two molecules. This is defined for two binary arrays as: @@ -1499,6 +1127,7 @@ def _get_rogers_tanimoto(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("rogot_goldberg", to_distance=lambda x: 1 - x) def _get_rogot_goldberg(self, mol1_descriptor, mol2_descriptor): """Calculate Rogot-Goldberg similarity between two molecules. This is defined for two binary arrays as: @@ -1513,7 +1142,7 @@ def _get_rogot_goldberg(self, mol1_descriptor, mol2_descriptor): """ if not (mol1_descriptor.is_fingerprint() and mol2_descriptor.is_fingerprint()): raise ValueError( - "Rogot-Goldberg similarity is only useful for bit strings " + "Rogot-Goldberg similarity is only useful for bit strings " "generated from fingerprints. Consider using " "other similarity measures for arbitrary vectors." ) @@ -1526,6 +1155,7 @@ def _get_rogot_goldberg(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("russel-rao", to_distance=lambda x: 1 - x) def _get_russel_rao(self, mol1_descriptor, mol2_descriptor): """Calculate russel-rao similarity between two molecules. This is defined for two binary arrays as: @@ -1551,6 +1181,7 @@ def _get_russel_rao(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("simple_matching", "sokal-michener", "rand", to_distance=lambda x: 1 - x) def _get_simple_matching(self, mol1_descriptor, mol2_descriptor): """Calculate simple matching similarity between two molecules. This is defined for two binary arrays as: @@ -1576,6 +1207,7 @@ def _get_simple_matching(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("simpson") def _get_simpson(self, mol1_descriptor, mol2_descriptor): """Calculate simpson similarity between two molecules. This is defined for two binary arrays as: @@ -1602,6 +1234,7 @@ def _get_simpson(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("sokal-sneath", "sokal-sneath_1", to_distance=lambda x: 1 - x) def _get_sokal_sneath(self, mol1_descriptor, mol2_descriptor): """Calculate Sokal-Sneath similarity between two molecules. This is defined for two binary arrays as: @@ -1628,6 +1261,12 @@ def _get_sokal_sneath(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register( + "symmetric_sokal_sneath", + "sokal-sneath_2", + "sokal-sneath-2", + "symmetric-sokal-sneath", + ) def _get_symmetric_sokal_sneath(self, mol1_descriptor, mol2_descriptor): """Calculate Symmetric Sokal-Sneath similarity between two molecules. This is defined for two binary arrays as: @@ -1653,6 +1292,7 @@ def _get_symmetric_sokal_sneath(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("sokal-sneath-3", "sokal-sneath_3", to_distance=lambda x: 1 - x) def _get_sokal_sneath_3(self, mol1_descriptor, mol2_descriptor): """Calculate Sokal-Sneath(3) similarity between two molecules. This is defined for two binary arrays as: @@ -1688,6 +1328,7 @@ def _get_sokal_sneath_3(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("sokal-sneath-4", "sokal-sneath_4", to_distance=lambda x: 1 - x) def _get_sokal_sneath_4(self, mol1_descriptor, mol2_descriptor): """Calculate Sokal-Sneath(4) similarity between two molecules. This is defined for two binary arrays as: @@ -1724,6 +1365,7 @@ def _get_sokal_sneath_4(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("sorgenfrei") def _get_sorgenfrei(self, mol1_descriptor, mol2_descriptor): """Calculate Sorgenfrei similarity between two molecules. This is defined for two binary arrays as: @@ -1750,6 +1392,7 @@ def _get_sorgenfrei(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 1.0 return self._normalize(similarity_) + @register("yule_1", "yule-1") def _get_yule_1(self, mol1_descriptor, mol2_descriptor): """Calculate Yule(1) similarity between two molecules. This is defined for two binary arrays as: @@ -1778,6 +1421,7 @@ def _get_yule_1(self, mol1_descriptor, mol2_descriptor): self.normalize_fn["scale_"] = 2.0 return self._normalize(similarity_) + @register("yule_2", "yule-2", to_distance=lambda x: 1 - x) def _get_yule_2(self, mol1_descriptor, mol2_descriptor): """Calculate Yule(2) similarity between two molecules. This is defined for two binary arrays as: @@ -1846,7 +1490,7 @@ def is_distance_metric(self): Returns: bool: True if it is a distance metric. """ - return hasattr(self, "to_distance") + return self._is_distance @staticmethod @lru_cache(maxsize=None) @@ -1908,71 +1552,7 @@ def get_supported_binary_metrics(): Returns: List: List of strings. """ - return [ - "tanimoto", - "dice", - "austin-colwell", - "sorenson", - "gleason", - "dice_2", - "dice_3", - "jaccard", - "cosine", - "driver-kroeber", - "ochiai", - "simple_matching", - "sokal-michener", - "rand", - "rogers-tanimoto", - "russel-rao", - "forbes", - "simpson", - "braun-blanquet", - "baroni-urbani-buser", - "kulczynski", - "sokal-sneath", - "sokal-sneath-2", - "symmetric_sokal_sneath", - "symmetric-sokal-sneath", - "sokal-sneath-3", - "sokal-sneath_3", - "sokal-sneath-4", - "sokal-sneath_4", - "faith", - "mountford", - "michael", - "rogot-goldberg", - "hawkins-dotson", - "maxwell-pilliner", - "harris-lahey", - "consonni-todeschini-1", - "consonni-todeschini-2", - "consonni-todeschini-3", - "consonni-todeschini-4", - "consonni-todeschini-5", - "yule-1", - "yule_1", - "yule_2", - "yule_2", - "fossum", - "holiday-fossum", - "holiday_fossum", - "dennis", - "holiday-dennis", - "holiday_dennis", - "cole-1", - "cole_1", - "cole-2", - "cole_2", - "dispersion", - "choi", - "goodman-kruskal", - "pearson-heron", - "sorgenfrei", - "cohen", - "peirce_1", - "peirce_2", - ] + return BINARY_METRICS @staticmethod def get_supported_metrics(): @@ -1982,85 +1562,7 @@ def get_supported_metrics(): Returns: List: List of strings. """ - return [ - "tanimoto", - "jaccard-tanimoto", - "l0_similarity", - "l1_similarity", - "manhattan_similarity", - "l2_similarity", - "euclidean_similarity", - "dice", - "sorenson", - "gleason", - "dice_2", - "dice_3", - "cosine", - "driver-kroeber", - "ochiai", - "simple_matching", - "sokal-michener", - "rand", - "rogers-tanimoto", - "russel-rao", - "forbes", - "simpson", - "braun-blanquet", - "baroni-urbani-buser", - "kulczynski", - "sokal-sneath", - "sokal-sneath_1", - "sokal-sneath-2", - "symmetric_sokal_sneath", - "symmetric-sokal-sneath", - "sokal-sneath-3", - "sokal-sneath_3", - "sokal-sneath-4", - "sokal-sneath_4", - "jaccard", - "faith", - "mountford", - "michael", - "rogot-goldberg", - "hawkins-dotson", - "maxwell-pilliner", - "harris-lahey", - "consonni-todeschini-1", - "consonni-todeschini-2", - "consonni-todeschini-3", - "consonni-todeschini-4", - "consonni-todeschini-5", - "consonni-todeschini_1", - "consonni-todeschini_2", - "consonni-todeschini_3", - "consonni-todeschini_4", - "consonni-todeschini_5", - "austin-colwell", - "yule-1", - "yule_1", - "yule_2", - "yule_2", - "fossum", - "holiday-fossum", - "holiday_fossum", - "dennis", - "holiday-dennis", - "holiday_dennis", - "cole-1", - "cole_1", - "cole-2", - "cole_2", - "dispersion", - "choi", - "goodman-kruskal", - "pearson-heron", - "sorgenfrei", - "cohen", - "peirce_1", - "peirce-1", - "peirce_2", - "peirce-2", - ] + return ALL_METRICS @staticmethod def get_uniq_metrics(): @@ -2071,55 +1573,7 @@ def get_uniq_metrics(): Returns: List: List of strings. """ - return [ - "tanimoto", - "l0_similarity", - "l1_similarity", - "l2_similarity", - "dice", - "dice_2", - "dice_3", - "cosine", - "simple_matching", - "rogers-tanimoto", - "russel-rao", - "forbes", - "simpson", - "braun-blanquet", - "baroni-urbani-buser", - "kulczynski", - "sokal-sneath", - "sokal-sneath-2", - "sokal-sneath-3", - "sokal-sneath-4", - "jaccard", - "faith", - "mountford", - "michael", - "rogot-goldberg", - "hawkins-dotson", - "maxwell-pilliner", - "harris-lahey", - "consonni-todeschini-1", - "consonni-todeschini-2", - "consonni-todeschini-3", - "consonni-todeschini-4", - "consonni-todeschini-5", - "austin-colwell", - "yule-1", - "yule_2", - "fossum", - "dennis", - "cole-1", - "cole-2", - "dispersion", - "goodman-kruskal", - "pearson-heron", - "sorgenfrei", - "cohen", - "peirce-1", - "peirce-2", - ] + return UNIQUE_METRICS def __str__(self): return self.label_ diff --git a/aimsim/tasks/__init__.py b/aimsim/tasks/__init__.py index 0ac8808b..e53df89c 100644 --- a/aimsim/tasks/__init__.py +++ b/aimsim/tasks/__init__.py @@ -5,6 +5,7 @@ from .visualize_dataset import VisualizeDataset from .identify_outliers import IdentifyOutliers from .measure_search import MeasureSearch +from .extended_similarity_indices import ExtendedSimilarityIndices # this import must come last from .task_manager import TaskManager diff --git a/aimsim/tasks/extended_similarity_indices.py b/aimsim/tasks/extended_similarity_indices.py new file mode 100644 index 00000000..0071c3e5 --- /dev/null +++ b/aimsim/tasks/extended_similarity_indices.py @@ -0,0 +1,288 @@ +""" +Calculates the Extended Similarity Indexes as shown in +this table: https://jcheminf.biomedcentral.com/articles/10.1186/s13321-021-00505-3/tables/1 +and described in "Extended similarity indices: the benefits of comparing more than two objects +simultaneously. Part 1: Theory and characteristics" + +Both gen_sim_dict and calculate_counters were provided by Raymond Quintana, similar to +that which is here: https://github.com/ramirandaq/MultipleComparisons +""" +import json +from math import log, ceil + +import numpy as np + +from .task import Task + + +class ExtendedSimilarityIndices(Task): + def __call__(self, molecule_set): + """ + Calculates the extended similarity indices for the given molecule_set. + + Args: + molecule_set (AIMSim.chemical_datastructures Molecule): Target + molecule. + + """ + n_mols = len(molecule_set.molecule_database) + fprints = [] + for i in range(n_mols): + fprints.append(molecule_set.molecule_database[i].get_descriptor_val()) + fprint_array_sum = np.sum(fprints, axis=0) + indices_array = self.gen_sim_dict(fprint_array_sum, n_mols) + + print("Extended Similarity Indices:") + print(json.dumps(indices_array, indent=2)) + return indices_array + + def _extract_configs(self): + pass + + def __str__(self): + return "Task: Calculate Extended Similarity Indices" + + def gen_sim_dict( + self, c_total, n_fingerprints, c_threshold=None, w_factor="fraction" + ): + # Indices + # AC: Austin-Colwell, BUB: Baroni-Urbani-Buser, CTn: Consoni-Todschini n + # Fai: Faith, Gle: Gleason, Ja: Jaccard, Ja0: Jaccard 0-variant + # JT: Jaccard-Tanimoto, RT: Rogers-Tanimoto, RR: Russel-Rao + # SM: Sokal-Michener, SSn: Sokal-Sneath n + + counters = self.calculate_counters( + c_total, n_fingerprints, c_threshold=c_threshold, w_factor="fraction" + ) + + # Weighted Indices + ac_w = (2 / np.pi) * np.arcsin( + np.sqrt(counters["total_w_sim"] / counters["w_p"]) + ) + bub_w = ((counters["w_a"] * counters["w_d"]) ** 0.5 + counters["w_a"]) / ( + (counters["w_a"] * counters["w_d"]) ** 0.5 + + counters["w_a"] + + counters["total_w_dis"] + ) + ct1_w = (log(1 + counters["w_a"] + counters["w_d"])) / ( + log(1 + counters["w_p"]) + ) + ct2_w = (log(1 + counters["w_p"]) - log(1 + counters["total_w_dis"])) / ( + log(1 + counters["w_p"]) + ) + ct3_w = (log(1 + counters["w_a"])) / (log(1 + counters["w_p"])) + ct4_w = (log(1 + counters["w_a"])) / ( + log(1 + counters["w_a"] + counters["total_w_dis"]) + ) + fai_w = (counters["w_a"] + 0.5 * counters["w_d"]) / (counters["w_p"]) + gle_w = (2 * counters["w_a"]) / (2 * counters["w_a"] + counters["total_w_dis"]) + ja_w = (3 * counters["w_a"]) / (3 * counters["w_a"] + counters["total_w_dis"]) + ja0_w = (3 * counters["total_w_sim"]) / ( + 3 * counters["total_w_sim"] + counters["total_w_dis"] + ) + jt_w = (counters["w_a"]) / (counters["w_a"] + counters["total_w_dis"]) + rt_w = (counters["total_w_sim"]) / (counters["w_p"] + counters["total_w_dis"]) + rr_w = (counters["w_a"]) / (counters["w_p"]) + sm_w = (counters["total_w_sim"]) / (counters["w_p"]) + ss1_w = (counters["w_a"]) / (counters["w_a"] + 2 * counters["total_w_dis"]) + ss2_w = (2 * counters["total_w_sim"]) / ( + counters["w_p"] + counters["total_w_sim"] + ) + + # Non-Weighted Indices + ac_nw = (2 / np.pi) * np.arcsin( + np.sqrt(counters["total_w_sim"] / counters["p"]) + ) + bub_nw = ((counters["w_a"] * counters["w_d"]) ** 0.5 + counters["w_a"]) / ( + (counters["a"] * counters["d"]) ** 0.5 + + counters["a"] + + counters["total_dis"] + ) + ct1_nw = (log(1 + counters["w_a"] + counters["w_d"])) / (log(1 + counters["p"])) + ct2_nw = (log(1 + counters["w_p"]) - log(1 + counters["total_w_dis"])) / ( + log(1 + counters["p"]) + ) + ct3_nw = (log(1 + counters["w_a"])) / (log(1 + counters["p"])) + ct4_nw = (log(1 + counters["w_a"])) / ( + log(1 + counters["a"] + counters["total_dis"]) + ) + fai_nw = (counters["w_a"] + 0.5 * counters["w_d"]) / (counters["p"]) + gle_nw = (2 * counters["w_a"]) / (2 * counters["a"] + counters["total_dis"]) + ja_nw = (3 * counters["w_a"]) / (3 * counters["a"] + counters["total_dis"]) + ja0_nw = (3 * counters["total_w_sim"]) / ( + 3 * counters["total_sim"] + counters["total_dis"] + ) + jt_nw = (counters["w_a"]) / (counters["a"] + counters["total_dis"]) + rt_nw = (counters["total_w_sim"]) / (counters["p"] + counters["total_dis"]) + rr_nw = (counters["w_a"]) / (counters["p"]) + sm_nw = (counters["total_w_sim"]) / (counters["p"]) + ss1_nw = (counters["w_a"]) / (counters["a"] + 2 * counters["total_dis"]) + ss2_nw = (2 * counters["total_w_sim"]) / (counters["p"] + counters["total_sim"]) + + # Dictionary with all the results + Indices = { + "nw": { + "AC": ac_nw, + "BUB": bub_nw, + "CT1": ct1_nw, + "CT2": ct2_nw, + "CT3": ct3_nw, + "CT4": ct4_nw, + "Fai": fai_nw, + "Gle": gle_nw, + "Ja": ja_nw, + "Ja0": ja0_nw, + "JT": jt_nw, + "RT": rt_nw, + "RR": rr_nw, + "SM": sm_nw, + "SS1": ss1_nw, + "SS2": ss2_nw, + }, + "w": { + "AC": ac_w, + "BUB": bub_w, + "CT1": ct1_w, + "CT2": ct2_w, + "CT3": ct3_w, + "CT4": ct4_w, + "Fai": fai_w, + "Gle": gle_w, + "Ja": ja_w, + "Ja0": ja0_w, + "JT": jt_w, + "RT": rt_w, + "RR": rr_w, + "SM": sm_w, + "SS1": ss1_w, + "SS2": ss2_w, + }, + } + return Indices + + def calculate_counters( + self, c_total, n_fingerprints, c_threshold=None, w_factor="fraction" + ): + """Calculate 1-similarity, 0-similarity, and dissimilarity counters + + Arguments + --------- + c_total : np.ndarray + Vector containing the sums of each column of the fingerprint matrix. + + n_fingerprints : int + Number of objects to be compared. + + c_threshold : {None, 'dissimilar', int} + Coincidence threshold. + None : Default, c_threshold = n_fingerprints % 2 + 'dissimilar' : c_threshold = ceil(n_fingerprints / 2) + int : Integer number < n_fingerprints + float : Real number in the (0 , 1) interval. Indicates the % of the total data that will serve as threshold. + + w_factor : {"fraction", "power_n"} + Type of weight function that will be used. + 'fraction' : similarity = d[k]/n + dissimilarity = 1 - (d[k] - n_fingerprints % 2)/n_fingerprints + 'power_n' : similarity = n**-(n_fingerprints - d[k]) + dissimilarity = n**-(d[k] - n_fingerprints % 2) + other values : similarity = dissimilarity = 1 + + Returns + ------- + counters : dict + Dictionary with the weighted and non-weighted counters. + + Notes + ----- + Please, cite the original papers on the n-ary indices: + https://jcheminf.biomedcentral.com/articles/10.1186/s13321-021-00505-3 + https://jcheminf.biomedcentral.com/articles/10.1186/s13321-021-00504-4 + """ + # Assign c_threshold + if not c_threshold: + c_threshold = n_fingerprints % 2 + if c_threshold == "dissimilar": + c_threshold = ceil(n_fingerprints / 2) + if c_threshold == "min": + c_threshold = n_fingerprints % 2 + if isinstance(c_threshold, int): + if c_threshold >= n_fingerprints: + raise ValueError( + "c_threshold cannot be equal or greater than n_fingerprints." + ) + c_threshold = c_threshold + if 0 < c_threshold < 1: + c_threshold *= n_fingerprints + + # Set w_factor + if w_factor: + if "power" in w_factor: + power = int(w_factor.split("_")[-1]) + + def f_s(d): + return power ** -float(n_fingerprints - d) + + def f_d(d): + return power ** -float(d - n_fingerprints % 2) + + elif w_factor == "fraction": + + def f_s(d): + return d / n_fingerprints + + def f_d(d): + return 1 - (d - n_fingerprints % 2) / n_fingerprints + + else: + + def f_s(d): + return 1 + + def f_d(d): + return 1 + + else: + + def f_s(d): + return 1 + + def f_d(d): + return 1 + + # Calculate a, d, b + c + + a_indices = 2 * c_total - n_fingerprints > c_threshold + d_indices = n_fingerprints - 2 * c_total > c_threshold + dis_indices = np.abs(2 * c_total - n_fingerprints) <= c_threshold + + a = np.sum(a_indices) + d = np.sum(d_indices) + total_dis = np.sum(dis_indices) + + a_w_array = f_s(2 * c_total[a_indices] - n_fingerprints) + d_w_array = f_s(abs(2 * c_total[d_indices] - n_fingerprints)) + total_w_dis_array = f_d(abs(2 * c_total[dis_indices] - n_fingerprints)) + + w_a = np.sum(a_w_array) + w_d = np.sum(d_w_array) + total_w_dis = np.sum(total_w_dis_array) + + total_sim = a + d + total_w_sim = w_a + w_d + p = total_sim + total_dis + w_p = total_w_sim + total_w_dis + + counters = { + "a": a, + "w_a": w_a, + "d": d, + "w_d": w_d, + "total_sim": total_sim, + "total_w_sim": total_w_sim, + "total_dis": total_dis, + "total_w_dis": total_w_dis, + "p": p, + "w_p": w_p, + } + return counters diff --git a/aimsim/tasks/identify_outliers.py b/aimsim/tasks/identify_outliers.py index f9fcbb49..684b2b79 100644 --- a/aimsim/tasks/identify_outliers.py +++ b/aimsim/tasks/identify_outliers.py @@ -56,7 +56,7 @@ def __call__(self, molecule_set): "Molecule {} (name: {}) is a potential outlier " "({:.2f} outlier score)".format( nmol + 1, - molecule_set.molecule_database[nmol], + molecule_set.molecule_database[nmol].get_name(), iof.decision_function(descs[nmol].reshape(1, -1))[0], ) ) @@ -66,8 +66,7 @@ def __call__(self, molecule_set): with open(self.output + ".log", "a") as file: file.write(msg + "\n") if self.plot_outlier: - reduced_features = molecule_set.get_transformed_descriptors( - method_="pca") + reduced_features = molecule_set.get_transformed_descriptors(method_="pca") plot_scatter_interactive( reduced_features[:, 0], reduced_features[:, 1], diff --git a/aimsim/tasks/task_manager.py b/aimsim/tasks/task_manager.py index 4761dc52..0ef00fc0 100644 --- a/aimsim/tasks/task_manager.py +++ b/aimsim/tasks/task_manager.py @@ -29,11 +29,12 @@ def _set_tasks(self, tasks): try: if task == "compare_target_molecule": loaded_task = CompareTargetMolecule(task_configs) + elif task == "get_extended_similarity_indices": + loaded_task = ExtendedSimilarityIndices(task_configs) elif task == "visualize_dataset": loaded_task = VisualizeDataset(task_configs) elif task == "see_property_variation_w_similarity": - loaded_task = SeePropertyVariationWithSimilarity( - task_configs) + loaded_task = SeePropertyVariationWithSimilarity(task_configs) elif task == "identify_outliers": loaded_task = IdentifyOutliers(task_configs) elif task == "cluster": @@ -72,31 +73,27 @@ def _initialize_molecule_set(self, molecule_set_configs): raise InvalidConfigurationError is_verbose = molecule_set_configs.get("is_verbose", False) n_threads = molecule_set_configs.get("n_workers", 1) - similarity_measure = molecule_set_configs.get("similarity_measure", - 'determine') - fingerprint_type = molecule_set_configs.get('fingerprint_type', - 'determine') - fingerprint_params = molecule_set_configs.get('fingerprint_params', {}) - if similarity_measure == 'determine' or fingerprint_type == 'determine': + similarity_measure = molecule_set_configs.get("similarity_measure", "determine") + fingerprint_type = molecule_set_configs.get("fingerprint_type", "determine") + fingerprint_params = molecule_set_configs.get("fingerprint_params", {}) + if similarity_measure == "determine" or fingerprint_type == "determine": subsample_subset_size = molecule_set_configs.get( - 'measure_id_subsample', - 0.05) - only_valid_dist = molecule_set_configs.get( - 'only_valid_dist', - True) + "measure_id_subsample", 0.05 + ) + only_valid_dist = molecule_set_configs.get("only_valid_dist", True) if is_verbose: - print('Determining best fingerprint_type / similarity_measure') - measure_search = MeasureSearch(correlation_type='pearson') - if similarity_measure == 'determine': + print("Determining best fingerprint_type / similarity_measure") + measure_search = MeasureSearch(correlation_type="pearson") + if similarity_measure == "determine": similarity_measure = None - if fingerprint_type == 'determine': + if fingerprint_type == "determine": fingerprint_type = None fingerprint_params = {} measure_search_molset_configs = { - 'molecule_database_src': molecule_database_src, - 'molecule_database_src_type': database_src_type, - 'is_verbose': is_verbose, - 'n_threads': n_threads, + "molecule_database_src": molecule_database_src, + "molecule_database_src_type": database_src_type, + "is_verbose": is_verbose, + "n_threads": n_threads, } best_measure = measure_search( @@ -106,14 +103,14 @@ def _initialize_molecule_set(self, molecule_set_configs): fingerprint_params=fingerprint_params, subsample_subset_size=subsample_subset_size, show_top=5, - only_metric=only_valid_dist) + only_metric=only_valid_dist, + ) similarity_measure = best_measure.similarity_measure fingerprint_type = best_measure.fingerprint_type - print(f'Chosen measure: {fingerprint_type} ' - f'and {similarity_measure}.') + print(f"Chosen measure: {fingerprint_type} " f"and {similarity_measure}.") - sampling_ratio = molecule_set_configs.get("sampling_ratio", 1.) - print(f'Choosing sampling ratio of {sampling_ratio} for tasks') + sampling_ratio = molecule_set_configs.get("sampling_ratio", 1.0) + print(f"Choosing sampling ratio of {sampling_ratio} for tasks") self.molecule_set = MoleculeSet( molecule_database_src=molecule_database_src, molecule_database_src_type=database_src_type, diff --git a/aimsim/tasks/visualize_dataset.py b/aimsim/tasks/visualize_dataset.py index 38b8a0ac..bc7f824c 100644 --- a/aimsim/tasks/visualize_dataset.py +++ b/aimsim/tasks/visualize_dataset.py @@ -76,10 +76,18 @@ def __call__(self, molecule_set): 2. PDF of the similarity distribution of the molecules in the database. """ + if len(molecule_set.molecule_database) > 1_000: + warn( + "Similarity heatmap and embedding plot may take significant time on large datasets " + f"(detected size {len(molecule_set.molecule_database):d}).", + RuntimeWarning, + ) + similarity_matrix = molecule_set.get_similarity_matrix() if molecule_set.is_verbose: print("Plotting similarity heatmap") plot_heatmap(similarity_matrix, **self.plot_settings["heatmap_plot"]) + if molecule_set.is_verbose: print("Generating pairwise similarities") pairwise_similarity_vector = molecule_set.get_pairwise_similarities() diff --git a/aimsim/utils/extras.py b/aimsim/utils/extras.py index 3e4fe315..1c522636 100644 --- a/aimsim/utils/extras.py +++ b/aimsim/utils/extras.py @@ -10,9 +10,8 @@ def requires_mordred(function): return function except ImportError: return MordredNotInstalledWarning( - """Attempting to call this function ({:s}) requires mordred to be installed. - Please use 'pip install aimsim[mordred]' in an environment with the appropriate version of Python. - """.format( + "Attempting to call this function ({:s}) requires mordred to be installed from the mordredcommunity package. " + "Run 'pip install mordredcommunity' or 'conda install -c conda-forge mordredcommunity.".format( function.__name__ ) ) diff --git a/conda-environment-macOS12-M1.yml b/conda-environment-macOS12-M1.yml deleted file mode 100644 index 3e394384..00000000 --- a/conda-environment-macOS12-M1.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: aimsim-env -channels: - - conda-forge -dependencies: - - boost=1.74.0=py310hd0bb7a8_5 - - boost-cpp=1.74.0=h1cb353e_8 - - brotli=1.0.9=h1c322ee_7 - - brotli-bin=1.0.9=h1c322ee_7 - - bzip2=1.0.8=h3422bc3_4 - - ca-certificates=2021.10.8=h4653dfc_0 - - cairo=1.16.0=h3e596be_1011 - - certifi=2021.10.8=py310hbe9552e_2 - - cycler=0.11.0=pyhd8ed1ab_0 - - dill=0.3.4=pyhd8ed1ab_0 - - expat=2.4.8=h6b3803e_0 - - font-ttf-dejavu-sans-mono=2.37=hab24e00_0 - - font-ttf-inconsolata=3.000=h77eed37_0 - - font-ttf-source-code-pro=2.038=h77eed37_0 - - font-ttf-ubuntu=0.83=hab24e00_0 - - fontconfig=2.14.0=hfb34624_0 - - fonts-conda-ecosystem=1=0 - - fonts-conda-forge=1=0 - - fonttools=4.33.2=py310h02f21da_0 - - freetype=2.10.4=h17b34a0_1 - - gettext=0.19.8.1=h049c9fb_1008 - - giflib=5.2.1=h27ca646_2 - - greenlet=1.1.2=py310h1105856_2 - - icu=70.1=h6b3803e_0 - - jbig=2.1=h3422bc3_2003 - - joblib=1.1.0=pyhd8ed1ab_0 - - jpeg=9e=h1c322ee_1 - - kiwisolver=1.4.2=py310hea002bf_1 - - lcms2=2.12=had6a04f_0 - - lerc=3.0=hbdafb3b_0 - - libblas=3.9.0=14_osxarm64_openblas - - libbrotlicommon=1.0.9=h1c322ee_7 - - libbrotlidec=1.0.9=h1c322ee_7 - - libbrotlienc=1.0.9=h1c322ee_7 - - libcblas=3.9.0=14_osxarm64_openblas - - libcxx=13.0.1=h6a5c8ee_0 - - libdeflate=1.10=h3422bc3_0 - - libffi=3.4.2=h3422bc3_5 - - libgfortran=5.0.0.dev0=11_0_1_hf114ba7_23 - - libgfortran5=11.0.1.dev0=hf114ba7_23 - - libglib=2.70.2=h67e64d8_4 - - libiconv=1.16=h642e427_0 - - liblapack=3.9.0=14_osxarm64_openblas - - libopenblas=0.3.20=openmp_h2209c59_0 - - libpng=1.6.37=hf7e6567_2 - - libtiff=4.3.0=h77dc3b6_3 - - libwebp=1.2.2=h0d20362_0 - - libwebp-base=1.2.2=h3422bc3_1 - - libxcb=1.13=h9b22ae9_1004 - - libzlib=1.2.11=h90dfc92_1014 - - llvm-openmp=13.0.1=h455960f_1 - - lz4-c=1.9.3=hbdafb3b_1 - - matplotlib-base=3.5.1=py310hacb9267_0 - - multiprocess=0.70.12.2=py310hf8d0d8f_2 - - munkres=1.1.4=pyh9f0ad1d_0 - - ncurses=6.3=h07bb92c_1 - - numpy=1.22.3=py310h2e04ed8_2 - - openjpeg=2.4.0=h062765e_1 - - openssl=3.0.2=h90dfc92_1 - - packaging=21.3=pyhd8ed1ab_0 - - pandas=1.4.2=py310h3a37f5e_1 - - patsy=0.5.2=pyhd8ed1ab_0 - - pcre=8.45=hbdafb3b_0 - - pillow=9.1.0=py310hade9107_2 - - pip=22.0.4=pyhd8ed1ab_0 - - pixman=0.40.0=h27ca646_0 - - plotly=5.7.0=pyhd8ed1ab_0 - - psutil=5.9.0=py310hf8d0d8f_1 - - pthread-stubs=0.4=h27ca646_1001 - - pycairo=1.21.0=py310hdaee2fb_1 - - pyparsing=3.0.8=pyhd8ed1ab_0 - - python=3.10.4=h14b404e_0_cpython - - python-dateutil=2.8.2=pyhd8ed1ab_0 - - python_abi=3.10=2_cp310 - - pytz=2022.1=pyhd8ed1ab_0 - - pyyaml=6.0=py310hf8d0d8f_4 - - rdkit=2022.03.1=py310h615aa19_1 - - readline=8.1=hedafd6a_0 - - reportlab=3.5.68=py310h5c813d2_1 - - scikit-learn=1.0.2=py310h5f111c3_0 - - scipy=1.8.0=py310h6ecf4ae_1 - - seaborn=0.11.2=hd8ed1ab_0 - - seaborn-base=0.11.2=pyhd8ed1ab_0 - - setuptools=62.1.0=py310hbe9552e_0 - - six=1.16.0=pyh6c4a22f_0 - - sqlalchemy=1.4.35=py310hf8d0d8f_0 - - sqlite=3.38.2=h7e3ccbd_0 - - statsmodels=0.13.2=py310h949c51b_0 - - tenacity=8.0.1=pyhd8ed1ab_0 - - threadpoolctl=3.1.0=pyh8a188c0_0 - - tk=8.6.12=he1e0b03_0 - - tzdata=2022a=h191b570_0 - - unicodedata2=14.0.0=py310hf8d0d8f_1 - - wheel=0.37.1=pyhd8ed1ab_0 - - xorg-libxau=1.0.9=h27ca646_0 - - xorg-libxdmcp=1.1.3=h27ca646_0 - - xz=5.2.5=h642e427_1 - - yaml=0.2.5=h3422bc3_2 - - zlib=1.2.11=h90dfc92_1014 - - zstd=1.5.2=h861e0a7_0 - - pip: - - mordred==1.2.0 - - networkx==2.8 - - padelpy==0.1.11 - - scikit-learn-extra==0.2.0 -prefix: /Users/himaghnabhattacharjee/miniforge3/envs/aimsim-env diff --git a/config.yaml b/config.yaml index fd96b6d8..5836c087 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,4 @@ +# see AIMSim-demo.ipynb for a comprehensive list of all possible configuration options is_verbose: True molecule_database: 'tests\small.smi' molecule_database_source_type: text @@ -14,6 +15,7 @@ fingerprint_type: 'topological_fingerprint' # The lines beginning with '#!' can be directly uncommented to implement them. tasks: + get_extended_similarity_indices: visualize_dataset: heatmap_plot_settings: plot_color: 'green' # Set a color recognized by matplotlib. diff --git a/docs/_sources/README.rst.txt b/docs/_sources/README.rst.txt index 0670e46e..939cc4d3 100644 --- a/docs/_sources/README.rst.txt +++ b/docs/_sources/README.rst.txt @@ -61,7 +61,7 @@ This command also installs the required dependencies. It is recommended to insta Running AIMSim -------------- -``AIMSim`` is compatible with Python 3.7+. +``AIMSim`` is compatible with Python 3.8+. Start ``AIMSim`` with a graphical user interface: ``aimsim`` diff --git a/interfaces/UI/AIMSim_ui_main.py b/interfaces/UI/AIMSim_ui_main.py index 6397d6b3..f9366bc6 100644 --- a/interfaces/UI/AIMSim_ui_main.py +++ b/interfaces/UI/AIMSim_ui_main.py @@ -17,13 +17,12 @@ import os import tkinter as tk from tkinter import messagebox, filedialog -import tkinter.ttk as ttk import webbrowser import pkg_resources -import customtkinter as ctk -from tktooltip import ToolTip +from .libraries import customtkinter as ctk +from .libraries.tktooltip.tooltip import ToolTip ctk.set_appearance_mode("dark") # Modes: system (default), light, dark ctk.set_default_color_theme("blue") # Themes: blue (default), dark-blue, green @@ -31,12 +30,12 @@ class AIMSimUiApp(ctk.CTk): """User interface to access key functionalities of AIMSim.""" - WIDTH = 600 + + WIDTH = 800 HEIGHT = 400 def __init__(self): - """Constructor for AIMSim UI. - """ + """Constructor for AIMSim UI.""" super().__init__() # build ui self.title("AIMSim") @@ -211,9 +210,7 @@ def __init__(self): values=SimilarityMeasure.get_uniq_metrics(), hover=False, ) - self.similarityMeasureCombobox.set( - self.similarityMeasureCombobox.values[0] - ) + self.similarityMeasureCombobox.set(self.similarityMeasureCombobox.values[0]) self.similarityMeasureCombobox.grid( row=4, column=1, @@ -266,10 +263,12 @@ def updateCompatibleMetricsListener(event): self.similarityMeasureCombobox.configure( True, values=[ - metric for metric in SimilarityMeasure.get_compatible_metrics().get( + metric + for metric in SimilarityMeasure.get_compatible_metrics().get( self.molecularDescriptor.get(), "Error" - ) if (metric in SimilarityMeasure.get_uniq_metrics()) - ] + ) + if (metric in SimilarityMeasure.get_uniq_metrics()) + ], ) self.similarityMeasureCombobox.current( self.similarityMeasureCombobox.values[0] @@ -288,9 +287,7 @@ def updateCompatibleMetricsListener(event): pady=(0, 0), sticky="we", ) - self.molecularDescriptorCombobox.set( - self.molecularDescriptorCombobox.values[0] - ) + self.molecularDescriptorCombobox.set(self.molecularDescriptorCombobox.values[0]) # checkbox to show all descriptors in AIMSim self.showAllDescriptorsButton = ctk.CTkCheckBox( master=self, @@ -385,6 +382,22 @@ def updateCompatibleMetricsListener(event): sticky="w", ) + # extended similarity indices + self.extendedSimilarityCheckbutton = ctk.CTkCheckBox( + master=self, + cursor="arrow", + state=tk.NORMAL, + text="Get Extended Similarity", + ) + self.extendedSimilarityCheckbutton.grid( + row=7, + column=4, + columnspan=3, + padx=0, + pady=(0, 0), + sticky="w", + ) + # add ToolTips ToolTip( self.openConfigButton, @@ -453,10 +466,10 @@ def browseCallback(self): initialdir=".", title="Select Molecule Database File", filetypes=[ - ('SMILES', '.smi .txt .SMILES'), - ('Protein Data Bank', '.pdb'), - ('Comma-Separated Values', '.csv .tsv'), - ('Excel Workbook', '.xlsx'), + ("SMILES", ".smi .txt .SMILES"), + ("Protein Data Bank", ".pdb"), + ("Comma-Separated Values", ".csv .tsv"), + ("Excel Workbook", ".xlsx"), ], ) if out: @@ -477,7 +490,10 @@ def showAllDescriptorsCallback(self): values=Descriptor.get_supported_fprints(), ) # switch off unsupported descriptor - if self.molecularDescriptorCombobox.current_value not in Descriptor.get_supported_fprints(): + if ( + self.molecularDescriptorCombobox.current_value + not in Descriptor.get_supported_fprints() + ): self.molecularDescriptorCombobox.set( self.molecularDescriptorCombobox.values[0] ) @@ -486,13 +502,9 @@ def showAllDescriptorsCallback(self): def useMeasureSearchCallback(self): """measure search dropdown disable/enable""" if self.useMeasureSearchCheckbox.get(): - self.similarityMeasureCombobox.configure( - state='disabled' - ) + self.similarityMeasureCombobox.configure(state="disabled") else: - self.similarityMeasureCombobox.configure( - state='normal' - ) + self.similarityMeasureCombobox.configure(state="normal") return def openConfigCallback(self): @@ -526,10 +538,16 @@ def runCallback(self): tasks_dict["visualize_dataset"] = inner_dict if self.identifyOutliersCheckbutton.get(): tasks_dict["identify_outliers"] = {"output": "terminal"} + if self.extendedSimilarityCheckbutton.get(): + tasks_dict["get_extended_similarity_indices"] = {} if self.targetMolecule.get() not in ("", "optional"): tasks_dict["compare_target_molecule"] = { - "target_molecule_smiles": self.targetMolecule.get() if not os.path.exists(self.targetMolecule.get()) else None, - "target_molecule_src": self.targetMolecule.get() if os.path.exists(self.targetMolecule.get()) else None, + "target_molecule_smiles": self.targetMolecule.get() + if not os.path.exists(self.targetMolecule.get()) + else None, + "target_molecule_src": self.targetMolecule.get() + if os.path.exists(self.targetMolecule.get()) + else None, "similarity_plot_settings": { "plot_color": "orange", "plot_title": "Molecule Database Compared to Target Molecule", @@ -545,7 +563,7 @@ def runCallback(self): verboseChecked = self.verboseCheckbutton.get() if self.multiprocessingCheckbutton.get(): - n_workers = 'auto' + n_workers = "auto" else: n_workers = 1 @@ -564,7 +582,9 @@ def runCallback(self): "n_workers": n_workers, "molecule_database": self.databaseFile.get(), "molecule_database_source_type": molecule_database_source_type, - "similarity_measure": 'determine' if self.useMeasureSearchCheckbox.get() else self.similarityMeasure.get(), + "similarity_measure": "determine" + if self.useMeasureSearchCheckbox.get() + else self.similarityMeasure.get(), "fingerprint_type": self.molecularDescriptor.get(), "tasks": tasks_dict, } diff --git a/interfaces/UI/libraries/README.md b/interfaces/UI/libraries/README.md new file mode 100644 index 00000000..251f58c2 --- /dev/null +++ b/interfaces/UI/libraries/README.md @@ -0,0 +1,3 @@ +This directory contains external GUI libraries used by AIMSim, which are both made available under the MIT license. + +They are included here rather than installed from a package manager for ease of packaging on our end. \ No newline at end of file diff --git a/interfaces/UI/libraries/customtkinter/__init__.py b/interfaces/UI/libraries/customtkinter/__init__.py new file mode 100644 index 00000000..f760bd64 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/__init__.py @@ -0,0 +1,109 @@ +__version__ = "4.6.3" + +import os +import sys +from tkinter.constants import * +from tkinter import StringVar, IntVar, DoubleVar, BooleanVar + +# import manager classes +from .settings import Settings +from .appearance_mode_tracker import AppearanceModeTracker +from .theme_manager import ThemeManager +from .scaling_tracker import ScalingTracker +from .font_manager import FontManager +from .draw_engine import DrawEngine + +AppearanceModeTracker.init_appearance_mode() + +# load default blue theme +try: + ThemeManager.load_theme("blue") +except FileNotFoundError as err: + raise FileNotFoundError(f"{err}\n\nThe .json theme file for CustomTkinter could not be found.\n" + + f"If packaging with pyinstaller was used, have a look at the wiki:\n" + + f"https://github.com/TomSchimansky/CustomTkinter/wiki/Packaging#windows-pyinstaller-auto-py-to-exe") + +FontManager.init_font_manager() + +# determine draw method based on current platform +if sys.platform == "darwin": + DrawEngine.preferred_drawing_method = "polygon_shapes" +else: + DrawEngine.preferred_drawing_method = "font_shapes" + +if sys.platform.startswith("win") and sys.getwindowsversion().build < 9000: # No automatic scaling on Windows < 8.1 + ScalingTracker.deactivate_automatic_dpi_awareness = True + +# load Roboto fonts (used on Windows/Linux) +script_directory = os.path.dirname(os.path.abspath(__file__)) +FontManager.load_font(os.path.join(script_directory, "assets", "fonts", "Roboto", "Roboto-Regular.ttf")) +FontManager.load_font(os.path.join(script_directory, "assets", "fonts", "Roboto", "Roboto-Medium.ttf")) + +# load font necessary for rendering the widgets (used on Windows/Linux) +if FontManager.load_font(os.path.join(script_directory, "assets", "fonts", "CustomTkinter_shapes_font.otf")) is False: + # change draw method if font loading failed + if DrawEngine.preferred_drawing_method == "font_shapes": + sys.stderr.write("customtkinter.__init__ warning: " + + "Preferred drawing method 'font_shapes' can not be used because the font file could not be loaded.\n" + + "Using 'circle_shapes' instead. The rendering quality will be bad!") + DrawEngine.preferred_drawing_method = "circle_shapes" + +# import widgets +from .widgets.widget_base_class import CTkBaseClass +from .widgets.ctk_button import CTkButton +from .widgets.ctk_checkbox import CTkCheckBox +from .widgets.ctk_entry import CTkEntry +from .widgets.ctk_slider import CTkSlider +from .widgets.ctk_frame import CTkFrame +from .widgets.ctk_progressbar import CTkProgressBar +from .widgets.ctk_label import CTkLabel +from .widgets.ctk_radiobutton import CTkRadioButton +from .widgets.ctk_canvas import CTkCanvas +from .widgets.ctk_switch import CTkSwitch +from .widgets.ctk_optionmenu import CTkOptionMenu +from .widgets.ctk_combobox import CTkComboBox +from .widgets.ctk_scrollbar import CTkScrollbar +from .widgets.ctk_textbox import CTkTextbox + +# import windows +from .windows.ctk_tk import CTk +from .windows.ctk_toplevel import CTkToplevel +from .windows.ctk_input_dialog import CTkInputDialog + + +def set_appearance_mode(mode_string: str): + """ possible values: light, dark, system """ + AppearanceModeTracker.set_appearance_mode(mode_string) + + +def get_appearance_mode() -> str: + """ get current state of the appearance mode (light or dark) """ + if AppearanceModeTracker.appearance_mode == 0: + return "Light" + elif AppearanceModeTracker.appearance_mode == 1: + return "Dark" + + +def set_default_color_theme(color_string: str): + """ set color theme or load custom theme file by passing the path """ + ThemeManager.load_theme(color_string) + + +def set_widget_scaling(scaling_value: float): + """ set scaling for the widget dimensions """ + ScalingTracker.set_widget_scaling(scaling_value) + + +def set_spacing_scaling(scaling_value: float): + """ set scaling for geometry manager calls (place, pack, grid)""" + ScalingTracker.set_spacing_scaling(scaling_value) + + +def set_window_scaling(scaling_value: float): + """ set scaling for window dimensions """ + ScalingTracker.set_window_scaling(scaling_value) + + +def deactivate_automatic_dpi_awareness(): + """ deactivate DPI awareness of current process (windll.shcore.SetProcessDpiAwareness(0)) """ + ScalingTracker.deactivate_automatic_dpi_awareness = False diff --git a/interfaces/UI/libraries/customtkinter/appearance_mode_tracker.py b/interfaces/UI/libraries/customtkinter/appearance_mode_tracker.py new file mode 100644 index 00000000..210b8bf9 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/appearance_mode_tracker.py @@ -0,0 +1,135 @@ +import sys +import tkinter +from distutils.version import StrictVersion as Version +from typing import Callable + +try: + import darkdetect + + if Version(darkdetect.__version__) < Version("0.3.1"): + sys.stderr.write("WARNING: You have to upgrade the darkdetect library: pip3 install --upgrade darkdetect\n") + if sys.platform != "darwin": + exit() +except ImportError as err: + raise err +except Exception: + sys.stderr.write("customtkinter.appearance_mode_tracker warning: failed to import darkdetect") + + +class AppearanceModeTracker: + + callback_list = [] + app_list = [] + update_loop_running = False + update_loop_interval = 500 # milliseconds + + appearance_mode_set_by = "system" + appearance_mode = 0 # Light (standard) + + @classmethod + def init_appearance_mode(cls): + if cls.appearance_mode_set_by == "system": + new_appearance_mode = cls.detect_appearance_mode() + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + @classmethod + def add(cls, callback: Callable, widget=None): + cls.callback_list.append(callback) + + if widget is not None: + app = cls.get_tk_root_of_widget(widget) + if app not in cls.app_list: + cls.app_list.append(app) + + if not cls.update_loop_running: + app.after(cls.update_loop_interval, cls.update) + cls.update_loop_running = True + + @classmethod + def remove(cls, callback: Callable): + try: + cls.callback_list.remove(callback) + except ValueError: + return + + @staticmethod + def detect_appearance_mode() -> int: + try: + if darkdetect.theme() == "Dark": + return 1 # Dark + else: + return 0 # Light + except NameError: + return 0 # Light + + @classmethod + def get_tk_root_of_widget(cls, widget): + current_widget = widget + + while isinstance(current_widget, tkinter.Tk) is False: + current_widget = current_widget.master + + return current_widget + + @classmethod + def update_callbacks(cls): + if cls.appearance_mode == 0: + for callback in cls.callback_list: + try: + callback("Light") + except Exception: + continue + + elif cls.appearance_mode == 1: + for callback in cls.callback_list: + try: + callback("Dark") + except Exception: + continue + + @classmethod + def update(cls): + if cls.appearance_mode_set_by == "system": + new_appearance_mode = cls.detect_appearance_mode() + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + # find an existing tkinter.Tk object for the next call of .after() + for app in cls.app_list: + try: + app.after(cls.update_loop_interval, cls.update) + return + except Exception: + continue + + cls.update_loop_running = False + + @classmethod + def get_mode(cls) -> int: + return cls.appearance_mode + + @classmethod + def set_appearance_mode(cls, mode_string: str): + if mode_string.lower() == "dark": + cls.appearance_mode_set_by = "user" + new_appearance_mode = 1 + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + elif mode_string.lower() == "light": + cls.appearance_mode_set_by = "user" + new_appearance_mode = 0 + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + elif mode_string.lower() == "system": + cls.appearance_mode_set_by = "system" diff --git a/interfaces/UI/libraries/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf b/interfaces/UI/libraries/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf new file mode 100644 index 00000000..a8910531 Binary files /dev/null and b/interfaces/UI/libraries/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf differ diff --git a/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf b/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf new file mode 100644 index 00000000..e89b0b79 Binary files /dev/null and b/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf b/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf new file mode 100644 index 00000000..3d6861b4 Binary files /dev/null and b/interfaces/UI/libraries/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/interfaces/UI/libraries/customtkinter/assets/themes/blue.json b/interfaces/UI/libraries/customtkinter/assets/themes/blue.json new file mode 100644 index 00000000..82de8609 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/assets/themes/blue.json @@ -0,0 +1,79 @@ +{ + "color": { + "window_bg_color": ["#EBEBEC", "#212325"], + "button": ["#3B8ED0", "#1F6AA5"], + "button_hover": ["#36719F", "#144870"], + "button_border": ["#3E454A", "#949A9F"], + "checkbox_border": ["#3E454A", "#949A9F"], + "checkmark": ["white", "gray90"], + "entry": ["#F9F9FA", "#343638"], + "entry_border": ["#979DA2", "#565B5E"], + "entry_placeholder_text": ["gray52", "gray62"], + "frame_border": ["#979DA2", "#1F2122"], + "frame_low": ["#D1D5D8", "#2A2D2E"], + "frame_high": ["#C0C2C5", "#343638"], + "label": [null, null], + "text": ["gray10", "#DCE4EE"], + "text_disabled": ["gray60", "#777B80"], + "text_button_disabled": ["gray40", "gray74"], + "progressbar": ["#939BA2", "#4A4D50"], + "progressbar_progress": ["#3B8ED0", "#1F6AA5"], + "progressbar_border": ["gray", "gray"], + "slider": ["#939BA2", "#4A4D50"], + "slider_progress": ["gray40", "#AAB0B5"], + "slider_button": ["#3B8ED0", "#1F6AA5"], + "slider_button_hover": ["#36719F", "#144870"], + "switch": ["#939BA2", "#4A4D50"], + "switch_progress": ["#3B8ED0", "#1F6AA5"], + "switch_button": ["gray36", "#D5D9DE"], + "switch_button_hover": ["gray20", "gray100"], + "optionmenu_button": ["#36719F", "#144870"], + "optionmenu_button_hover": ["#27577D", "#203A4F"], + "combobox_border": ["#979DA2", "#565B5E"], + "combobox_button_hover": ["#6E7174", "#7A848D"], + "dropdown_color": ["gray90", "gray20"], + "dropdown_hover": ["gray75", "gray28"], + "dropdown_text": ["gray10", "#DCE4EE"], + "scrollbar_button": ["gray55", "gray41"], + "scrollbar_button_hover": ["gray40", "gray53"] + }, + "text": { + "macOS": { + "font": "SF Display", + "size": -13 + }, + "Windows": { + "font": "Roboto", + "size": -13 + }, + "Linux": { + "font": "Roboto", + "size": -13 + } + }, + "shape": { + "button_corner_radius": 6, + "button_border_width": 0, + "checkbox_corner_radius": 6, + "checkbox_border_width": 3, + "radiobutton_corner_radius": 1000, + "radiobutton_border_width_unchecked": 3, + "radiobutton_border_width_checked": 6, + "entry_border_width": 2, + "frame_corner_radius": 6, + "frame_border_width": 0, + "label_corner_radius": 0, + "progressbar_border_width": 0, + "progressbar_corner_radius": 1000, + "slider_border_width": 6, + "slider_corner_radius": 1000, + "slider_button_length": 0, + "slider_button_corner_radius": 1000, + "switch_border_width": 3, + "switch_corner_radius": 1000, + "switch_button_corner_radius": 1000, + "switch_button_length": 0, + "scrollbar_corner_radius": 1000, + "scrollbar_border_spacing": 4 + } +} diff --git a/interfaces/UI/libraries/customtkinter/assets/themes/dark-blue.json b/interfaces/UI/libraries/customtkinter/assets/themes/dark-blue.json new file mode 100644 index 00000000..0b1a33f2 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/assets/themes/dark-blue.json @@ -0,0 +1,79 @@ +{ + "color": { + "window_bg_color": ["gray98", "gray10"], + "button": ["#608BD5", "#395E9C"], + "button_hover": ["#A4BDE6", "#748BB3"], + "button_border": ["gray40", "gray70"], + "checkbox_border": ["gray40", "gray60"], + "checkmark": ["white", "gray90"], + "entry": ["white", "gray24"], + "entry_border": ["gray70", "gray32"], + "entry_placeholder_text": ["gray52", "gray62"], + "frame_border": ["#A7C2E0", "#5FB4DD"], + "frame_low": ["gray92", "gray16"], + "frame_high": ["gray86", "gray20"], + "label": [null, null], + "text": ["gray12", "gray90"], + "text_disabled": ["gray60", "gray50"], + "text_button_disabled": ["gray40", "gray74"], + "progressbar": ["#6B6B6B", "gray0"], + "progressbar_progress": ["#608BD5", "#395E9C"], + "progressbar_border": ["gray", "gray"], + "slider": ["#6B6B6B", "gray6"], + "slider_progress": ["gray70", "gray30"], + "slider_button": ["#608BD5", "#395E9C"], + "slider_button_hover": ["#A4BDE6", "#748BB3"], + "switch": ["gray70", "gray35"], + "switch_progress": ["#608BD5", "#395E9C"], + "switch_button": ["gray38", "gray70"], + "switch_button_hover": ["gray30", "gray90"], + "optionmenu_button": ["#36719F", "#144870"], + "optionmenu_button_hover": ["#27577D", "#203A4F"], + "combobox_border": ["gray70", "gray32"], + "combobox_button_hover": ["#6E7174", "#7A848D"], + "dropdown_color": ["gray90", "gray20"], + "dropdown_hover": ["gray75", "gray28"], + "dropdown_text": ["gray10", "#DCE4EE"], + "scrollbar_button": ["gray55", "gray41"], + "scrollbar_button_hover": ["gray40", "gray53"] + }, + "text": { + "macOS": { + "font": "SF Display", + "size": -13 + }, + "Windows": { + "font": "Roboto", + "size": -13 + }, + "Linux": { + "font": "Roboto", + "size": -13 + } + }, + "shape": { + "button_corner_radius": 8, + "button_border_width": 0, + "checkbox_corner_radius": 7, + "checkbox_border_width": 3, + "radiobutton_corner_radius": 1000, + "radiobutton_border_width_unchecked": 3, + "radiobutton_border_width_checked": 6, + "entry_border_width": 2, + "frame_corner_radius": 10, + "frame_border_width": 0, + "label_corner_radius": 0, + "progressbar_border_width": 0, + "progressbar_corner_radius": 1000, + "slider_border_width": 6, + "slider_corner_radius": 8, + "slider_button_length": 0, + "slider_button_corner_radius": 1000, + "switch_border_width": 3, + "switch_corner_radius": 1000, + "switch_button_corner_radius": 1000, + "switch_button_length": 0, + "scrollbar_corner_radius": 1000, + "scrollbar_border_spacing": 4 + } +} diff --git a/interfaces/UI/libraries/customtkinter/assets/themes/green.json b/interfaces/UI/libraries/customtkinter/assets/themes/green.json new file mode 100644 index 00000000..d3e94421 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/assets/themes/green.json @@ -0,0 +1,79 @@ +{ + "color": { + "window_bg_color": ["gray92", "gray12"], + "button": ["#72CF9F", "#11B384"], + "button_hover": ["#0E9670", "#0D8A66"], + "button_border": ["gray40", "gray70"], + "checkbox_border": ["gray40", "gray60"], + "checkmark": ["white", "gray90"], + "entry": ["white", "gray24"], + "entry_border": ["gray70", "gray32"], + "entry_placeholder_text": ["gray52", "gray62"], + "frame_border": ["#A7C2E0", "#5FB4DD"], + "frame_low": ["gray87", "gray18"], + "frame_high": ["gray82", "gray22"], + "label": [null, null], + "text": ["gray20", "gray90"], + "text_disabled": ["gray60", "gray50"], + "text_button_disabled": ["gray40", "gray74"], + "progressbar": ["#6B6B6B", "#222222"], + "progressbar_progress": ["#72CF9F", "#11B384"], + "progressbar_border": ["gray", "gray"], + "slider": ["#6B6B6B", "#222222"], + "slider_progress": ["white", "#555555"], + "slider_button": ["#72CF9F", "#11B384"], + "slider_button_hover": ["#0E9670", "#0D8A66"], + "switch": ["gray70", "gray35"], + "switch_progress": ["#72CF9F", "#11B384"], + "switch_button": ["gray38", "gray70"], + "switch_button_hover": ["gray30", "gray90"], + "optionmenu_button": ["#0E9670", "#0D8A66"], + "optionmenu_button_hover":["gray40", "gray70"], + "combobox_border": ["gray70", "gray32"], + "combobox_button_hover": ["#6E7174", "#7A848D"], + "dropdown_color": ["gray90", "gray20"], + "dropdown_hover": ["gray75", "gray28"], + "dropdown_text": ["gray10", "#DCE4EE"], + "scrollbar_button": ["gray55", "gray41"], + "scrollbar_button_hover": ["gray40", "gray53"] + }, + "text": { + "macOS": { + "font": "SF Display", + "size": -13 + }, + "Windows": { + "font": "Roboto", + "size": -13 + }, + "Linux": { + "font": "Roboto", + "size": -13 + } + }, + "shape": { + "button_corner_radius": 6, + "button_border_width": 0, + "checkbox_corner_radius": 7, + "checkbox_border_width": 3, + "radiobutton_corner_radius": 1000, + "radiobutton_border_width_unchecked": 3, + "radiobutton_border_width_checked": 6, + "entry_border_width": 2, + "frame_corner_radius": 10, + "frame_border_width": 0, + "label_corner_radius": 0, + "progressbar_border_width": 0, + "progressbar_corner_radius": 1000, + "slider_border_width": 6, + "slider_corner_radius": 8, + "slider_button_length": 0, + "slider_button_corner_radius": 1000, + "switch_border_width": 3, + "switch_corner_radius": 1000, + "switch_button_corner_radius": 1000, + "switch_button_length": 0, + "scrollbar_corner_radius": 1000, + "scrollbar_border_spacing": 4 + } +} diff --git a/interfaces/UI/libraries/customtkinter/assets/themes/sweetkind.json b/interfaces/UI/libraries/customtkinter/assets/themes/sweetkind.json new file mode 100644 index 00000000..178fec11 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/assets/themes/sweetkind.json @@ -0,0 +1,79 @@ +{ + "color": { + "window_bg_color": ["#ebf0f5", "#181b28"], + "button": ["#e46bff", "#212435"], + "button_hover": ["#8593d6", "#171926"], + "button_border": ["#525983", "#080b12"], + "checkbox_border": ["#01e9c4", "#01e9c4"], + "checkmark": ["#01e9c4", "#01e9c4"], + "entry": ["#dee2e7", "#212435"], + "entry_border": ["#fa00d0", "#080b12"], + "entry_placeholder_text": ["#cdc8ce", "#cdc8ce"], + "frame_border": ["#525983", "#10121f"], + "frame_low": ["#dee2e7", "#181b28"], + "frame_high": ["#dee2e7", "#1b1e2d"], + "label": [null, null], + "text": ["#0c0e14", "#cdc8ce"], + "text_disabled": ["#5e6062", "#7a8894"], + "text_button_disabled": ["#7a8894", "#7a8894"], + "progressbar": ["#fa00d0", "#fa00d0"], + "progressbar_progress": ["#363844", "#363844"], + "progressbar_border": ["#fa00d0", "#0d101f"], + "slider": ["#fa00d0", "#fa00d0"], + "slider_progress": ["#0d101f", "#0d101f"], + "slider_button": ["#fa00d0", "#fa00d0"], + "slider_button_hover": ["#e46bff", "#fa00d0"], + "switch": ["#7681be", "#1f2233"], + "switch_progress": ["#00e6c3", "#00e6c3"], + "switch_button": ["#525983", "#2e324a"], + "switch_button_hover": ["#fa00d0", "#2e324a"], + "optionmenu_button": ["#525983", "#080b12"], + "optionmenu_button_hover": ["#fa00d0", "#080b12"], + "combobox_border": ["#525983", "#080b12"], + "combobox_button_hover": ["#fa00d0", "#fa00d0"], + "dropdown_color": ["#dee2e7", "#212435"], + "dropdown_hover": ["#fa00d0", "#fa00d0"], + "dropdown_text": ["#0c0e14", "#cdc8ce"], + "scrollbar_button": ["#fa00d0", "#fa00d0"], + "scrollbar_button_hover": ["#9b45ff", "#9b45ff"] + }, + "text": { + "macOS": { + "font": "SF Display", + "size": -13 + }, + "Windows": { + "font": "Roboto", + "size": -13 + }, + "Linux": { + "font": "Roboto", + "size": -13 + } + }, + "shape": { + "button_corner_radius": 8, + "button_border_width": 1, + "checkbox_corner_radius": 7, + "checkbox_border_width": 1, + "radiobutton_corner_radius": 1000, + "radiobutton_border_width_unchecked": 2, + "radiobutton_border_width_checked": 6, + "entry_border_width": 1, + "frame_corner_radius": 10, + "frame_border_width": 1, + "label_corner_radius": 3, + "progressbar_border_width": 2, + "progressbar_corner_radius": 1000, + "slider_border_width": 6, + "slider_corner_radius": 8, + "slider_button_length": 0, + "slider_button_corner_radius": 1000, + "switch_border_width": 3, + "switch_corner_radius": 1000, + "switch_button_corner_radius": 1000, + "switch_button_length": 2, + "scrollbar_corner_radius": 1000, + "scrollbar_border_spacing": 4 + } +} diff --git a/interfaces/UI/libraries/customtkinter/draw_engine.py b/interfaces/UI/libraries/customtkinter/draw_engine.py new file mode 100644 index 00000000..eafaeb2c --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/draw_engine.py @@ -0,0 +1,1181 @@ +from __future__ import annotations +import sys +import math +import tkinter +from typing import Union, TYPE_CHECKING + +if TYPE_CHECKING: + from .widgets.ctk_canvas import CTkCanvas + + +class DrawEngine: + """ + This is the core of the CustomTkinter library where all the drawing on the tkinter.Canvas happens. + A year of experimenting and trying out different drawing methods have led to the current state of this + class, and I don't think there's much I can do to make the rendering look better than this with the + limited capabilities the tkinter.Canvas offers. + + Functions: + - draw_rounded_rect_with_border() + - draw_rounded_rect_with_border_vertical_split() + - draw_rounded_progress_bar_with_border() + - draw_rounded_slider_with_border_and_button() + - draw_rounded_scrollbar() + - draw_checkmark() + - draw_dropdown_arrow() + + """ + + preferred_drawing_method: str = None # 'polygon_shapes', 'font_shapes', 'circle_shapes' + + def __init__(self, canvas: CTkCanvas): + self._canvas = canvas + + def __calc_optimal_corner_radius(self, user_corner_radius: Union[float, int]) -> Union[float, int]: + # optimize for drawing with polygon shapes + if self.preferred_drawing_method == "polygon_shapes": + if sys.platform == "darwin": + return user_corner_radius + else: + return round(user_corner_radius) + + # optimize for drawing with antialiased font shapes + elif self.preferred_drawing_method == "font_shapes": + return round(user_corner_radius) + + # optimize for drawing with circles and rects + elif self.preferred_drawing_method == "circle_shapes": + user_corner_radius = 0.5 * round(user_corner_radius / 0.5) # round to 0.5 steps + + # make sure the value is always with .5 at the end for smoother corners + if user_corner_radius == 0: + return 0 + elif user_corner_radius % 1 == 0: + return user_corner_radius + 0.5 + else: + return user_corner_radius + + def draw_rounded_rect_with_border(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], overwrite_preferred_drawing_method: str = None) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas. The border elements have a 'border_parts' tag, + the main foreground elements have an 'inner_parts' tag to color the elements accordingly. + + returns bool if recoloring is necessary """ + + width = math.floor(width / 2) * 2 # round (floor) _current_width and _current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + corner_radius = round(corner_radius) + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if overwrite_preferred_drawing_method is not None: + preferred_drawing_method = overwrite_preferred_drawing_method + else: + preferred_drawing_method = self.preferred_drawing_method + + if preferred_drawing_method == "polygon_shapes": + return self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + elif preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ()) + elif preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_circle_shapes(width, height, corner_radius, border_width, inner_corner_radius) + + def __draw_rounded_rect_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + requires_recoloring = False + + # create border button parts (only if border exists) + if border_width > 0: + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_1", "border_parts")) + requires_recoloring = True + + self._canvas.coords("border_line_1", + (corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius)) + self._canvas.itemconfig("border_line_1", + joinstyle=tkinter.ROUND, + width=corner_radius * 2) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if not self._canvas.find_withtag("inner_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_1", "inner_parts"), joinstyle=tkinter.ROUND) + requires_recoloring = True + + if corner_radius <= border_width: + bottom_right_shift = -1 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + self._canvas.coords("inner_line_1", + border_width + inner_corner_radius, + border_width + inner_corner_radius, + width - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius, + width - (border_width + inner_corner_radius) + bottom_right_shift, + height - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius, + height - (border_width + inner_corner_radius) + bottom_right_shift) + self._canvas.itemconfig("inner_line_1", + width=inner_corner_radius * 2) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + exclude_parts: tuple) -> bool: + requires_recoloring = False + + # create border button parts + if border_width > 0: + if corner_radius > 0: + # create canvas border corner parts if not already created, but only if needed, and delete if not needed + if not self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" in exclude_parts: + self._canvas.delete("border_oval_1_a", "border_oval_1_b") + + if not self._canvas.find_withtag("border_oval_2_a") and width > 2 * corner_radius and "border_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_2_a") and (not width > 2 * corner_radius or "border_oval_2" in exclude_parts): + self._canvas.delete("border_oval_2_a", "border_oval_2_b") + + if not self._canvas.find_withtag("border_oval_3_a") and height > 2 * corner_radius \ + and width > 2 * corner_radius and "border_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and (not (height > 2 * corner_radius + and width > 2 * corner_radius) or "border_oval_3" in exclude_parts): + self._canvas.delete("border_oval_3_a", "border_oval_3_b") + + if not self._canvas.find_withtag("border_oval_4_a") and height > 2 * corner_radius and "border_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_4_a") and (not height > 2 * corner_radius or "border_oval_4" in exclude_parts): + self._canvas.delete("border_oval_4_a", "border_oval_4_b") + + # change position of border corner parts + self._canvas.coords("border_oval_1_a", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_1_b", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_a", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_b", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_3_a", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_3_b", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_a", corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_b", corner_radius, height - corner_radius, corner_radius) + + else: + self._canvas.delete("border_corner_part") # delete border corner parts if not needed + + # create canvas border rectangle parts if not already created + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_rectangle_part", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_2", "border_rectangle_part", "border_parts"), width=0) + requires_recoloring = True + + # change position of border rectangle parts + self._canvas.coords("border_rectangle_1", (0, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_2", (corner_radius, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if inner_corner_radius > 0: + + # create canvas border corner parts if not already created, but only if they're needed and delete if not needed + if not self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" in exclude_parts: + self._canvas.delete("inner_oval_1_a", "inner_oval_1_b") + + if not self._canvas.find_withtag("inner_oval_2_a") and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_2_a") and (not width - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_2" in exclude_parts): + self._canvas.delete("inner_oval_2_a", "inner_oval_2_b") + + if not self._canvas.find_withtag("inner_oval_3_a") and height - (2 * border_width) > 2 * inner_corner_radius \ + and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_3_a") and (not (height - (2 * border_width) > 2 * inner_corner_radius + and width - (2 * border_width) > 2 * inner_corner_radius) or "inner_oval_3" in exclude_parts): + self._canvas.delete("inner_oval_3_a", "inner_oval_3_b") + + if not self._canvas.find_withtag("inner_oval_4_a") and height - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_4_a") and (not height - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_4" in exclude_parts): + self._canvas.delete("inner_oval_4_a", "inner_oval_4_b") + + # change position of border corner parts + self._canvas.coords("inner_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_1_b", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_a", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_b", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_a", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_b", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_a", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_b", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + # create canvas inner rectangle parts if not already created + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_1", "inner_rectangle_part", "inner_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("inner_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_2", "inner_rectangle_part", "inner_parts"), width=0) + requires_recoloring = True + + elif self._canvas.find_withtag("inner_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("inner_rectangle_2") + + # change position of inner rectangle parts + self._canvas.coords("inner_rectangle_1", (border_width + inner_corner_radius, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_2", (border_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_circle_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + requires_recoloring = False + + # border button parts + if border_width > 0: + if corner_radius > 0: + + if not self._canvas.find_withtag("border_oval_1"): + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_1", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_2", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_3", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_4", "border_corner_part", "border_parts"), width=0) + self._canvas.tag_lower("border_parts") + requires_recoloring = True + + self._canvas.coords("border_oval_1", 0, 0, corner_radius * 2 - 1, corner_radius * 2 - 1) + self._canvas.coords("border_oval_2", width - corner_radius * 2, 0, width - 1, corner_radius * 2 - 1) + self._canvas.coords("border_oval_3", 0, height - corner_radius * 2, corner_radius * 2 - 1, height - 1) + self._canvas.coords("border_oval_4", width - corner_radius * 2, height - corner_radius * 2, width - 1, height - 1) + + else: + self._canvas.delete("border_corner_part") + + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_rectangle_part", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_2", "border_rectangle_part", "border_parts"), width=0) + self._canvas.tag_lower("border_parts") + requires_recoloring = True + + self._canvas.coords("border_rectangle_1", (0, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_2", (corner_radius, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # inner button parts + if inner_corner_radius > 0: + + if not self._canvas.find_withtag("inner_oval_1"): + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_1", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_2", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_3", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_4", "inner_corner_part", "inner_parts"), width=0) + self._canvas.tag_raise("inner_parts") + requires_recoloring = True + + self._canvas.coords("inner_oval_1", (border_width, border_width, + border_width + inner_corner_radius * 2 - 1, border_width + inner_corner_radius * 2 - 1)) + self._canvas.coords("inner_oval_2", (width - border_width - inner_corner_radius * 2, border_width, + width - border_width - 1, border_width + inner_corner_radius * 2 - 1)) + self._canvas.coords("inner_oval_3", (border_width, height - border_width - inner_corner_radius * 2, + border_width + inner_corner_radius * 2 - 1, height - border_width - 1)) + self._canvas.coords("inner_oval_4", (width - border_width - inner_corner_radius * 2, height - border_width - inner_corner_radius * 2, + width - border_width - 1, height - border_width - 1)) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_1", "inner_rectangle_part", "inner_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_2", "inner_rectangle_part", "inner_parts"), width=0) + self._canvas.tag_raise("inner_parts") + requires_recoloring = True + + self._canvas.coords("inner_rectangle_1", (border_width + inner_corner_radius, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_2", (border_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + return requires_recoloring + + def draw_rounded_rect_with_border_vertical_split(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], left_section_width: Union[float, int]) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas which is split at left_section_width. + The border elements have the tags 'border_parts_left', 'border_parts_lright', + the main foreground elements have an 'inner_parts_left' and inner_parts_right' tag, + to color the elements accordingly. + + returns bool if recoloring is necessary """ + + left_section_width = round(left_section_width) + width = math.floor(width / 2) * 2 # round (floor) _current_width and _current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + corner_radius = round(corner_radius) + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if left_section_width > width - corner_radius * 2: + left_section_width = width - corner_radius * 2 + elif left_section_width < corner_radius * 2: + left_section_width = corner_radius * 2 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width, ()) + + def __draw_rounded_rect_with_border_vertical_split_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int) -> bool: + requires_recoloring = False + + # create border button parts (only if border exists) + if border_width > 0: + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_left_1", "border_parts_left", "border_parts", "left_parts")) + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_right_1", "border_parts_right", "border_parts", "right_parts")) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_left_1", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_right_1", "border_parts_right", "border_parts", "right_parts"), width=0) + requires_recoloring = True + + self._canvas.coords("border_line_left_1", + (corner_radius, + corner_radius, + left_section_width - corner_radius, + corner_radius, + left_section_width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius)) + self._canvas.coords("border_line_right_1", + (left_section_width + corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + corner_radius, + height - corner_radius)) + self._canvas.coords("border_rect_left_1", + (left_section_width - corner_radius, + 0, + left_section_width, + height)) + self._canvas.coords("border_rect_right_1", + (left_section_width, + 0, + left_section_width + corner_radius, + height)) + self._canvas.itemconfig("border_line_left_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + self._canvas.itemconfig("border_line_right_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if not self._canvas.find_withtag("inner_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_left_1", "inner_parts_left", "inner_parts", "left_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_right_1", "inner_parts_right", "inner_parts", "right_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_left_1", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_right_1", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + self._canvas.coords("inner_line_left_1", + corner_radius, + corner_radius, + left_section_width - inner_corner_radius, + corner_radius, + left_section_width - inner_corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius) + self._canvas.coords("inner_line_right_1", + left_section_width + inner_corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + inner_corner_radius, + height - corner_radius) + self._canvas.coords("inner_rect_left_1", + (left_section_width - inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rect_right_1", + (left_section_width, + border_width, + left_section_width + inner_corner_radius, + height - border_width)) + self._canvas.itemconfig("inner_line_left_1", width=inner_corner_radius * 2) + self._canvas.itemconfig("inner_line_right_1", width=inner_corner_radius * 2) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_vertical_split_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int, exclude_parts: tuple) -> bool: + requires_recoloring = False + + # create border button parts + if border_width > 0: + if corner_radius > 0: + # create canvas border corner parts if not already created, but only if needed, and delete if not needed + if not self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_a", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_b", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" in exclude_parts: + self._canvas.delete("border_oval_1_a", "border_oval_1_b") + + if not self._canvas.find_withtag("border_oval_2_a") and width > 2 * corner_radius and "border_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_a", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_b", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_2_a") and (not width > 2 * corner_radius or "border_oval_2" in exclude_parts): + self._canvas.delete("border_oval_2_a", "border_oval_2_b") + + if not self._canvas.find_withtag("border_oval_3_a") and height > 2 * corner_radius \ + and width > 2 * corner_radius and "border_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_a", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_b", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and (not (height > 2 * corner_radius + and width > 2 * corner_radius) or "border_oval_3" in exclude_parts): + self._canvas.delete("border_oval_3_a", "border_oval_3_b") + + if not self._canvas.find_withtag("border_oval_4_a") and height > 2 * corner_radius and "border_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_a", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_b", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_4_a") and (not height > 2 * corner_radius or "border_oval_4" in exclude_parts): + self._canvas.delete("border_oval_4_a", "border_oval_4_b") + + # change position of border corner parts + self._canvas.coords("border_oval_1_a", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_1_b", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_a", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_b", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_3_a", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_3_b", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_a", corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_b", corner_radius, height - corner_radius, corner_radius) + + else: + self._canvas.delete("border_corner_part") # delete border corner parts if not needed + + # create canvas border rectangle parts if not already created + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_1", "border_rectangle_part", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_2", "border_rectangle_part", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_1", "border_rectangle_part", "border_parts_right", "border_parts", "right_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_2", "border_rectangle_part", "border_parts_right", "border_parts", "right_parts"), width=0) + requires_recoloring = True + + # change position of border rectangle parts + self._canvas.coords("border_rectangle_left_1", (0, corner_radius, left_section_width, height - corner_radius)) + self._canvas.coords("border_rectangle_left_2", (corner_radius, 0, left_section_width, height)) + self._canvas.coords("border_rectangle_right_1", (left_section_width, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_right_2", (left_section_width, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if inner_corner_radius > 0: + + # create canvas border corner parts if not already created, but only if they're needed and delete if not needed + if not self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_a", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_b", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" in exclude_parts: + self._canvas.delete("inner_oval_1_a", "inner_oval_1_b") + + if not self._canvas.find_withtag("inner_oval_2_a") and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_a", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_b", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_2_a") and (not width - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_2" in exclude_parts): + self._canvas.delete("inner_oval_2_a", "inner_oval_2_b") + + if not self._canvas.find_withtag("inner_oval_3_a") and height - (2 * border_width) > 2 * inner_corner_radius \ + and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_a", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_b", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_3_a") and (not (height - (2 * border_width) > 2 * inner_corner_radius + and width - (2 * border_width) > 2 * inner_corner_radius) or "inner_oval_3" in exclude_parts): + self._canvas.delete("inner_oval_3_a", "inner_oval_3_b") + + if not self._canvas.find_withtag("inner_oval_4_a") and height - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_a", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_b", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_4_a") and (not height - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_4" in exclude_parts): + self._canvas.delete("inner_oval_4_a", "inner_oval_4_b") + + # change position of border corner parts + self._canvas.coords("inner_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_1_b", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_a", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_b", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_a", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_b", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_a", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_b", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + # create canvas inner rectangle parts if not already created + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_1", "inner_rectangle_part", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_1", "inner_rectangle_part", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("inner_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_2", "inner_rectangle_part", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_2", "inner_rectangle_part", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + elif self._canvas.find_withtag("inner_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("inner_rectangle_left_2") + self._canvas.delete("inner_rectangle_right_2") + + # change position of inner rectangle parts + self._canvas.coords("inner_rectangle_left_1", (border_width + inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rectangle_left_2", (border_width, + border_width + inner_corner_radius, + left_section_width, + height - inner_corner_radius - border_width)) + self._canvas.coords("inner_rectangle_right_1", (left_section_width, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_right_2", (left_section_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + + def draw_rounded_progress_bar_with_border(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + """ Draws a rounded bar on the canvas, and onntop sits a progress bar from value 1 to value 2 (range 0-1, left to right, bottom to top). + The border elements get the 'border_parts' tag", the main elements get the 'inner_parts' tag and + the progress elements get the 'progress_parts' tag. The 'orientation' argument defines from which direction the progress starts (n, w, s, e). + + returns bool if recoloring is necessary """ + + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value_1, progress_value_2, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value_1, progress_value_2, orientation) + + def __draw_rounded_progress_bar_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + + requires_recoloring = self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + + if corner_radius <= border_width: + bottom_right_shift = 0 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + # create progress parts + if not self._canvas.find_withtag("progress_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("progress_line_1", "progress_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("progress_parts", "inner_parts") + requires_recoloring = True + + if orientation == "w": + self._canvas.coords("progress_line_1", + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - (border_width + inner_corner_radius) + bottom_right_shift) + + elif orientation == "s": + self._canvas.coords("progress_line_1", + border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - (border_width + inner_corner_radius), + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - (border_width + inner_corner_radius), + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), + border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + + self._canvas.itemconfig("progress_line_1", width=inner_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_progress_bar_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + + requires_recoloring, requires_recoloring_2 = False, False + + if inner_corner_radius > 0: + # create canvas border corner parts if not already created + if not self._canvas.find_withtag("progress_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_1_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_1_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_2_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_2_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("progress_oval_3_a") and round(inner_corner_radius) * 2 < height - 2 * border_width: + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_3_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_3_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_4_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_4_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("progress_oval_3_a") and not round(inner_corner_radius) * 2 < height - 2 * border_width: + self._canvas.delete("progress_oval_3_a", "progress_oval_3_b", "progress_oval_4_a", "progress_oval_4_b") + + if not self._canvas.find_withtag("progress_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("progress_rectangle_1", "progress_rectangle_part", "progress_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("progress_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("progress_rectangle_2", "progress_rectangle_part", "progress_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("progress_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("progress_rectangle_2") + + # horizontal orientation from the bottom + if orientation == "w": + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ()) + + # set positions of progress corner parts + self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_1_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_2_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_2_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_3_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_3_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_4_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_4_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - border_width - inner_corner_radius, inner_corner_radius) + + # set positions of progress rect parts + self._canvas.coords("progress_rectangle_1", + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width) + self._canvas.coords("progress_rectangle_2", + border_width + 2 * inner_corner_radius + (width - 2 * inner_corner_radius - 2 * border_width) * progress_value_1, + border_width + inner_corner_radius, + border_width + 2 * inner_corner_radius + (width - 2 * inner_corner_radius - 2 * border_width) * progress_value_2, + height - inner_corner_radius - border_width) + + # vertical orientation from the bottom + if orientation == "s": + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ()) + + # set positions of progress corner parts + self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_1_b", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_2_a", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_2_b", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_3_a", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_3_b", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_4_a", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_4_b", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + + # set positions of progress rect parts + self._canvas.coords("progress_rectangle_1", + border_width + inner_corner_radius, + border_width + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - border_width - inner_corner_radius, + border_width + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + self._canvas.coords("progress_rectangle_2", + border_width, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - border_width, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + + return requires_recoloring or requires_recoloring_2 + + def draw_rounded_slider_with_border_and_button(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], button_length: Union[float, int], button_corner_radius: Union[float, int], + slider_value: float, orientation: str) -> bool: + + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + if button_corner_radius > width / 2 or button_corner_radius > height / 2: # restrict button_corner_radius if it's too larger + button_corner_radius = min(width / 2, height / 2) + + button_length = round(button_length) + border_width = round(border_width) + button_corner_radius = round(button_corner_radius) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_slider_with_border_and_button_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_slider_with_border_and_button_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) + + def __draw_rounded_slider_with_border_and_button_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + + # draw normal progressbar + requires_recoloring = self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + 0, slider_value, orientation) + + # create slider button part + if not self._canvas.find_withtag("slider_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("slider_line_1", "slider_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("slider_parts") # manage z-order + requires_recoloring = True + + if corner_radius <= border_width: + bottom_right_shift = -1 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + if orientation == "w": + slider_x_position = corner_radius + (button_length / 2) + (width - 2 * corner_radius - button_length) * slider_value + self._canvas.coords("slider_line_1", + slider_x_position - (button_length / 2), button_corner_radius, + slider_x_position + (button_length / 2), button_corner_radius, + slider_x_position + (button_length / 2), height - button_corner_radius, + slider_x_position - (button_length / 2), height - button_corner_radius) + self._canvas.itemconfig("slider_line_1", + width=button_corner_radius * 2) + elif orientation == "s": + slider_y_position = corner_radius + (button_length / 2) + (height - 2 * corner_radius - button_length) * (1 - slider_value) + self._canvas.coords("slider_line_1", + button_corner_radius, slider_y_position - (button_length / 2), + button_corner_radius, slider_y_position + (button_length / 2), + width - button_corner_radius, slider_y_position + (button_length / 2), + width - button_corner_radius, slider_y_position - (button_length / 2)) + self._canvas.itemconfig("slider_line_1", + width=button_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_slider_with_border_and_button_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + + # draw normal progressbar + requires_recoloring = self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + 0, slider_value, orientation) + + # create 4 circles (if not needed, then less) + if not self._canvas.find_withtag("slider_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_1_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_1_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("slider_oval_2_a") and button_length > 0: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_2_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_2_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("slider_oval_2_a") and not button_length > 0: + self._canvas.delete("slider_oval_2_a", "slider_oval_2_b") + + if not self._canvas.find_withtag("slider_oval_4_a") and height > 2 * button_corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_4_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_4_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("slider_oval_4_a") and not height > 2 * button_corner_radius: + self._canvas.delete("slider_oval_4_a", "slider_oval_4_b") + + if not self._canvas.find_withtag("slider_oval_3_a") and button_length > 0 and height > 2 * button_corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_3_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_3_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and not (button_length > 0 and height > 2 * button_corner_radius): + self._canvas.delete("slider_oval_3_a", "slider_oval_3_b") + + # create the 2 rectangles (if needed) + if not self._canvas.find_withtag("slider_rectangle_1") and button_length > 0: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("slider_rectangle_1", "slider_rectangle_part", "slider_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("slider_rectangle_1") and not button_length > 0: + self._canvas.delete("slider_rectangle_1") + + if not self._canvas.find_withtag("slider_rectangle_2") and height > 2 * button_corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("slider_rectangle_2", "slider_rectangle_part", "slider_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("slider_rectangle_2") and not height > 2 * button_corner_radius: + self._canvas.delete("slider_rectangle_2") + + # set positions of circles and rectangles + if orientation == "w": + slider_x_position = corner_radius + (button_length / 2) + (width - 2 * corner_radius - button_length) * slider_value + self._canvas.coords("slider_oval_1_a", slider_x_position - (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_1_b", slider_x_position - (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_2_a", slider_x_position + (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_2_b", slider_x_position + (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_3_a", slider_x_position + (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_3_b", slider_x_position + (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_4_a", slider_x_position - (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_4_b", slider_x_position - (button_length / 2), height - button_corner_radius, button_corner_radius) + + self._canvas.coords("slider_rectangle_1", + slider_x_position - (button_length / 2), 0, + slider_x_position + (button_length / 2), height) + self._canvas.coords("slider_rectangle_2", + slider_x_position - (button_length / 2) - button_corner_radius, button_corner_radius, + slider_x_position + (button_length / 2) + button_corner_radius, height - button_corner_radius) + + elif orientation == "s": + slider_y_position = corner_radius + (button_length / 2) + (height - 2 * corner_radius - button_length) * (1 - slider_value) + self._canvas.coords("slider_oval_1_a", button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_1_b", button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_2_a", button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_2_b", button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_3_a", width - button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_3_b", width - button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_4_a", width - button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_4_b", width - button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + + self._canvas.coords("slider_rectangle_1", + 0, slider_y_position - (button_length / 2), + width, slider_y_position + (button_length / 2)) + self._canvas.coords("slider_rectangle_2", + button_corner_radius, slider_y_position - (button_length / 2) - button_corner_radius, + width - button_corner_radius, slider_y_position + (button_length / 2) + button_corner_radius) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_raise("slider_parts") + + return requires_recoloring + + def draw_rounded_scrollbar(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_spacing: Union[float, int], start_value: float, end_value: float, orientation: str) -> bool: + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_spacing = round(border_spacing) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_spacing: + inner_corner_radius = corner_radius - border_spacing + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_scrollbar_polygon_shapes(width, height, corner_radius, inner_corner_radius, + start_value, end_value, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_scrollbar_font_shapes(width, height, corner_radius, inner_corner_radius, + start_value, end_value, orientation) + + def __draw_rounded_scrollbar_polygon_shapes(self, width: int, height: int, corner_radius: int, inner_corner_radius: int, + start_value: float, end_value: float, orientation: str) -> bool: + requires_recoloring = False + + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_parts"), width=0) + requires_recoloring = True + self._canvas.coords("border_rectangle_1", 0, 0, width, height) + + if not self._canvas.find_withtag("scrollbar_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("scrollbar_polygon_1", "scrollbar_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("scrollbar_parts", "border_parts") + requires_recoloring = True + + if orientation == "vertical": + self._canvas.coords("scrollbar_polygon_1", + corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, + corner_radius, corner_radius + (height - 2 * corner_radius) * end_value) + elif orientation == "horizontal": + self._canvas.coords("scrollbar_polygon_1", + corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, + corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius,) + + self._canvas.itemconfig("scrollbar_polygon_1", width=inner_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_scrollbar_font_shapes(self, width: int, height: int, corner_radius: int, inner_corner_radius: int, + start_value: float, end_value: float, orientation: str) -> bool: + requires_recoloring = False + + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_parts"), width=0) + requires_recoloring = True + self._canvas.coords("border_rectangle_1", 0, 0, width, height) + + if inner_corner_radius > 0: + if not self._canvas.find_withtag("scrollbar_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_1_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_1_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("scrollbar_oval_2_a") and width > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_2_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_2_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_2_a") and not width > 2 * corner_radius: + self._canvas.delete("scrollbar_oval_2_a", "scrollbar_oval_2_b") + + if not self._canvas.find_withtag("scrollbar_oval_3_a") and height > 2 * corner_radius and width > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_3_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_3_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_3_a") and not (height > 2 * corner_radius and width > 2 * corner_radius): + self._canvas.delete("scrollbar_oval_3_a", "scrollbar_oval_3_b") + + if not self._canvas.find_withtag("scrollbar_oval_4_a") and height > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_4_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_4_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_4_a") and not height > 2 * corner_radius: + self._canvas.delete("scrollbar_oval_4_a", "scrollbar_oval_4_b") + else: + self._canvas.delete("scrollbar_corner_part") + + if not self._canvas.find_withtag("scrollbar_rectangle_1") and height > 2 * corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("scrollbar_rectangle_1", "scrollbar_rectangle_part", "scrollbar_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_rectangle_1") and not height > 2 * corner_radius: + self._canvas.delete("scrollbar_rectangle_1") + + if not self._canvas.find_withtag("scrollbar_rectangle_2") and width > 2 * corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("scrollbar_rectangle_2", "scrollbar_rectangle_part", "scrollbar_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_rectangle_2") and not width > 2 * corner_radius: + self._canvas.delete("scrollbar_rectangle_2") + + if orientation == "vertical": + self._canvas.coords("scrollbar_rectangle_1", + corner_radius - inner_corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - (corner_radius - inner_corner_radius), corner_radius + (height - 2 * corner_radius) * end_value) + self._canvas.coords("scrollbar_rectangle_2", + corner_radius, corner_radius - inner_corner_radius + (height - 2 * corner_radius) * start_value, + width - (corner_radius), corner_radius + inner_corner_radius + (height - 2 * corner_radius) * end_value) + + self._canvas.coords("scrollbar_oval_1_a", corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_1_b", corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_a", width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_b", width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_a", width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_b", width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_a", corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_b", corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + + if orientation == "horizontal": + self._canvas.coords("scrollbar_rectangle_1", + corner_radius - inner_corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, + corner_radius + inner_corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius) + self._canvas.coords("scrollbar_rectangle_2", + corner_radius + (width - 2 * corner_radius) * start_value, corner_radius - inner_corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, height - (corner_radius - inner_corner_radius)) + + self._canvas.coords("scrollbar_oval_1_a", corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_1_b", corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_a", corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_b", corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_a", corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_b", corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_a", corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_b", corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius, inner_corner_radius) + + return requires_recoloring + + def draw_checkmark(self, width: Union[float, int], height: Union[float, int], size: Union[int, float]) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas. The border elements have a 'border_parts' tag, + the main foreground elements have an 'inner_parts' tag to color the elements accordingly. + + returns bool if recoloring is necessary """ + + size = round(size) + requires_recoloring = False + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + x, y, radius = width / 2, height / 2, size / 2.8 + if not self._canvas.find_withtag("checkmark"): + self._canvas.create_line(0, 0, 0, 0, tags=("checkmark", "create_line"), width=round(height / 8), joinstyle=tkinter.MITER, capstyle=tkinter.ROUND) + self._canvas.tag_raise("checkmark") + requires_recoloring = True + + self._canvas.coords("checkmark", + x + radius, y - radius, + x - radius / 4, y + radius * 0.8, + x - radius, y + radius / 6) + elif self.preferred_drawing_method == "font_shapes": + if not self._canvas.find_withtag("checkmark"): + self._canvas.create_text(0, 0, text="Z", font=("CustomTkinter_shapes_font", -size), tags=("checkmark", "create_text"), anchor=tkinter.CENTER) + self._canvas.tag_raise("checkmark") + requires_recoloring = True + + self._canvas.coords("checkmark", round(width / 2), round(height / 2)) + + return requires_recoloring + + def draw_dropdown_arrow(self, x_position: Union[int, float], y_position: Union[int, float], size: Union[int, float]) -> bool: + """ Draws a dropdown bottom facing arrow at (x_position, y_position) in a given size + + returns bool if recoloring is necessary """ + + x_position, y_position, size = round(x_position), round(y_position), round(size) + requires_recoloring = False + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + if not self._canvas.find_withtag("dropdown_arrow"): + self._canvas.create_line(0, 0, 0, 0, tags="dropdown_arrow", width=round(size / 3), joinstyle=tkinter.ROUND, capstyle=tkinter.ROUND) + self._canvas.tag_raise("dropdown_arrow") + requires_recoloring = True + + self._canvas.coords("dropdown_arrow", + x_position - (size / 2), + y_position - (size / 5), + x_position, + y_position + (size / 5), + x_position + (size / 2), + y_position - (size / 5)) + + elif self.preferred_drawing_method == "font_shapes": + if not self._canvas.find_withtag("dropdown_arrow"): + self._canvas.create_text(0, 0, text="Y", font=("CustomTkinter_shapes_font", -size), tags="dropdown_arrow", anchor=tkinter.CENTER) + self._canvas.tag_raise("dropdown_arrow") + requires_recoloring = True + + self._canvas.coords("dropdown_arrow", x_position, y_position) + + return requires_recoloring diff --git a/interfaces/UI/libraries/customtkinter/font_manager.py b/interfaces/UI/libraries/customtkinter/font_manager.py new file mode 100644 index 00000000..91dfd043 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/font_manager.py @@ -0,0 +1,66 @@ +import sys +import os +import shutil +from typing import Union + + +class FontManager: + + linux_font_path = "~/.fonts/" + + @classmethod + def init_font_manager(cls): + # Linux + if sys.platform.startswith("linux"): + try: + if not os.path.isdir(os.path.expanduser(cls.linux_font_path)): + os.mkdir(os.path.expanduser(cls.linux_font_path)) + return True + except Exception as err: + sys.stderr.write("FontManager error: " + str(err) + "\n") + return False + + # other platforms + else: + return True + + @classmethod + def windows_load_font(cls, font_path: Union[str, bytes], private: bool = True, enumerable: bool = False) -> bool: + """ Function taken from: https://stackoverflow.com/questions/11993290/truly-custom-font-in-tkinter/30631309#30631309 """ + + from ctypes import windll, byref, create_unicode_buffer, create_string_buffer + + FR_PRIVATE = 0x10 + FR_NOT_ENUM = 0x20 + + if isinstance(font_path, bytes): + path_buffer = create_string_buffer(font_path) + add_font_resource_ex = windll.gdi32.AddFontResourceExA + elif isinstance(font_path, str): + path_buffer = create_unicode_buffer(font_path) + add_font_resource_ex = windll.gdi32.AddFontResourceExW + else: + raise TypeError('font_path must be of type bytes or str') + + flags = (FR_PRIVATE if private else 0) | (FR_NOT_ENUM if not enumerable else 0) + num_fonts_added = add_font_resource_ex(byref(path_buffer), flags, 0) + return bool(num_fonts_added) + + @classmethod + def load_font(cls, font_path: str) -> bool: + # Windows + if sys.platform.startswith("win"): + return cls.windows_load_font(font_path, private=True, enumerable=False) + + # Linux + elif sys.platform.startswith("linux"): + try: + shutil.copy(font_path, os.path.expanduser(cls.linux_font_path)) + return True + except Exception as err: + sys.stderr.write("FontManager error: " + str(err) + "\n") + return False + + # macOS and others + else: + return False diff --git a/interfaces/UI/libraries/customtkinter/scaling_tracker.py b/interfaces/UI/libraries/customtkinter/scaling_tracker.py new file mode 100644 index 00000000..2e9983aa --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/scaling_tracker.py @@ -0,0 +1,181 @@ +import tkinter +import sys +from typing import Callable + + +class ScalingTracker: + deactivate_automatic_dpi_awareness = False + + window_widgets_dict = {} # contains window objects as keys with list of widget callbacks as elements + window_dpi_scaling_dict = {} # contains window objects as keys and corresponding scaling factors + + widget_scaling = 1 # user values which multiply to detected window scaling factor + window_scaling = 1 + spacing_scaling = 1 + + update_loop_running = False + update_loop_interval = 150 # milliseconds + + @classmethod + def get_widget_scaling(cls, widget) -> float: + window_root = cls.get_window_root_of_widget(widget) + return cls.window_dpi_scaling_dict[window_root] * cls.widget_scaling + + @classmethod + def get_spacing_scaling(cls, widget) -> float: + window_root = cls.get_window_root_of_widget(widget) + return cls.window_dpi_scaling_dict[window_root] * cls.spacing_scaling + + @classmethod + def get_window_scaling(cls, window) -> float: + window_root = cls.get_window_root_of_widget(window) + return cls.window_dpi_scaling_dict[window_root] * cls.window_scaling + + @classmethod + def set_widget_scaling(cls, widget_scaling_factor: float): + cls.widget_scaling = max(widget_scaling_factor, 0.4) + cls.update_scaling_callbacks_all() + + @classmethod + def set_spacing_scaling(cls, spacing_scaling_factor: float): + cls.spacing_scaling = max(spacing_scaling_factor, 0.4) + cls.update_scaling_callbacks_all() + + @classmethod + def set_window_scaling(cls, window_scaling_factor: float): + cls.window_scaling = max(window_scaling_factor, 0.4) + cls.update_scaling_callbacks_all() + + @classmethod + def get_window_root_of_widget(cls, widget): + current_widget = widget + + while isinstance(current_widget, tkinter.Tk) is False and\ + isinstance(current_widget, tkinter.Toplevel) is False: + current_widget = current_widget.master + + return current_widget + + @classmethod + def update_scaling_callbacks_all(cls): + for window, callback_list in cls.window_widgets_dict.items(): + for set_scaling_callback in callback_list: + if not cls.deactivate_automatic_dpi_awareness: + set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling, + cls.window_dpi_scaling_dict[window] * cls.spacing_scaling, + cls.window_dpi_scaling_dict[window] * cls.window_scaling) + else: + set_scaling_callback(cls.widget_scaling, + cls.spacing_scaling, + cls.window_scaling) + + @classmethod + def update_scaling_callbacks_for_window(cls, window): + for set_scaling_callback in cls.window_widgets_dict[window]: + if not cls.deactivate_automatic_dpi_awareness: + set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling, + cls.window_dpi_scaling_dict[window] * cls.spacing_scaling, + cls.window_dpi_scaling_dict[window] * cls.window_scaling) + else: + set_scaling_callback(cls.widget_scaling, + cls.spacing_scaling, + cls.window_scaling) + + @classmethod + def add_widget(cls, widget_callback: Callable, widget): + window_root = cls.get_window_root_of_widget(widget) + + if window_root not in cls.window_widgets_dict: + cls.window_widgets_dict[window_root] = [widget_callback] + else: + cls.window_widgets_dict[window_root].append(widget_callback) + + if window_root not in cls.window_dpi_scaling_dict: + cls.window_dpi_scaling_dict[window_root] = cls.get_window_dpi_scaling(window_root) + + if not cls.update_loop_running: + window_root.after(100, cls.check_dpi_scaling) + cls.update_loop_running = True + + @classmethod + def remove_widget(cls, widget_callback, widget): + window_root = cls.get_window_root_of_widget(widget) + try: + cls.window_widgets_dict[window_root].remove(widget_callback) + except: + pass + + @classmethod + def remove_window(cls, window_callback, window): + try: + del cls.window_widgets_dict[window] + except: + pass + + @classmethod + def add_window(cls, window_callback, window): + if window not in cls.window_widgets_dict: + cls.window_widgets_dict[window] = [window_callback] + else: + cls.window_widgets_dict[window].append(window_callback) + + if window not in cls.window_dpi_scaling_dict: + cls.window_dpi_scaling_dict[window] = cls.get_window_dpi_scaling(window) + + @classmethod + def activate_high_dpi_awareness(cls): + """ make process DPI aware, customtkinter elements will get scaled automatically, + only gets activated when CTk object is created """ + + if not cls.deactivate_automatic_dpi_awareness: + if sys.platform == "darwin": + pass # high DPI scaling works automatically on macOS + + elif sys.platform.startswith("win"): + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(2) + # Microsoft Docs: https://docs.microsoft.com/en-us/windows/win32/api/shellscalingapi/ne-shellscalingapi-process_dpi_awareness + else: + pass # DPI awareness on Linux not implemented + + @classmethod + def get_window_dpi_scaling(cls, window) -> float: + if not cls.deactivate_automatic_dpi_awareness: + if sys.platform == "darwin": + return 1 # scaling works automatically on macOS + + elif sys.platform.startswith("win"): + from ctypes import windll, pointer, wintypes + + DPI100pc = 96 # DPI 96 is 100% scaling + DPI_type = 0 # MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2 + window_hwnd = wintypes.HWND(window.winfo_id()) + monitor_handle = windll.user32.MonitorFromWindow(window_hwnd, wintypes.DWORD(2)) # MONITOR_DEFAULTTONEAREST = 2 + x_dpi, y_dpi = wintypes.UINT(), wintypes.UINT() + windll.shcore.GetDpiForMonitor(monitor_handle, DPI_type, pointer(x_dpi), pointer(y_dpi)) + return (x_dpi.value + y_dpi.value) / (2 * DPI100pc) + + else: + return 1 # DPI awareness on Linux not implemented + else: + return 1 + + @classmethod + def check_dpi_scaling(cls): + # check for every window if scaling value changed + for window in cls.window_widgets_dict: + if window.winfo_exists(): + current_dpi_scaling_value = cls.get_window_dpi_scaling(window) + if current_dpi_scaling_value != cls.window_dpi_scaling_dict[window]: + cls.window_dpi_scaling_dict[window] = current_dpi_scaling_value + cls.update_scaling_callbacks_for_window(window) + + # find an existing tkinter object for the next call of .after() + for app in cls.window_widgets_dict.keys(): + try: + app.after(cls.update_loop_interval, cls.check_dpi_scaling) + return + except Exception: + continue + + cls.update_loop_running = False diff --git a/interfaces/UI/libraries/customtkinter/settings.py b/interfaces/UI/libraries/customtkinter/settings.py new file mode 100644 index 00000000..a93f8001 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/settings.py @@ -0,0 +1,6 @@ + +class Settings: + cursor_manipulation_enabled = True + deactivate_macos_window_header_manipulation = False + deactivate_windows_window_header_manipulation = False + use_dropdown_fallback = True diff --git a/interfaces/UI/libraries/customtkinter/theme_manager.py b/interfaces/UI/libraries/customtkinter/theme_manager.py new file mode 100644 index 00000000..7c70c8a0 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/theme_manager.py @@ -0,0 +1,93 @@ +import sys +import os +import json + + +class ThemeManager: + + theme = {} # contains all the theme data + built_in_themes = ["blue", "green", "dark-blue", "sweetkind"] + + @classmethod + def load_theme(cls, theme_name_or_path: str): + script_directory = os.path.dirname(os.path.abspath(__file__)) + + if theme_name_or_path in cls.built_in_themes: + with open(os.path.join(script_directory, "assets", "themes", f"{theme_name_or_path}.json"), "r") as f: + cls.theme = json.load(f) + else: + with open(theme_name_or_path, "r") as f: + cls.theme = json.load(f) + + if sys.platform == "darwin": + cls.theme["text"] = cls.theme["text"]["macOS"] + elif sys.platform.startswith("win"): + cls.theme["text"] = cls.theme["text"]["Windows"] + else: + cls.theme["text"] = cls.theme["text"]["Linux"] + + @staticmethod + def single_color(color, appearance_mode: int) -> str: + """ color can be either a single hex color string or a color name or it can be a + tuple color with (light_color, dark_color). The functions then returns + always a single color string """ + + if type(color) == tuple or type(color) == list: + return color[appearance_mode] + else: + return color + + @staticmethod + def rgb2hex(rgb_color: tuple) -> str: + return "#{:02x}{:02x}{:02x}".format(round(rgb_color[0]), round(rgb_color[1]), round(rgb_color[2])) + + @staticmethod + def hex2rgb(hex_color: str) -> tuple: + return tuple(int(hex_color.strip("#")[i:i+2], 16) for i in (0, 2, 4)) + + @classmethod + def linear_blend(cls, color_1: str, color_2: str, blend_factor: float) -> str: + """ Blends two hex colors linear, where blend_factor of 0 + results in color_1 and blend_factor of 1 results in color_2. """ + + if color_1 is None or color_2 is None: + return None + + rgb_1 = cls.hex2rgb(color_1) + rgb_2 = cls.hex2rgb(color_2) + + new_rgb = (rgb_1[0] + (rgb_2[0] - rgb_1[0]) * blend_factor, + rgb_1[1] + (rgb_2[1] - rgb_1[1]) * blend_factor, + rgb_1[2] + (rgb_2[2] - rgb_1[2]) * blend_factor) + + return cls.rgb2hex(new_rgb) + + @classmethod + def get_minimal_darker(cls, color: str) -> str: + if color.startswith("#"): + color_rgb = cls.hex2rgb(color) + if color_rgb[0] > 0: + return cls.rgb2hex((color_rgb[0] - 1, color_rgb[1], color_rgb[2])) + elif color_rgb[1] > 0: + return cls.rgb2hex((color_rgb[0], color_rgb[1] - 1, color_rgb[2])) + elif color_rgb[2] > 0: + return cls.rgb2hex((color_rgb[0], color_rgb[1], color_rgb[2] - 1)) + else: + return cls.rgb2hex((color_rgb[0] + 1, color_rgb[1], color_rgb[2] - 1)) # otherwise slightly lighter + + @classmethod + def multiply_hex_color(cls, hex_color: str, factor: float = 1.0) -> str: + try: + rgb_color = ThemeManager.hex2rgb(hex_color) + dark_rgb_color = (min(255, rgb_color[0] * factor), + min(255, rgb_color[1] * factor), + min(255, rgb_color[2] * factor)) + return ThemeManager.rgb2hex(dark_rgb_color) + except Exception as err: + # sys.stderr.write("ERROR (CTkColorManager): failed to darken the following color: " + str(hex_color) + " " + str(err)) + return hex_color + + @classmethod + def set_main_color(cls, main_color, main_color_hover): + cls.MAIN_COLOR = main_color + cls.MAIN_HOVER_COLOR = main_color_hover diff --git a/interfaces/UI/libraries/customtkinter/widgets/__init__.py b/interfaces/UI/libraries/customtkinter/widgets/__init__.py new file mode 100644 index 00000000..62acbf56 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/__init__.py @@ -0,0 +1,3 @@ +from .ctk_canvas import CTkCanvas + +CTkCanvas.init_font_character_mapping() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_button.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_button.py new file mode 100644 index 00000000..09acdc8e --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_button.py @@ -0,0 +1,377 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkButton(CTkBaseClass): + """ button with border, rounded corners, hover effect, image support """ + + def __init__(self, *args, + bg_color: Union[str, Tuple[str, str], None] = None, + fg_color: Union[str, Tuple[str, str], None] = "default_theme", + hover_color: Union[str, Tuple[str, str]] = "default_theme", + border_color: Union[str, Tuple[str, str]] = "default_theme", + text_color: Union[str, Tuple[str, str]] = "default_theme", + text_color_disabled: Union[str, Tuple[str, str]] = "default_theme", + width: int = 140, + height: int = 28, + corner_radius: Union[int, str] = "default_theme", + border_width: Union[int, str] = "default_theme", + text: str = "CTkButton", + textvariable: tkinter.Variable = None, + text_font: any = "default_theme", + image: tkinter.PhotoImage = None, + hover: bool = True, + compound: str = "left", + state: str = "normal", + command: Callable = None, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color + self.border_color = ThemeManager.theme["color"]["button_border"] if border_color == "default_theme" else border_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + + # shape + self.corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["button_border_width"] if border_width == "default_theme" else border_width + + # text, font, image + self.image = image + self.image_label = None + self.text = text + self.text_label = None + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # callback and hover functionality + self.command = command + self.textvariable = textvariable + self.state = state + self.hover = hover + self.compound = compound + self.click_animation_running = False + + # configure grid system (2x2) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + self.grid_columnconfigure(1, weight=1) + + # canvas + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew") + self.draw_engine = DrawEngine(self.canvas) + + # canvas event bindings + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.clicked) + self.canvas.bind("", self.clicked) + self.bind('', self.update_dimensions_event) + + # configure cursor and initial draw + self.set_cursor() + self.draw() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + if self.text_label is not None: + self.text_label.destroy() + self.text_label = None + if self.image_label is not None: + self.image_label.destroy() + self.image_label = None + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width: int = None, height: int = None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width)) + + if no_color_updates is False or requires_recoloring: + + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + # set color for the button border parts (outline) + self.canvas.itemconfig("border_parts", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + # set color for inner button parts + if self.fg_color is None: + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + # create text label if text given + if self.text is not None and self.text != "": + + if self.text_label is None: + self.text_label = tkinter.Label(master=self, + font=self.apply_font_scaling(self.text_font), + text=self.text, + textvariable=self.textvariable) + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.clicked) + self.text_label.bind("", self.clicked) + + if no_color_updates is False: + # set text_label fg color (text color) + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=(ThemeManager.single_color(self.text_color_disabled, self._appearance_mode))) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + if self.fg_color is None: + self.text_label.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.text_label.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + else: + # delete text_label if no text given + if self.text_label is not None: + self.text_label.destroy() + self.text_label = None + + # create image label if image given + if self.image is not None: + + if self.image_label is None: + self.image_label = tkinter.Label(master=self) + + self.image_label.bind("", self.on_enter) + self.image_label.bind("", self.on_leave) + self.image_label.bind("", self.clicked) + self.image_label.bind("", self.clicked) + + if no_color_updates is False: + # set image_label bg color (background color of label) + if self.fg_color is None: + self.image_label.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.image_label.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.image_label.configure(image=self.image) # set image + + else: + # delete text_label if no text given + if self.image_label is not None: + self.image_label.destroy() + self.image_label = None + + # create grid layout with just an image given + if self.image_label is not None and self.text_label is None: + self.image_label.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="", + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) # bottom pady with +1 for rounding to even + + # create grid layout with just text given + if self.image_label is None and self.text_label is not None: + self.text_label.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="", + padx=self.apply_widget_scaling(self.corner_radius), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) # bottom pady with +1 for rounding to even + + # create grid layout of image and text label in 2x2 grid system with given compound + if self.image_label is not None and self.text_label is not None: + if self.compound == tkinter.LEFT or self.compound == "left": + self.image_label.grid(row=0, column=0, sticky="e", rowspan=2, columnspan=1, + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), 2), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) + self.text_label.grid(row=0, column=1, sticky="w", rowspan=2, columnspan=1, + padx=(2, max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width))), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) + elif self.compound == tkinter.TOP or self.compound == "top": + self.image_label.grid(row=0, column=0, sticky="s", columnspan=2, rowspan=1, + padx=max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), + pady=(self.apply_widget_scaling(self.border_width), 2)) + self.text_label.grid(row=1, column=0, sticky="n", columnspan=2, rowspan=1, + padx=max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), + pady=(2, self.apply_widget_scaling(self.border_width))) + elif self.compound == tkinter.RIGHT or self.compound == "right": + self.image_label.grid(row=0, column=1, sticky="w", rowspan=2, columnspan=1, + padx=(2, max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width))), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) + self.text_label.grid(row=0, column=0, sticky="e", rowspan=2, columnspan=1, + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), 2), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width) + 1)) + elif self.compound == tkinter.BOTTOM or self.compound == "bottom": + self.image_label.grid(row=1, column=0, sticky="n", columnspan=2, rowspan=1, + padx=max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), + pady=(2, self.apply_widget_scaling(self.border_width))) + self.text_label.grid(row=0, column=0, sticky="s", columnspan=2, rowspan=1, + padx=max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(self.border_width)), + pady=(self.apply_widget_scaling(self.border_width), 2)) + + def configure(self, require_redraw=False, **kwargs): + if "text" in kwargs: + self.text = kwargs.pop("text") + if self.text_label is None: + require_redraw = True # text_label will be created in .draw() + else: + self.text_label.configure(text=self.text) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + if self.text_label is not None: + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "state" in kwargs: + self.state = kwargs.pop("state") + self.set_cursor() + require_redraw = True + + if "image" in kwargs: + self.image = kwargs.pop("image") + require_redraw = True + + if "corner_radius" in kwargs: + self.corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "compound" in kwargs: + self.compound = kwargs.pop("compound") + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "hover_color" in kwargs: + self.hover_color = kwargs.pop("hover_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "textvariable" in kwargs: + self.textvariable = kwargs.pop("textvariable") + if self.text_label is not None: + self.text_label.configure(textvariable=self.textvariable) + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + super().configure(require_redraw=require_redraw, **kwargs) + + def set_cursor(self): + if Settings.cursor_manipulation_enabled: + if self.state == tkinter.DISABLED: + if sys.platform == "darwin" and self.command is not None and Settings.cursor_manipulation_enabled: + self.configure(cursor="arrow") + elif sys.platform.startswith("win") and self.command is not None and Settings.cursor_manipulation_enabled: + self.configure(cursor="arrow") + + elif self.state == tkinter.NORMAL: + if sys.platform == "darwin" and self.command is not None and Settings.cursor_manipulation_enabled: + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and self.command is not None and Settings.cursor_manipulation_enabled: + self.configure(cursor="hand2") + + def set_image(self, image): + """ will be removed in next major """ + self.configure(image=image) + + def set_text(self, text): + """ will be removed in next major """ + self.configure(text=text) + + def on_enter(self, event=None): + if self.hover is True and self.state == tkinter.NORMAL: + if self.hover_color is None: + inner_parts_color = self.fg_color + else: + inner_parts_color = self.hover_color + + # set color of inner button parts to hover color + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(inner_parts_color, self._appearance_mode), + fill=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + # set text_label bg color to button hover color + if self.text_label is not None: + self.text_label.configure(bg=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + # set image_label bg color to button hover color + if self.image_label is not None: + self.image_label.configure(bg=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + def on_leave(self, event=None): + self.click_animation_running = False + + if self.hover is True: + if self.fg_color is None: + inner_parts_color = self.bg_color + else: + inner_parts_color = self.fg_color + + # set color of inner button parts + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(inner_parts_color, self._appearance_mode), + fill=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + # set text_label bg color (label color) + if self.text_label is not None: + self.text_label.configure(bg=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + # set image_label bg color (image bg color) + if self.image_label is not None: + self.image_label.configure(bg=ThemeManager.single_color(inner_parts_color, self._appearance_mode)) + + def click_animation(self): + if self.click_animation_running: + self.on_enter() + + def clicked(self, event=None): + if self.command is not None: + if self.state != tkinter.DISABLED: + + # click animation: change color with .on_leave() and back to normal after 100ms with click_animation() + self.on_leave() + self.click_animation_running = True + self.after(100, self.click_animation) + + self.command() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_canvas.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_canvas.py new file mode 100644 index 00000000..c0754b13 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_canvas.py @@ -0,0 +1,96 @@ +import tkinter +import sys +from typing import Union, Tuple + + +class CTkCanvas(tkinter.Canvas): + radius_to_char_fine: dict = None # dict to map radius to font circle character + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aa_circle_canvas_ids = set() + + @classmethod + def init_font_character_mapping(cls): + """ optimizations made for Windows 10, 11 only """ + + radius_to_char_warped = {19: 'B', 18: 'B', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'B', 12: 'B', 11: 'B', + 10: 'B', + 9: 'C', 8: 'D', 7: 'C', 6: 'E', 5: 'F', 4: 'G', 3: 'H', 2: 'H', 1: 'H', 0: 'A'} + + radius_to_char_fine_windows_10 = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'C', 12: 'C', + 11: 'C', 10: 'C', + 9: 'D', 8: 'D', 7: 'D', 6: 'C', 5: 'D', 4: 'G', 3: 'G', 2: 'H', 1: 'H', + 0: 'A'} + + radius_to_char_fine_windows_11 = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'C', 12: 'C', + 11: 'D', 10: 'D', + 9: 'E', 8: 'F', 7: 'C', 6: 'I', 5: 'E', 4: 'G', 3: 'P', 2: 'R', 1: 'R', + 0: 'A'} + + radius_to_char_fine_linux = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'F', 12: 'C', + 11: 'F', 10: 'C', + 9: 'D', 8: 'G', 7: 'D', 6: 'F', 5: 'D', 4: 'G', 3: 'M', 2: 'H', 1: 'H', + 0: 'A'} + + if sys.platform.startswith("win"): + if sys.getwindowsversion().build > 20000: # Windows 11 + cls.radius_to_char_fine = radius_to_char_fine_windows_11 + else: # < Windows 11 + cls.radius_to_char_fine = radius_to_char_fine_windows_10 + elif sys.platform.startswith("linux"): # Optimized on Kali Linux + cls.radius_to_char_fine = radius_to_char_fine_linux + else: + cls.radius_to_char_fine = radius_to_char_fine_windows_10 + + def get_char_from_radius(self, radius: int) -> str: + if radius >= 20: + return "A" + else: + return self.radius_to_char_fine[radius] + + def create_aa_circle(self, x_pos: int, y_pos: int, radius: int, angle: int = 0, fill: str = "white", + tags: Union[str, Tuple[str, ...]] = "", anchor: str = tkinter.CENTER) -> int: + # create a circle with a font element + circle_1 = self.create_text(x_pos, y_pos, text=self.get_char_from_radius(radius), anchor=anchor, fill=fill, + font=("CustomTkinter_shapes_font", -radius * 2), tags=tags, angle=angle) + self.addtag_withtag("ctk_aa_circle_font_element", circle_1) + self.aa_circle_canvas_ids.add(circle_1) + + return circle_1 + + def coords(self, tag_or_id, *args): + + if type(tag_or_id) == str and "ctk_aa_circle_font_element" in self.gettags(tag_or_id): + coords_id = self.find_withtag(tag_or_id)[0] # take the lowest id for the given tag + super().coords(coords_id, *args[:2]) + + if len(args) == 3: + super().itemconfigure(coords_id, font=("CustomTkinter_shapes_font", -int(args[2]) * 2), text=self.get_char_from_radius(args[2])) + + elif type(tag_or_id) == int and tag_or_id in self.aa_circle_canvas_ids: + super().coords(tag_or_id, *args[:2]) + + if len(args) == 3: + super().itemconfigure(tag_or_id, font=("CustomTkinter_shapes_font", -args[2] * 2), text=self.get_char_from_radius(args[2])) + + else: + super().coords(tag_or_id, *args) + + def itemconfig(self, tag_or_id, *args, **kwargs): + kwargs_except_outline = kwargs.copy() + if "outline" in kwargs_except_outline: + del kwargs_except_outline["outline"] + + if type(tag_or_id) == int: + if tag_or_id in self.aa_circle_canvas_ids: + super().itemconfigure(tag_or_id, *args, **kwargs_except_outline) + else: + super().itemconfigure(tag_or_id, *args, **kwargs) + else: + configure_ids = self.find_withtag(tag_or_id) + for configure_id in configure_ids: + if configure_id in self.aa_circle_canvas_ids: + super().itemconfigure(configure_id, *args, **kwargs_except_outline) + else: + super().itemconfigure(configure_id, *args, **kwargs) diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_checkbox.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_checkbox.py new file mode 100644 index 00000000..38931c01 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_checkbox.py @@ -0,0 +1,322 @@ +import tkinter +import sys +from typing import Union + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkCheckBox(CTkBaseClass): + """ tkinter custom checkbox with border, rounded corners and hover effect """ + + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + hover_color="default_theme", + border_color="default_theme", + border_width="default_theme", + checkmark_color="default_theme", + width=24, + height=24, + corner_radius="default_theme", + text_font="default_theme", + text_color="default_theme", + text="CTkCheckBox", + text_color_disabled="default_theme", + hover=True, + command=None, + state=tkinter.NORMAL, + onvalue=1, + offvalue=0, + variable=None, + textvariable=None, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color + self.border_color = ThemeManager.theme["color"]["checkbox_border"] if border_color == "default_theme" else border_color + self.checkmark_color = ThemeManager.theme["color"]["checkmark"] if checkmark_color == "default_theme" else checkmark_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["checkbox_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["checkbox_border_width"] if border_width == "default_theme" else border_width + + # text + self.text = text + self.text_label: Union[tkinter.Label, None] = None + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # callback and hover functionality + self.command = command + self.state = state + self.hover = hover + self.check_state = False + + self.onvalue = onvalue + self.offvalue = offvalue + self.variable: tkinter.Variable = variable + self.variable_callback_blocked = False + self.textvariable: tkinter.Variable = textvariable + self.variable_callback_name = None + + # configure grid system (1x3) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=1) + self.grid_rowconfigure(0, weight=1) + + self.bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.bg_canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=3, rowspan=1, sticky="nswe") + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=1, rowspan=1) + self.draw_engine = DrawEngine(self.canvas) + + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.toggle) + + self.text_label = tkinter.Label(master=self, + bd=0, + text=self.text, + justify=tkinter.LEFT, + font=self.apply_font_scaling(self.text_font), + textvariable=self.textvariable) + self.text_label.grid(row=0, column=2, padx=0, pady=0, sticky="w") + self.text_label["anchor"] = "w" + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.toggle) + + # register variable callback and set state according to variable + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if variable.get() == self.onvalue else False + + self.draw() # initial draw + self.set_cursor() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + self.canvas.delete("checkmark") + self.bg_canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def destroy(self): + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + super().destroy() + + def draw(self, no_color_updates=False): + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width)) + + if self.check_state is True: + self.draw_engine.draw_checkmark(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self._current_height * 0.58)) + else: + self.canvas.delete("checkmark") + + self.bg_canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + if self.check_state is True: + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + if "create_line" in self.canvas.gettags("checkmark"): + self.canvas.itemconfig("checkmark", fill=ThemeManager.single_color(self.checkmark_color, self._appearance_mode)) + else: + self.canvas.itemconfig("checkmark", fill=ThemeManager.single_color(self.checkmark_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=(ThemeManager.single_color(self.text_color_disabled, self._appearance_mode))) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + self.text_label.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + def configure(self, require_redraw=False, **kwargs): + if "text" in kwargs: + self.text = kwargs.pop("text") + self.text_label.configure(text=self.text) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + if self.text_label is not None: + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "state" in kwargs: + self.state = kwargs.pop("state") + self.set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "hover_color" in kwargs: + self.hover_color = kwargs.pop("hover_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "textvariable" in kwargs: + self.textvariable = kwargs.pop("textvariable") + self.text_label.configure(textvariable=self.textvariable) + + if "variable" in kwargs: + if self.variable is not None and self.variable != "": + self.variable.trace_remove("write", self.variable_callback_name) # remove old variable callback + + self.variable = kwargs.pop("variable") + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if self.variable.get() == self.onvalue else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def set_cursor(self): + if Settings.cursor_manipulation_enabled: + if self.state == tkinter.DISABLED: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + + elif self.state == tkinter.NORMAL: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="pointinghand") + if self.text_label is not None: + self.text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="hand2") + if self.text_label is not None: + self.text_label.configure(cursor="hand2") + + def on_enter(self, event=0): + if self.hover is True and self.state == tkinter.NORMAL: + if self.check_state is True: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.hover_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.hover_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + if self.hover is True: + if self.check_state is True: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + if self.variable.get() == self.onvalue: + self.select(from_variable_callback=True) + elif self.variable.get() == self.offvalue: + self.deselect(from_variable_callback=True) + + def toggle(self, event=0): + if self.state == tkinter.NORMAL: + if self.check_state is True: + self.check_state = False + self.draw() + else: + self.check_state = True + self.draw() + + if self.variable is not None: + self.variable_callback_blocked = True + self.variable.set(self.onvalue if self.check_state is True else self.offvalue) + self.variable_callback_blocked = False + + if self.command is not None: + self.command() + + def select(self, from_variable_callback=False): + self.check_state = True + self.draw() + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(self.onvalue) + self.variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + self.check_state = False + self.draw() + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(self.offvalue) + self.variable_callback_blocked = False + + def get(self): + return self.onvalue if self.check_state is True else self.offvalue diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_combobox.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_combobox.py new file mode 100644 index 00000000..eda3a643 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_combobox.py @@ -0,0 +1,297 @@ +import tkinter +import sys + +from .dropdown_menu import DropdownMenu +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkComboBox(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + border_color="default_theme", + button_color="default_theme", + button_hover_color="default_theme", + dropdown_color="default_theme", + dropdown_hover_color="default_theme", + dropdown_text_color="default_theme", + variable=None, + values=None, + command=None, + width=140, + height=28, + corner_radius="default_theme", + border_width="default_theme", + text_font="default_theme", + dropdown_text_font="default_theme", + text_color="default_theme", + text_color_disabled="default_theme", + hover=True, + state=tkinter.NORMAL, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color variables + self.fg_color = ThemeManager.theme["color"]["entry"] if fg_color == "default_theme" else fg_color + self.border_color = ThemeManager.theme["color"]["combobox_border"] if border_color == "default_theme" else border_color + self.button_color = ThemeManager.theme["color"]["combobox_border"] if button_color == "default_theme" else button_color + self.button_hover_color = ThemeManager.theme["color"]["combobox_button_hover"] if button_hover_color == "default_theme" else button_hover_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["entry_border_width"] if border_width == "default_theme" else border_width + + # text and font + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # callback and hover functionality + self.command = command + self.textvariable = variable + self.state = state + self.hover = hover + + if values is None: + self.values = ["CTkComboBox"] + else: + self.values = values + + self.dropdown_menu = DropdownMenu(master=self, + values=self.values, + command=self.dropdown_callback, + fg_color=dropdown_color, + hover_color=dropdown_hover_color, + text_color=dropdown_text_color, + text_font=dropdown_text_font) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew") + self.draw_engine = DrawEngine(self.canvas) + + self.entry = tkinter.Entry(master=self, + state=self.state, + width=1, + bd=0, + highlightthickness=0, + font=self.apply_font_scaling(self.text_font)) + left_section_width = self._current_width - self._current_height + self.entry.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="ew", + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(3)), + max(self.apply_widget_scaling(self._current_width - left_section_width + 3), self.apply_widget_scaling(3)))) + + # insert default value + if len(self.values) > 0: + self.entry.insert(0, self.values[0]) + else: + self.entry.insert(0, "CTkComboBox") + + self.draw() # initial draw + + # event bindings + self.canvas.tag_bind("right_parts", "", self.on_enter) + self.canvas.tag_bind("dropdown_arrow", "", self.on_enter) + self.canvas.tag_bind("right_parts", "", self.on_leave) + self.canvas.tag_bind("dropdown_arrow", "", self.on_leave) + self.canvas.tag_bind("right_parts", "", self.clicked) + self.canvas.tag_bind("dropdown_arrow", "", self.clicked) + self.bind('', self.update_dimensions_event) + + if self.textvariable is not None: + self.entry.configure(textvariable=self.textvariable) + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + # change entry font size and grid padding + left_section_width = self._current_width - self._current_height + self.entry.configure(font=self.apply_font_scaling(self.text_font)) + self.entry.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="ew", + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(3)), + max(self.apply_widget_scaling(self._current_width - left_section_width + 3), self.apply_widget_scaling(3)))) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width: int = None, height: int = None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + left_section_width = self._current_width - self._current_height + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border_vertical_split(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + self.apply_widget_scaling(left_section_width)) + + requires_recoloring_2 = self.draw_engine.draw_dropdown_arrow(self.apply_widget_scaling(self._current_width - (self._current_height / 2)), + self.apply_widget_scaling(self._current_height / 2), + self.apply_widget_scaling(self._current_height / 3)) + + if no_color_updates is False or requires_recoloring or requires_recoloring_2: + + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.canvas.itemconfig("inner_parts_left", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts_left", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts_right", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + self.entry.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + disabledforeground=ThemeManager.single_color(self.text_color_disabled, self._appearance_mode), + disabledbackground=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.canvas.itemconfig("dropdown_arrow", + fill=ThemeManager.single_color(self.text_color_disabled, self._appearance_mode)) + else: + self.canvas.itemconfig("dropdown_arrow", + fill=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + def open_dropdown_menu(self): + self.dropdown_menu.open(self.winfo_rootx(), + self.winfo_rooty() + self.apply_widget_scaling(self._current_height + 0)) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self.state = kwargs.pop("state") + self.entry.configure(state=self.state) + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "button_color" in kwargs: + self.button_color = kwargs.pop("button_color") + require_redraw = True + + if "button_hover_color" in kwargs: + self.button_hover_color = kwargs.pop("button_hover_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.entry.configure(font=self.apply_font_scaling(self.text_font)) + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "variable" in kwargs: + self.textvariable = kwargs.pop("variable") + self.entry.configure(textvariable=self.textvariable) + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + if "values" in kwargs: + self.values = kwargs.pop("values") + self.dropdown_menu.configure(values=self.values) + + if "dropdown_color" in kwargs: + self.dropdown_menu.configure(fg_color=kwargs.pop("dropdown_color")) + + if "dropdown_hover_color" in kwargs: + self.dropdown_menu.configure(hover_color=kwargs.pop("dropdown_hover_color")) + + if "dropdown_text_color" in kwargs: + self.dropdown_menu.configure(text_color=kwargs.pop("dropdown_text_color")) + + if "dropdown_text_font" in kwargs: + self.dropdown_menu.configure(text_font=kwargs.pop("dropdown_text_font")) + + super().configure(require_redraw=require_redraw, **kwargs) + + def on_enter(self, event=0): + if self.hover is True and self.state == tkinter.NORMAL and len(self.values) > 0: + if sys.platform == "darwin" and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="hand2") + + # set color of inner button parts to hover color + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts_right", + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + if self.hover is True: + if sys.platform == "darwin" and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + elif sys.platform.startswith("win") and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + + # set color of inner button parts + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts_right", + outline=ThemeManager.single_color(self.button_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + def dropdown_callback(self, value: str): + if self.state == "readonly": + self.entry.configure(state="normal") + self.entry.delete(0, tkinter.END) + self.entry.insert(0, value) + self.entry.configure(state="readonly") + else: + self.entry.delete(0, tkinter.END) + self.entry.insert(0, value) + + if self.command is not None: + self.command(value) + + def set(self, value: str): + if self.state == "readonly": + self.entry.configure(state="normal") + self.entry.delete(0, tkinter.END) + self.entry.insert(0, value) + self.entry.configure(state="readonly") + else: + self.entry.delete(0, tkinter.END) + self.entry.insert(0, value) + + def get(self) -> str: + return self.entry.get() + + def clicked(self, event=0): + if self.state is not tkinter.DISABLED and len(self.values) > 0: + self.open_dropdown_menu() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_entry.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_entry.py new file mode 100644 index 00000000..ef6171c4 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_entry.py @@ -0,0 +1,252 @@ +import tkinter + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkEntry(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + text_color="default_theme", + placeholder_text_color="default_theme", + text_font="default_theme", + placeholder_text=None, + corner_radius="default_theme", + border_width="default_theme", + border_color="default_theme", + width=140, + height=28, + state=tkinter.NORMAL, + textvariable: tkinter.Variable = None, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + if "master" in kwargs: + super().__init__(*args, bg_color=bg_color, width=width, height=height, master=kwargs.pop("master")) + else: + super().__init__(*args, bg_color=bg_color, width=width, height=height) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # color + self.fg_color = ThemeManager.theme["color"]["entry"] if fg_color == "default_theme" else fg_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.placeholder_text_color = ThemeManager.theme["color"]["entry_placeholder_text"] if placeholder_text_color == "default_theme" else placeholder_text_color + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + self.border_color = ThemeManager.theme["color"]["entry_border"] if border_color == "default_theme" else border_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["entry_border_width"] if border_width == "default_theme" else border_width + + # placeholder text + self.placeholder_text = placeholder_text + self.placeholder_text_active = False + self.pre_placeholder_arguments = {} # some set arguments of the entry will be changed for placeholder and then set back + + # textvariable + self.textvariable = textvariable + + self.state = state + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.grid(column=0, row=0, sticky="nswe") + self.draw_engine = DrawEngine(self.canvas) + + self.entry = tkinter.Entry(master=self, + bd=0, + width=1, + highlightthickness=0, + font=self.apply_font_scaling(self.text_font), + state=self.state, + textvariable=self.textvariable, + **kwargs) + self.entry.grid(column=0, row=0, sticky="nswe", + padx=self.apply_widget_scaling(self.corner_radius) if self.corner_radius >= 6 else self.apply_widget_scaling(6), + pady=(self.apply_widget_scaling(self.border_width), self.apply_widget_scaling(self.border_width + 1))) + + super().bind('', self.update_dimensions_event) + self.entry.bind('', self.entry_focus_out) + self.entry.bind('', self.entry_focus_in) + + self.activate_placeholder() + self.draw() + + def set_scaling(self, *args, **kwargs): + super().set_scaling( *args, **kwargs) + + self.entry.configure(font=self.apply_font_scaling(self.text_font)) + self.entry.grid(column=0, row=0, sticky="we", + padx=self.apply_widget_scaling(self.corner_radius) if self.corner_radius >= 6 else self.apply_widget_scaling(6)) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width)) + + if requires_recoloring or no_color_updates is False: + if ThemeManager.single_color(self.fg_color, self._appearance_mode) is not None: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.entry.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + disabledbackground=ThemeManager.single_color(self.fg_color, self._appearance_mode), + highlightcolor=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + disabledforeground=ThemeManager.single_color(self.text_color, self._appearance_mode), + insertbackground=ThemeManager.single_color(self.text_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.entry.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode), + disabledbackground=ThemeManager.single_color(self.bg_color, self._appearance_mode), + highlightcolor=ThemeManager.single_color(self.bg_color, self._appearance_mode), + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + disabledforeground=ThemeManager.single_color(self.text_color, self._appearance_mode), + insertbackground=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + if self.placeholder_text_active: + self.entry.config(fg=ThemeManager.single_color(self.placeholder_text_color, self._appearance_mode)) + + def bind(self, *args, **kwargs): + self.entry.bind(*args, **kwargs) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self.state = kwargs.pop("state") + self.entry.configure(state=self.state) + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "corner_radius" in kwargs: + self.corner_radius = kwargs.pop("corner_radius") + + if self.corner_radius * 2 > self._current_height: + self.corner_radius = self._current_height / 2 + elif self.corner_radius * 2 > self._current_width: + self.corner_radius = self._current_width / 2 + + self.entry.grid(column=0, row=0, sticky="we", padx=self.apply_widget_scaling(self.corner_radius) if self.corner_radius >= 6 else self.apply_widget_scaling(6)) + require_redraw = True + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + if "placeholder_text" in kwargs: + self.placeholder_text = kwargs.pop("placeholder_text") + if self.placeholder_text_active: + self.entry.delete(0, tkinter.END) + self.entry.insert(0, self.placeholder_text) + else: + self.activate_placeholder() + + if "placeholder_text_color" in kwargs: + self.placeholder_text_color = kwargs.pop("placeholder_text_color") + require_redraw = True + + if "textvariable" in kwargs: + self.textvariable = kwargs.pop("textvariable") + self.entry.configure(textvariable=self.textvariable) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.entry.configure(font=self.apply_font_scaling(self.text_font)) + + if "show" in kwargs: + if self.placeholder_text_active: + self.pre_placeholder_arguments["show"] = kwargs.pop("show") + else: + self.entry.configure(show=kwargs.pop("show")) + + if "bg_color" in kwargs: + super().configure(bg_color=kwargs.pop("bg_color"), require_redraw=require_redraw) + else: + super().configure(require_redraw=require_redraw) + + self.entry.configure(**kwargs) # pass remaining kwargs to entry + + def activate_placeholder(self): + if self.entry.get() == "" and self.placeholder_text is not None and (self.textvariable is None or self.textvariable == ""): + self.placeholder_text_active = True + + self.pre_placeholder_arguments = {"show": self.entry.cget("show")} + self.entry.config(fg=ThemeManager.single_color(self.placeholder_text_color, self._appearance_mode), show="") + self.entry.delete(0, tkinter.END) + self.entry.insert(0, self.placeholder_text) + + def deactivate_placeholder(self): + if self.placeholder_text_active: + self.placeholder_text_active = False + + self.entry.config(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + self.entry.delete(0, tkinter.END) + for argument, value in self.pre_placeholder_arguments.items(): + self.entry[argument] = value + + def entry_focus_out(self, event=None): + self.activate_placeholder() + + def entry_focus_in(self, event=None): + self.deactivate_placeholder() + + def delete(self, *args, **kwargs): + self.entry.delete(*args, **kwargs) + + if self.entry.get() == "": + self.activate_placeholder() + + def insert(self, *args, **kwargs): + self.deactivate_placeholder() + + return self.entry.insert(*args, **kwargs) + + def get(self): + if self.placeholder_text_active: + return "" + else: + return self.entry.get() + + def focus(self): + self.entry.focus() + + def focus_force(self): + self.entry.focus_force() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_frame.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_frame.py new file mode 100644 index 00000000..2e5ced7e --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_frame.py @@ -0,0 +1,132 @@ +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkFrame(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + border_color="default_theme", + border_width="default_theme", + corner_radius="default_theme", + width=200, + height=200, + overwrite_preferred_drawing_method: str = None, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.border_color = ThemeManager.theme["color"]["frame_border"] if border_color == "default_theme" else border_color + + # determine fg_color of frame + if fg_color == "default_theme": + if isinstance(self.master, CTkFrame): + if self.master.fg_color == ThemeManager.theme["color"]["frame_low"]: + self.fg_color = ThemeManager.theme["color"]["frame_high"] + else: + self.fg_color = ThemeManager.theme["color"]["frame_low"] + else: + self.fg_color = ThemeManager.theme["color"]["frame_low"] + else: + self.fg_color = fg_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["frame_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["frame_border_width"] if border_width == "default_theme" else border_width + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.place(x=0, y=0, relwidth=1, relheight=1) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.draw_engine = DrawEngine(self.canvas) + self._overwrite_preferred_drawing_method = overwrite_preferred_drawing_method + + self.bind('', self.update_dimensions_event) + + self.draw() + + def winfo_children(self): + """ winfo_children of CTkFrame without self.canvas widget, + because it's not a child but part of the CTkFrame itself """ + + child_widgets = super().winfo_children() + try: + child_widgets.remove(self.canvas) + return child_widgets + except ValueError: + return child_widgets + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + overwrite_preferred_drawing_method=self._overwrite_preferred_drawing_method) + + if no_color_updates is False or requires_recoloring: + if self.fg_color is None: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.canvas.tag_lower("inner_parts") + self.canvas.tag_lower("border_parts") + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + # check if CTk widgets are children of the frame and change their bg_color to new frame fg_color + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self.fg_color) + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "corner_radius" in kwargs: + self.corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self.border_width = kwargs.pop("border_width") + require_redraw = True + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + super().configure(require_redraw=require_redraw, **kwargs) diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_label.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_label.py new file mode 100644 index 00000000..cac1154d --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_label.py @@ -0,0 +1,159 @@ +import sys +import tkinter + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkLabel(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + text_color="default_theme", + corner_radius="default_theme", + width=140, + height=28, + text="CTkLabel", + text_font="default_theme", + anchor="center", # label anchor: center, n, e, s, w + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + if "master" in kwargs: + super().__init__(*args, bg_color=bg_color, width=width, height=height, master=kwargs.pop("master")) + else: + super().__init__(*args, bg_color=bg_color, width=width, height=height) + + # color + self.fg_color = ThemeManager.theme["color"]["label"] if fg_color == "default_theme" else fg_color + if self.fg_color is None: + self.fg_color = self.bg_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["label_corner_radius"] if corner_radius == "default_theme" else corner_radius + + # text + self.anchor = anchor + self.text = text + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, sticky="nswe") + self.draw_engine = DrawEngine(self.canvas) + + self.text_label = tkinter.Label(master=self, + highlightthickness=0, + bd=0, + anchor=self.anchor, + text=self.text, + font=self.apply_font_scaling(self.text_font), + **kwargs) + text_label_grid_sticky = self.anchor if self.anchor != "center" else "" + self.text_label.grid(row=0, column=0, padx=self.apply_widget_scaling(self.corner_radius), + sticky=text_label_grid_sticky) + + self.bind('', self.update_dimensions_event) + self.draw() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + text_label_grid_sticky = self.anchor if self.anchor != "center" else "" + self.text_label.grid(row=0, column=0, padx=self.apply_widget_scaling(self.corner_radius), + sticky=text_label_grid_sticky) + + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + 0) + + if no_color_updates is False or requires_recoloring: + if ThemeManager.single_color(self.fg_color, self._appearance_mode) is not None: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + def config(self, **kwargs): + sys.stderr.write("Warning: Use .configure() instead of .config()") + self.configure(**kwargs) + + def configure(self, require_redraw=False, **kwargs): + if "anchor" in kwargs: + self.anchor = kwargs.pop("anchor") + text_label_grid_sticky = self.anchor if self.anchor != "center" else "" + self.text_label.grid(row=0, column=0, padx=self.apply_widget_scaling(self.corner_radius), + sticky=text_label_grid_sticky) + + if "text" in kwargs: + self.text = kwargs["text"] + self.text_label.configure(text=self.text) + del kwargs["text"] + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + require_redraw = True + del kwargs["fg_color"] + + if "text_color" in kwargs: + self.text_color = kwargs["text_color"] + require_redraw = True + del kwargs["text_color"] + + if "width" in kwargs: + self.set_dimensions(width=kwargs["width"]) + del kwargs["width"] + + if "height" in kwargs: + self.set_dimensions(height=kwargs["height"]) + del kwargs["height"] + + if "bg_color" in kwargs: + super().configure(bg_color=kwargs.pop("bg_color"), require_redraw=require_redraw) + else: + super().configure(require_redraw=require_redraw) + + self.text_label.configure(**kwargs) # pass remaining kwargs to label + + def set_text(self, text): + """ Will be removed in the next major release """ + + self.text = text + self.text_label.configure(text=self.text) diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_optionmenu.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_optionmenu.py new file mode 100644 index 00000000..cc8601f8 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_optionmenu.py @@ -0,0 +1,311 @@ +import tkinter +import sys + +from .dropdown_menu import DropdownMenu + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkOptionMenu(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + button_color="default_theme", + button_hover_color="default_theme", + text_color="default_theme", + text_color_disabled="default_theme", + dropdown_color="default_theme", + dropdown_hover_color="default_theme", + dropdown_text_color="default_theme", + variable=None, + values=None, + command=None, + width=140, + height=28, + corner_radius="default_theme", + text_font="default_theme", + dropdown_text_font="default_theme", + hover=True, + state=tkinter.NORMAL, + dynamic_resizing=True, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color variables + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.button_color = ThemeManager.theme["color"]["optionmenu_button"] if button_color == "default_theme" else button_color + self.button_hover_color = ThemeManager.theme["color"]["optionmenu_button_hover"] if button_hover_color == "default_theme" else button_hover_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius + + # text and font + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + self.dropdown_text_font = dropdown_text_font + + # callback and hover functionality + self.command = command + self.variable = variable + self.variable_callback_blocked = False + self.variable_callback_name = None + self.state = state + self.hover = hover + self.dynamic_resizing = dynamic_resizing + + if values is None: + self.values = ["CTkOptionMenu"] + else: + self.values = values + + if len(self.values) > 0: + self.current_value = self.values[0] + else: + self.current_value = "CTkOptionMenu" + + self.dropdown_menu = DropdownMenu(master=self, + values=self.values, + command=self.dropdown_callback, + fg_color=dropdown_color, + hover_color=dropdown_hover_color, + text_color=dropdown_text_color, + text_font=dropdown_text_font) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew") + self.draw_engine = DrawEngine(self.canvas) + + left_section_width = self._current_width - self._current_height + self.text_label = tkinter.Label(master=self, + font=self.apply_font_scaling(self.text_font), + anchor="w", + text=self.current_value) + self.text_label.grid(row=0, column=0, sticky="w", + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(3)), + max(self.apply_widget_scaling(self._current_width - left_section_width + 3), self.apply_widget_scaling(3)))) + + if not self.dynamic_resizing: + self.grid_propagate(0) + + if Settings.cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self.configure(cursor="hand2") + + # event bindings + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.clicked) + self.canvas.bind("", self.clicked) + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.clicked) + self.text_label.bind("", self.clicked) + + self.bind('', self.update_dimensions_event) + + self.draw() # initial draw + + if self.variable is not None: + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.current_value = self.variable.get() + self.text_label.configure(text=self.current_value) + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + # change label text size and grid padding + left_section_width = self._current_width - self._current_height + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + self.text_label.grid(row=0, column=0, sticky="w", + padx=(max(self.apply_widget_scaling(self.corner_radius), self.apply_widget_scaling(3)), + max(self.apply_widget_scaling(self._current_width - left_section_width + 3), self.apply_widget_scaling(3)))) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width: int = None, height: int = None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + left_section_width = self._current_width - self._current_height + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border_vertical_split(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + 0, + self.apply_widget_scaling(left_section_width)) + + requires_recoloring_2 = self.draw_engine.draw_dropdown_arrow(self.apply_widget_scaling(self._current_width - (self._current_height / 2)), + self.apply_widget_scaling(self._current_height / 2), + self.apply_widget_scaling(self._current_height / 3)) + + if no_color_updates is False or requires_recoloring or requires_recoloring_2: + + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.canvas.itemconfig("inner_parts_left", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=(ThemeManager.single_color(self.text_color_disabled, self._appearance_mode))) + self.canvas.itemconfig("dropdown_arrow", + fill=ThemeManager.single_color(self.text_color_disabled, self._appearance_mode)) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + self.canvas.itemconfig("dropdown_arrow", + fill=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + self.text_label.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.canvas.update_idletasks() + + def open_dropdown_menu(self): + self.dropdown_menu.open(self.winfo_rootx(), + self.winfo_rooty() + self.apply_widget_scaling(self._current_height + 0)) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self.state = kwargs.pop("state") + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "button_color" in kwargs: + self.button_color = kwargs.pop("button_color") + require_redraw = True + + if "button_hover_color" in kwargs: + self.button_hover_color = kwargs.pop("button_hover_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "variable" in kwargs: + if self.variable is not None: # remove old callback + self.variable.trace_remove("write", self.variable_callback_name) + + self.variable = kwargs.pop("variable") + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.current_value = self.variable.get() + self.text_label.configure(text=self.current_value) + else: + self.variable = None + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + if "values" in kwargs: + self.values = kwargs.pop("values") + self.dropdown_menu.configure(values=self.values) + + if "dropdown_color" in kwargs: + self.dropdown_menu.configure(fg_color=kwargs.pop("dropdown_color")) + + if "dropdown_hover_color" in kwargs: + self.dropdown_menu.configure(hover_color=kwargs.pop("dropdown_hover_color")) + + if "dropdown_text_color" in kwargs: + self.dropdown_menu.configure(text_color=kwargs.pop("dropdown_text_color")) + + if "dropdown_text_font" in kwargs: + self.dropdown_text_font = kwargs.pop("dropdown_text_font") + self.dropdown_menu.configure(text_font=self.dropdown_text_font) + + if "dynamic_resizing" in kwargs: + self.dynamic_resizing = kwargs.pop("dynamic_resizing") + if not self.dynamic_resizing: + self.grid_propagate(0) + else: + self.grid_propagate(1) + + super().configure(require_redraw=require_redraw, **kwargs) + + def on_enter(self, event=0): + if self.hover is True and self.state == tkinter.NORMAL and len(self.values) > 0: + # set color of inner button parts to hover color + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + if self.hover is True: + # set color of inner button parts + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_color, self._appearance_mode), + fill=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + self.current_value = self.variable.get() + self.text_label.configure(text=self.current_value) + + def dropdown_callback(self, value: str): + self.current_value = value + self.text_label.configure(text=self.current_value) + + if self.variable is not None: + self.variable_callback_blocked = True + self.variable.set(self.current_value) + self.variable_callback_blocked = False + + if self.command is not None: + self.command(self.current_value) + + def set(self, value: str): + self.current_value = value + self.text_label.configure(text=self.current_value) + + if self.variable is not None: + self.variable_callback_blocked = True + self.variable.set(self.current_value) + self.variable_callback_blocked = False + + def get(self) -> str: + return self.current_value + + def clicked(self, event=0): + if self.state is not tkinter.DISABLED and len(self.values) > 0: + self.open_dropdown_menu() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_progressbar.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_progressbar.py new file mode 100644 index 00000000..1c9494fe --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_progressbar.py @@ -0,0 +1,257 @@ +import tkinter +import math + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkProgressBar(CTkBaseClass): + """ tkinter custom progressbar, values from 0 to 1 """ + + def __init__(self, *args, + variable=None, + bg_color=None, + border_color="default_theme", + fg_color="default_theme", + progress_color="default_theme", + corner_radius="default_theme", + width=None, + height=None, + border_width="default_theme", + orient="horizontal", + mode="determinate", + determinate_speed=1, + indeterminate_speed=1, + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orient.lower() == "vertical": + width = 8 + else: + width = 200 + if height is None: + if orient.lower() == "vertical": + height = 200 + else: + height = 8 + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.border_color = ThemeManager.theme["color"]["progressbar_border"] if border_color == "default_theme" else border_color + self.fg_color = ThemeManager.theme["color"]["progressbar"] if fg_color == "default_theme" else fg_color + self.progress_color = ThemeManager.theme["color"]["progressbar_progress"] if progress_color == "default_theme" else progress_color + + # control variable + self.variable = variable + self.variable_callback_blocked = False + self.variable_callback_name = None + + # shape + self.corner_radius = ThemeManager.theme["shape"]["progressbar_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["progressbar_border_width"] if border_width == "default_theme" else border_width + self.determinate_value = 0.5 # range 0-1 + self.determinate_speed = determinate_speed # range 0-1 + self.indeterminate_value = 0 # range 0-inf + self.indeterminate_width = 0.4 # range 0-1 + self.indeterminate_speed = indeterminate_speed # range 0-1 to travel in 50ms + self.loop_running = False + self.orient = orient + self.mode = mode # "determinate" or "indeterminate" + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nswe") + self.draw_engine = DrawEngine(self.canvas) + + # Each time an item is resized due to pack position mode, the binding Configure is called on the widget + self.bind('', self.update_dimensions_event) + + self.draw() # initial draw + + if self.variable is not None: + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.variable_callback_blocked = True + self.set(self.variable.get(), from_variable_callback=True) + self.variable_callback_blocked = False + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def destroy(self): + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + super().destroy() + + def draw(self, no_color_updates=False): + if self.orient.lower() == "horizontal": + orientation = "w" + elif self.orient.lower() == "vertical": + orientation = "s" + else: + orientation = "w" + + if self.mode == "determinate": + requires_recoloring = self.draw_engine.draw_rounded_progress_bar_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + 0, + self.determinate_value, + orientation) + else: # indeterminate mode + progress_value = (math.sin(self.indeterminate_value * math.pi / 40) + 1) / 2 + progress_value_1 = min(1.0, progress_value + (self.indeterminate_width / 2)) + progress_value_2 = max(0.0, progress_value - (self.indeterminate_width / 2)) + + requires_recoloring = self.draw_engine.draw_rounded_progress_bar_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + progress_value_1, + progress_value_2, + orientation) + + if no_color_updates is False or requires_recoloring: + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("progress_parts", + fill=ThemeManager.single_color(self.progress_color, self._appearance_mode), + outline=ThemeManager.single_color(self.progress_color, self._appearance_mode)) + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + del kwargs["fg_color"] + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs["border_color"] + del kwargs["border_color"] + require_redraw = True + + if "progress_color" in kwargs: + self.progress_color = kwargs["progress_color"] + del kwargs["progress_color"] + require_redraw = True + + if "border_width" in kwargs: + self.border_width = kwargs["border_width"] + del kwargs["border_width"] + require_redraw = True + + if "variable" in kwargs: + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + self.variable = kwargs["variable"] + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.set(self.variable.get(), from_variable_callback=True) + else: + self.variable = None + + del kwargs["variable"] + + if "mode" in kwargs: + self.mode = kwargs.pop("mode") + require_redraw = True + + if "determinate_speed" in kwargs: + self.determinate_speed = kwargs.pop("determinate_speed") + + if "indeterminate_speed" in kwargs: + self.indeterminate_speed = kwargs.pop("indeterminate_speed") + + if "width" in kwargs: + self.set_dimensions(width=kwargs["width"]) + del kwargs["width"] + + if "height" in kwargs: + self.set_dimensions(height=kwargs["height"]) + del kwargs["height"] + + super().configure(require_redraw=require_redraw, **kwargs) + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + self.set(self.variable.get(), from_variable_callback=True) + + def set(self, value, from_variable_callback=False): + """ set determinate value """ + self.determinate_value = value + + if self.determinate_value > 1: + self.determinate_value = 1 + elif self.determinate_value < 0: + self.determinate_value = 0 + + self.draw(no_color_updates=True) + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(round(self.determinate_value) if isinstance(self.variable, tkinter.IntVar) else self.determinate_value) + self.variable_callback_blocked = False + + def get(self): + """ get determinate value """ + return self.determinate_value + + def start(self): + """ start indeterminate mode """ + if not self.loop_running: + self.loop_running = True + self.internal_loop() + + def stop(self): + """ stop indeterminate mode """ + self.loop_running = False + + def internal_loop(self): + if self.loop_running: + if self.mode == "determinate": + self.determinate_value += self.determinate_speed / 50 + if self.determinate_value > 1: + self.determinate_value -= 1 + self.draw() + self.after(20, self.internal_loop) + else: + self.indeterminate_value += self.indeterminate_speed + self.draw() + self.after(20, self.internal_loop) + + def step(self): + if self.mode == "determinate": + self.determinate_value += self.determinate_speed / 50 + if self.determinate_value > 1: + self.determinate_value -= 1 + self.draw() + else: + self.indeterminate_value += self.indeterminate_speed + self.draw() diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_radiobutton.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_radiobutton.py new file mode 100644 index 00000000..6082af1d --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_radiobutton.py @@ -0,0 +1,281 @@ +import tkinter +import sys +from typing import Union + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkRadioButton(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + hover_color="default_theme", + border_color="default_theme", + border_width_unchecked="default_theme", + border_width_checked="default_theme", + width=22, + height=22, + corner_radius="default_theme", + text_font="default_theme", + text_color="default_theme", + text="CTkRadioButton", + text_color_disabled="default_theme", + hover=True, + command=None, + state=tkinter.NORMAL, + value=0, + variable=None, + textvariable=None, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color + self.border_color = ThemeManager.theme["color"]["checkbox_border"] if border_color == "default_theme" else border_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["radiobutton_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width_unchecked = ThemeManager.theme["shape"]["radiobutton_border_width_unchecked"] if border_width_unchecked == "default_theme" else border_width_unchecked + self.border_width_checked = ThemeManager.theme["shape"]["radiobutton_border_width_checked"] if border_width_checked == "default_theme" else border_width_checked + self.border_width = self.border_width_unchecked + + # text + self.text = text + self.text_label: Union[tkinter.Label, None] = None + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # callback and control variables + self.command = command + self.state = state + self.hover = hover + self.check_state = False + self.value = value + self.variable: tkinter.Variable = variable + self.variable_callback_blocked = False + self.textvariable = textvariable + self.variable_callback_name = None + + # configure grid system (3x1) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=1) + + self.bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.bg_canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=3, rowspan=1, sticky="nswe") + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=1) + self.draw_engine = DrawEngine(self.canvas) + + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.invoke) + + self.text_label = tkinter.Label(master=self, + bd=0, + text=self.text, + justify=tkinter.LEFT, + font=self.apply_font_scaling(self.text_font), + textvariable=self.textvariable) + self.text_label.grid(row=0, column=2, padx=0, pady=0, sticky="w") + self.text_label["anchor"] = "w" + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.invoke) + + if self.variable is not None: + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if self.variable.get() == self.value else False + + self.draw() # initial draw + self.set_cursor() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + self.bg_canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def destroy(self): + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + super().destroy() + + def draw(self, no_color_updates=False): + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width)) + + self.bg_canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + if self.check_state is False: + self.canvas.itemconfig("border_parts", + outline=ThemeManager.single_color(self.border_color, self._appearance_mode), + fill=ThemeManager.single_color(self.border_color, self._appearance_mode)) + else: + self.canvas.itemconfig("border_parts", + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.canvas.itemconfig("inner_parts", + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode), + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color_disabled, self._appearance_mode)) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + self.text_label.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + def configure(self, require_redraw=False, **kwargs): + if "text" in kwargs: + self.text = kwargs.pop("text") + self.text_label.configure(text=self.text) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "state" in kwargs: + self.state = kwargs.pop("state") + self.set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "hover_color" in kwargs: + self.hover_color = kwargs.pop("hover_color") + require_redraw = True + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "border_width" in kwargs: + self.border_width = kwargs.pop("border_width") + require_redraw = True + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "textvariable" in kwargs: + self.textvariable = kwargs.pop("textvariable") + self.text_label.configure(textvariable=self.textvariable) + + if "variable" in kwargs: + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + self.variable = kwargs.pop("variable") + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if self.variable.get() == self.value else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def set_cursor(self): + if Settings.cursor_manipulation_enabled: + if self.state == tkinter.DISABLED: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + + elif self.state == tkinter.NORMAL: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="pointinghand") + if self.text_label is not None: + self.text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="hand2") + if self.text_label is not None: + self.text_label.configure(cursor="hand2") + + def on_enter(self, event=0): + if self.hover is True and self.state == tkinter.NORMAL: + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + if self.hover is True: + if self.check_state is True: + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + if self.variable.get() == self.value: + self.select(from_variable_callback=True) + else: + self.deselect(from_variable_callback=True) + + def invoke(self, event=0): + if self.state == tkinter.NORMAL: + if self.check_state is False: + self.check_state = True + self.select() + + if self.command is not None: + self.command() + + def select(self, from_variable_callback=False): + self.check_state = True + self.border_width = self.border_width_checked + self.draw() + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(self.value) + self.variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + self.check_state = False + self.border_width = self.border_width_unchecked + self.draw() + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set("") + self.variable_callback_blocked = False diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_scrollbar.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_scrollbar.py new file mode 100644 index 00000000..6d60fac5 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_scrollbar.py @@ -0,0 +1,225 @@ +import sys + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkScrollbar(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + scrollbar_color="default_theme", + scrollbar_hover_color="default_theme", + border_spacing="default_theme", + corner_radius="default_theme", + width=None, + height=None, + minimum_pixel_length=20, + orientation="vertical", + command=None, + hover=True, + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orientation.lower() == "vertical": + width = 16 + else: + width = 200 + if height is None: + if orientation.lower() == "horizontal": + height = 16 + else: + height = 200 + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.fg_color = ThemeManager.theme["color"]["frame_high"] if fg_color == "default_theme" else fg_color + self.scrollbar_color = ThemeManager.theme["color"]["scrollbar_button"] if scrollbar_color == "default_theme" else scrollbar_color + self.scrollbar_hover_color = ThemeManager.theme["color"]["scrollbar_button_hover"] if scrollbar_hover_color == "default_theme" else scrollbar_hover_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["scrollbar_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_spacing = ThemeManager.theme["shape"]["scrollbar_border_spacing"] if border_spacing == "default_theme" else border_spacing + + self.hover = hover + self.hover_state = False + self.command = command + self.orientation = orientation + self.start_value: float = 0 # 0 to 1 + self.end_value: float = 1 # 0 to 1 + self.minimum_pixel_length = minimum_pixel_length + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.place(x=0, y=0, relwidth=1, relheight=1) + self.draw_engine = DrawEngine(self.canvas) + + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.tag_bind("border_parts", "", self.clicked) + self.canvas.bind("", self.clicked) + self.canvas.bind("", self.mouse_scroll_event) + self.bind('', self.update_dimensions_event) + + self.draw() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw(no_color_updates=True) + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw(no_color_updates=True) + + def get_scrollbar_values_for_minimum_pixel_size(self): + # correct scrollbar float values if scrollbar is too small + if self.orientation == "vertical": + scrollbar_pixel_length = (self.end_value - self.start_value) * self._current_height + if scrollbar_pixel_length < self.minimum_pixel_length and -scrollbar_pixel_length + self._current_height != 0: + # calculate how much to increase the float interval values so that the scrollbar width is self.minimum_pixel_length + interval_extend_factor = (-scrollbar_pixel_length + self.minimum_pixel_length) / (-scrollbar_pixel_length + self._current_height) + corrected_end_value = self.end_value + (1 - self.end_value) * interval_extend_factor + corrected_start_value = self.start_value - self.start_value * interval_extend_factor + return corrected_start_value, corrected_end_value + else: + return self.start_value, self.end_value + + else: + scrollbar_pixel_length = (self.end_value - self.start_value) * self._current_width + if scrollbar_pixel_length < self.minimum_pixel_length and -scrollbar_pixel_length + self._current_width != 0: + # calculate how much to increase the float interval values so that the scrollbar width is self.minimum_pixel_length + interval_extend_factor = (-scrollbar_pixel_length + self.minimum_pixel_length) / (-scrollbar_pixel_length + self._current_width) + corrected_end_value = self.end_value + (1 - self.end_value) * interval_extend_factor + corrected_start_value = self.start_value - self.start_value * interval_extend_factor + return corrected_start_value, corrected_end_value + else: + return self.start_value, self.end_value + + def draw(self, no_color_updates=False): + corrected_start_value, corrected_end_value = self.get_scrollbar_values_for_minimum_pixel_size() + requires_recoloring = self.draw_engine.draw_rounded_scrollbar(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_spacing), + corrected_start_value, + corrected_end_value, + self.orientation) + + if no_color_updates is False or requires_recoloring: + if self.hover_state is True: + self.canvas.itemconfig("scrollbar_parts", + fill=ThemeManager.single_color(self.scrollbar_hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.scrollbar_hover_color, self._appearance_mode)) + else: + self.canvas.itemconfig("scrollbar_parts", + fill=ThemeManager.single_color(self.scrollbar_color, self._appearance_mode), + outline=ThemeManager.single_color(self.scrollbar_color, self._appearance_mode)) + + if self.fg_color is None: + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.canvas.update_idletasks() + + def set(self, start_value: float, end_value: float): + self.start_value = float(start_value) + self.end_value = float(end_value) + self.draw() + + def get(self): + return self.start_value, self.end_value + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + require_redraw = True + del kwargs["fg_color"] + + if "scrollbar_color" in kwargs: + self.scrollbar_color = kwargs["scrollbar_color"] + require_redraw = True + del kwargs["scrollbar_color"] + + if "scrollbar_hover_color" in kwargs: + self.scrollbar_hover_color = kwargs["scrollbar_hover_color"] + require_redraw = True + del kwargs["scrollbar_hover_color"] + + if "command" in kwargs: + self.command = kwargs["command"] + del kwargs["command"] + + if "corner_radius" in kwargs: + self.corner_radius = kwargs["corner_radius"] + require_redraw = True + del kwargs["corner_radius"] + + if "border_spacing" in kwargs: + self.border_spacing = kwargs["border_spacing"] + require_redraw = True + del kwargs["border_spacing"] + + if "width" in kwargs: + self.set_dimensions(width=kwargs["width"]) + del kwargs["width"] + + if "height" in kwargs: + self.set_dimensions(height=kwargs["height"]) + del kwargs["height"] + + super().configure(require_redraw=require_redraw, **kwargs) + + def on_enter(self, event=0): + if self.hover is True: + self.hover_state = True + self.canvas.itemconfig("scrollbar_parts", + outline=ThemeManager.single_color(self.scrollbar_hover_color, self._appearance_mode), + fill=ThemeManager.single_color(self.scrollbar_hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + self.hover_state = False + self.canvas.itemconfig("scrollbar_parts", + outline=ThemeManager.single_color(self.scrollbar_color, self._appearance_mode), + fill=ThemeManager.single_color(self.scrollbar_color, self._appearance_mode)) + + def clicked(self, event): + if self.orientation == "vertical": + value = ((event.y - self.border_spacing) / (self._current_height - 2 * self.border_spacing)) / self._widget_scaling + else: + value = ((event.x - self.border_spacing) / (self._current_width - 2 * self.border_spacing)) / self._widget_scaling + + current_scrollbar_length = self.end_value - self.start_value + value = max(current_scrollbar_length / 2, min(value, 1 - (current_scrollbar_length / 2))) + self.start_value = value - (current_scrollbar_length / 2) + self.end_value = value + (current_scrollbar_length / 2) + self.draw() + + if self.command is not None: + self.command('moveto', self.start_value) + + def mouse_scroll_event(self, event=None): + if self.command is not None: + if sys.platform.startswith("win"): + self.command('scroll', -int(event.delta/40), 'units') + else: + self.command('scroll', -event.delta, 'units') + diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_slider.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_slider.py new file mode 100644 index 00000000..5978f13e --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_slider.py @@ -0,0 +1,339 @@ +import tkinter +import sys + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkSlider(CTkBaseClass): + """ tkinter custom slider""" + + def __init__(self, *args, + bg_color=None, + border_color=None, + fg_color="default_theme", + progress_color="default_theme", + button_color="default_theme", + button_hover_color="default_theme", + from_=0, + to=1, + number_of_steps=None, + width=None, + height=None, + corner_radius="default_theme", + button_corner_radius="default_theme", + border_width="default_theme", + button_length="default_theme", + command=None, + variable=None, + orient="horizontal", + state="normal", + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orient.lower() == "vertical": + width = 16 + else: + width = 200 + if height is None: + if orient.lower() == "vertical": + height = 200 + else: + height = 16 + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.border_color = border_color + self.fg_color = ThemeManager.theme["color"]["slider"] if fg_color == "default_theme" else fg_color + self.progress_color = ThemeManager.theme["color"]["slider_progress"] if progress_color == "default_theme" else progress_color + self.button_color = ThemeManager.theme["color"]["slider_button"] if button_color == "default_theme" else button_color + self.button_hover_color = ThemeManager.theme["color"]["slider_button_hover"] if button_hover_color == "default_theme" else button_hover_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["slider_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.button_corner_radius = ThemeManager.theme["shape"]["slider_button_corner_radius"] if button_corner_radius == "default_theme" else button_corner_radius + self.border_width = ThemeManager.theme["shape"]["slider_border_width"] if border_width == "default_theme" else border_width + self.button_length = ThemeManager.theme["shape"]["slider_button_length"] if button_length == "default_theme" else button_length + self.value = 0.5 # initial value of slider in percent + self.orientation = orient + self.hover_state = False + self.from_ = from_ + self.to = to + self.number_of_steps = number_of_steps + self.output_value = self.from_ + (self.value * (self.to - self.from_)) + + if self.corner_radius < self.button_corner_radius: + self.corner_radius = self.button_corner_radius + + # callback and control variables + self.command = command + self.variable: tkinter.Variable = variable + self.variable_callback_blocked = False + self.variable_callback_name = None + self.state = state + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.canvas.grid(column=0, row=0, rowspan=1, columnspan=1, sticky="nswe") + self.draw_engine = DrawEngine(self.canvas) + + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.clicked) + self.canvas.bind("", self.clicked) + + # Each time an item is resized due to pack position mode, the binding Configure is called on the widget + self.bind('', self.update_dimensions_event) + + self.set_cursor() + self.draw() # initial draw + + if self.variable is not None: + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.variable_callback_blocked = True + self.set(self.variable.get(), from_variable_callback=True) + self.variable_callback_blocked = False + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def destroy(self): + # remove variable_callback from variable callbacks if variable exists + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + super().destroy() + + def set_cursor(self): + if self.state == "normal" and Settings.cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self.configure(cursor="hand2") + + elif self.state == "disabled" and Settings.cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="arrow") + elif sys.platform.startswith("win"): + self.configure(cursor="arrow") + + def draw(self, no_color_updates=False): + if self.orientation.lower() == "horizontal": + orientation = "w" + elif self.orientation.lower() == "vertical": + orientation = "s" + else: + orientation = "w" + + requires_recoloring = self.draw_engine.draw_rounded_slider_with_border_and_button(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + self.apply_widget_scaling(self.button_length), + self.apply_widget_scaling(self.button_corner_radius), + self.value, orientation) + + if no_color_updates is False or requires_recoloring: + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + if self.border_color is None: + self.canvas.itemconfig("border_parts", fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("border_parts", fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + self.canvas.itemconfig("inner_parts", fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + if self.progress_color is None: + self.canvas.itemconfig("progress_parts", fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("progress_parts", fill=ThemeManager.single_color(self.progress_color, self._appearance_mode), + outline=ThemeManager.single_color(self.progress_color, self._appearance_mode)) + + if self.hover_state is True: + self.canvas.itemconfig("slider_parts", + fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + else: + self.canvas.itemconfig("slider_parts", + fill=ThemeManager.single_color(self.button_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + def clicked(self, event=None): + if self.state == "normal": + if self.orientation.lower() == "horizontal": + self.value = (event.x / self._current_width) / self._widget_scaling + else: + self.value = 1 - (event.y / self._current_height) / self._widget_scaling + + if self.value > 1: + self.value = 1 + if self.value < 0: + self.value = 0 + + self.output_value = self.round_to_step_size(self.from_ + (self.value * (self.to - self.from_))) + self.value = (self.output_value - self.from_) / (self.to - self.from_) + + self.draw(no_color_updates=False) + + if self.variable is not None: + self.variable_callback_blocked = True + self.variable.set(round(self.output_value) if isinstance(self.variable, tkinter.IntVar) else self.output_value) + self.variable_callback_blocked = False + + if self.command is not None: + self.command(self.output_value) + + def on_enter(self, event=0): + if self.state == "normal": + self.hover_state = True + self.canvas.itemconfig("slider_parts", + fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + self.hover_state = False + self.canvas.itemconfig("slider_parts", + fill=ThemeManager.single_color(self.button_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + def round_to_step_size(self, value): + if self.number_of_steps is not None: + step_size = (self.to - self.from_) / self.number_of_steps + value = self.to - (round((self.to - value) / step_size) * step_size) + return value + else: + return value + + def get(self): + return self.output_value + + def set(self, output_value, from_variable_callback=False): + if self.from_ < self.to: + if output_value > self.to: + output_value = self.to + elif output_value < self.from_: + output_value = self.from_ + else: + if output_value < self.to: + output_value = self.to + elif output_value > self.from_: + output_value = self.from_ + + self.output_value = self.round_to_step_size(output_value) + self.value = (self.output_value - self.from_) / (self.to - self.from_) + + self.draw(no_color_updates=False) + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(round(self.output_value) if isinstance(self.variable, tkinter.IntVar) else self.output_value) + self.variable_callback_blocked = False + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + self.set(self.variable.get(), from_variable_callback=True) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self.state = kwargs["state"] + self.set_cursor() + require_redraw = True + del kwargs["state"] + + if "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + require_redraw = True + del kwargs["fg_color"] + + if "progress_color" in kwargs: + if kwargs["progress_color"] is None: + self.progress_color = self.fg_color + else: + self.progress_color = kwargs["progress_color"] + require_redraw = True + del kwargs["progress_color"] + + if "button_color" in kwargs: + self.button_color = kwargs["button_color"] + require_redraw = True + del kwargs["button_color"] + + if "button_hover_color" in kwargs: + self.button_hover_color = kwargs["button_hover_color"] + require_redraw = True + del kwargs["button_hover_color"] + + if "border_color" in kwargs: + self.border_color = kwargs["border_color"] + require_redraw = True + del kwargs["border_color"] + + if "border_width" in kwargs: + self.border_width = kwargs["border_width"] + require_redraw = True + del kwargs["border_width"] + + if "from_" in kwargs: + self.from_ = kwargs["from_"] + del kwargs["from_"] + + if "to" in kwargs: + self.to = kwargs["to"] + del kwargs["to"] + + if "number_of_steps" in kwargs: + self.number_of_steps = kwargs["number_of_steps"] + del kwargs["number_of_steps"] + + if "command" in kwargs: + self.command = kwargs["command"] + del kwargs["command"] + + if "variable" in kwargs: + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + self.variable = kwargs["variable"] + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.set(self.variable.get(), from_variable_callback=True) + else: + self.variable = None + + del kwargs["variable"] + + if "width" in kwargs: + self.set_dimensions(width=kwargs["width"]) + del kwargs["width"] + + if "height" in kwargs: + self.set_dimensions(height=kwargs["height"]) + del kwargs["height"] + + super().configure(require_redraw=require_redraw, **kwargs) diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_switch.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_switch.py new file mode 100644 index 00000000..1f58518a --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_switch.py @@ -0,0 +1,324 @@ +import tkinter +import sys + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkSwitch(CTkBaseClass): + def __init__(self, *args, + text="CTkSwitch", + text_font="default_theme", + text_color="default_theme", + text_color_disabled="default_theme", + bg_color=None, + border_color=None, + fg_color="default_theme", + progress_color="default_theme", + button_color="default_theme", + button_hover_color="default_theme", + width=36, + height=18, + corner_radius="default_theme", + # button_corner_radius="default_theme", + border_width="default_theme", + button_length="default_theme", + command=None, + onvalue=1, + offvalue=0, + variable=None, + textvariable=None, + state=tkinter.NORMAL, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self.border_color = border_color + self.fg_color = ThemeManager.theme["color"]["switch"] if fg_color == "default_theme" else fg_color + self.progress_color = ThemeManager.theme["color"]["switch_progress"] if progress_color == "default_theme" else progress_color + self.button_color = ThemeManager.theme["color"]["switch_button"] if button_color == "default_theme" else button_color + self.button_hover_color = ThemeManager.theme["color"]["switch_button_hover"] if button_hover_color == "default_theme" else button_hover_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + + # text + self.text = text + self.text_label = None + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # shape + self.corner_radius = ThemeManager.theme["shape"]["switch_corner_radius"] if corner_radius == "default_theme" else corner_radius + # self.button_corner_radius = ThemeManager.theme["shape"]["switch_button_corner_radius"] if button_corner_radius == "default_theme" else button_corner_radius + self.border_width = ThemeManager.theme["shape"]["switch_border_width"] if border_width == "default_theme" else border_width + self.button_length = ThemeManager.theme["shape"]["switch_button_length"] if button_length == "default_theme" else button_length + self.hover_state = False + self.check_state = False # True if switch is activated + self.state = state + self.onvalue = onvalue + self.offvalue = offvalue + + # callback and control variables + self.command = command + self.variable: tkinter.Variable = variable + self.variable_callback_blocked = False + self.variable_callback_name = None + self.textvariable = textvariable + + # configure grid system (3x1) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=0) + + self.bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.bg_canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=3, rowspan=1, sticky="nswe") + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.grid(row=0, column=0, padx=0, pady=0, columnspan=1, sticky="nswe") + self.draw_engine = DrawEngine(self.canvas) + + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.toggle) + + self.text_label = tkinter.Label(master=self, + bd=0, + text=self.text, + justify=tkinter.LEFT, + font=self.apply_font_scaling(self.text_font), + textvariable=self.textvariable) + self.text_label.grid(row=0, column=2, padx=0, pady=0, sticky="w") + self.text_label["anchor"] = "w" + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.toggle) + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if self.variable.get() == self.onvalue else False + + self.draw() # initial draw + self.set_cursor() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self.apply_widget_scaling(6)) + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + self.bg_canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def destroy(self): + # remove variable_callback from variable callbacks if variable exists + if self.variable is not None: + self.variable.trace_remove("write", self.variable_callback_name) + + super().destroy() + + def set_cursor(self): + if Settings.cursor_manipulation_enabled: + if self.state == tkinter.DISABLED: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="arrow") + if self.text_label is not None: + self.text_label.configure(cursor="arrow") + + elif self.state == tkinter.NORMAL: + if sys.platform == "darwin" and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="pointinghand") + if self.text_label is not None: + self.text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and Settings.cursor_manipulation_enabled: + self.canvas.configure(cursor="hand2") + if self.text_label is not None: + self.text_label.configure(cursor="hand2") + + def draw(self, no_color_updates=False): + + if self.check_state is True: + requires_recoloring = self.draw_engine.draw_rounded_slider_with_border_and_button(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + self.apply_widget_scaling(self.button_length), + self.apply_widget_scaling(self.corner_radius), + 1, "w") + else: + requires_recoloring = self.draw_engine.draw_rounded_slider_with_border_and_button(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width), + self.apply_widget_scaling(self.button_length), + self.apply_widget_scaling(self.corner_radius), + 0, "w") + + if no_color_updates is False or requires_recoloring: + self.bg_canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + if self.border_color is None: + self.canvas.itemconfig("border_parts", fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("border_parts", fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + + self.canvas.itemconfig("inner_parts", fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + if self.progress_color is None: + self.canvas.itemconfig("progress_parts", fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("progress_parts", fill=ThemeManager.single_color(self.progress_color, self._appearance_mode), + outline=ThemeManager.single_color(self.progress_color, self._appearance_mode)) + + self.canvas.itemconfig("slider_parts", fill=ThemeManager.single_color(self.button_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=(ThemeManager.single_color(self.text_color_disabled, self._appearance_mode))) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + self.text_label.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + def toggle(self, event=None): + if self.state is not tkinter.DISABLED: + if self.check_state is True: + self.check_state = False + else: + self.check_state = True + + self.draw(no_color_updates=True) + + if self.variable is not None: + self.variable_callback_blocked = True + self.variable.set(self.onvalue if self.check_state is True else self.offvalue) + self.variable_callback_blocked = False + + if self.command is not None: + self.command() + + def select(self, from_variable_callback=False): + if self.state is not tkinter.DISABLED or from_variable_callback: + self.check_state = True + + self.draw(no_color_updates=True) + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(self.onvalue) + self.variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + if self.state is not tkinter.DISABLED or from_variable_callback: + self.check_state = False + + self.draw(no_color_updates=True) + + if self.variable is not None and not from_variable_callback: + self.variable_callback_blocked = True + self.variable.set(self.offvalue) + self.variable_callback_blocked = False + + def get(self): + return self.onvalue if self.check_state is True else self.offvalue + + def on_enter(self, event=0): + self.hover_state = True + + if self.state is not tkinter.DISABLED: + self.canvas.itemconfig("slider_parts", fill=ThemeManager.single_color(self.button_hover_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_hover_color, self._appearance_mode)) + + def on_leave(self, event=0): + self.hover_state = False + self.canvas.itemconfig("slider_parts", fill=ThemeManager.single_color(self.button_color, self._appearance_mode), + outline=ThemeManager.single_color(self.button_color, self._appearance_mode)) + + def variable_callback(self, var_name, index, mode): + if not self.variable_callback_blocked: + if self.variable.get() == self.onvalue: + self.select(from_variable_callback=True) + elif self.variable.get() == self.offvalue: + self.deselect(from_variable_callback=True) + + def configure(self, require_redraw=False, **kwargs): + if "text" in kwargs: + self.text = kwargs.pop("text") + self.text_label.configure(text=self.text) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.text_label.configure(font=self.apply_font_scaling(self.text_font)) + + if "state" in kwargs: + self.state = kwargs.pop("state") + self.set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + if "progress_color" in kwargs: + new_progress_color = kwargs.pop("progress_color") + if new_progress_color is None: + self.progress_color = self.fg_color + else: + self.progress_color = new_progress_color + require_redraw = True + + if "button_color" in kwargs: + self.button_color = kwargs.pop("button_color") + require_redraw = True + + if "button_hover_color" in kwargs: + self.button_hover_color = kwargs.pop("button_hover_color") + require_redraw = True + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "border_width" in kwargs: + self.border_width = kwargs.pop("border_width") + require_redraw = True + + if "command" in kwargs: + self.command = kwargs.pop("command") + + if "textvariable" in kwargs: + self.textvariable = kwargs.pop("textvariable") + self.text_label.configure(textvariable=self.textvariable) + + if "variable" in kwargs: + if self.variable is not None and self.variable != "": + self.variable.trace_remove("write", self.variable_callback_name) + + self.variable = kwargs.pop("variable") + + if self.variable is not None and self.variable != "": + self.variable_callback_name = self.variable.trace_add("write", self.variable_callback) + self.check_state = True if self.variable.get() == self.onvalue else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) diff --git a/interfaces/UI/libraries/customtkinter/widgets/ctk_textbox.py b/interfaces/UI/libraries/customtkinter/widgets/ctk_textbox.py new file mode 100644 index 00000000..bad0d356 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/ctk_textbox.py @@ -0,0 +1,176 @@ +import tkinter + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkTextbox(CTkBaseClass): + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + border_color="default_theme", + border_width="default_theme", + corner_radius="default_theme", + text_font="default_theme", + text_color="default_theme", + width=200, + height=200, + **kwargs): + + # transfer basic functionality (bg_color, size, _appearance_mode, scaling) to CTkBaseClass + if "master" in kwargs: + super().__init__(*args, bg_color=bg_color, width=width, height=height, master=kwargs.pop("master")) + else: + super().__init__(*args, bg_color=bg_color, width=width, height=height) + + # color + self.fg_color = ThemeManager.theme["color"]["entry"] if fg_color == "default_theme" else fg_color + self.border_color = ThemeManager.theme["color"]["frame_border"] if border_color == "default_theme" else border_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["frame_corner_radius"] if corner_radius == "default_theme" else corner_radius + self.border_width = ThemeManager.theme["shape"]["frame_border_width"] if border_width == "default_theme" else border_width + + # text + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # configure 1x1 grid + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self._current_width), + height=self.apply_widget_scaling(self._current_height)) + self.canvas.grid(row=0, column=0, padx=0, pady=0, rowspan=1, columnspan=1, sticky="nsew") + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + self.draw_engine = DrawEngine(self.canvas) + + for arg in ["highlightthickness", "fg", "bg", "font", "width", "height"]: + kwargs.pop(arg, None) + self.textbox = tkinter.Text(self, + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + width=0, + height=0, + font=self.text_font, + highlightthickness=0, + relief="flat", + insertbackground=ThemeManager.single_color(("black", "white"), self._appearance_mode), + bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + **kwargs) + self.textbox.grid(row=0, column=0, padx=self.corner_radius, pady=self.corner_radius, rowspan=1, columnspan=1, sticky="nsew") + + self.bind('', self.update_dimensions_event) + self.draw() + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + self.textbox.configure(font=self.apply_font_scaling(self.text_font)) + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width), + self.apply_widget_scaling(self._current_height), + self.apply_widget_scaling(self.corner_radius), + self.apply_widget_scaling(self.border_width)) + + if no_color_updates is False or requires_recoloring: + if self.fg_color is None: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.bg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + else: + self.canvas.itemconfig("inner_parts", + fill=ThemeManager.single_color(self.fg_color, self._appearance_mode), + outline=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + self.canvas.itemconfig("border_parts", + fill=ThemeManager.single_color(self.border_color, self._appearance_mode), + outline=ThemeManager.single_color(self.border_color, self._appearance_mode)) + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + self.textbox.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + insertbackground=ThemeManager.single_color(("black", "white"), self._appearance_mode)) + + self.canvas.tag_lower("inner_parts") + self.canvas.tag_lower("border_parts") + + def yview(self, *args): + return self.textbox.yview(*args) + + def xview(self, *args): + return self.textbox.xview(*args) + + def insert(self, *args, **kwargs): + return self.textbox.insert(*args, **kwargs) + + def focus(self): + return self.textbox.focus() + + def tag_add(self, *args, **kwargs): + return self.textbox.tag_add(*args, **kwargs) + + def tag_config(self, *args, **kwargs): + return self.textbox.tag_config(*args, **kwargs) + + def tag_configure(self, *args, **kwargs): + return self.textbox.tag_configure(*args, **kwargs) + + def tag_remove(self, *args, **kwargs): + return self.textbox.tag_remove(*args, **kwargs) + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + require_redraw = True + + # check if CTk widgets are children of the frame and change their bg_color to new frame fg_color + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self.fg_color) + + if "border_color" in kwargs: + self.border_color = kwargs.pop("border_color") + require_redraw = True + + if "corner_radius" in kwargs: + self.corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self.border_width = kwargs.pop("border_width") + require_redraw = True + + if "width" in kwargs: + self.set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self.set_dimensions(height=kwargs.pop("height")) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.textbox.configure(font=self.apply_font_scaling(self.text_font)) + + if "font" in kwargs: + raise ValueError("No attribute named font. Use text_font instead of font for CTk widgets") + + if "bg_color" in kwargs: + super().configure(bg_color=kwargs.pop("bg_color"), require_redraw=require_redraw) + else: + super().configure(require_redraw=require_redraw) + + self.textbox.configure(**kwargs) diff --git a/interfaces/UI/libraries/customtkinter/widgets/dropdown_menu.py b/interfaces/UI/libraries/customtkinter/widgets/dropdown_menu.py new file mode 100644 index 00000000..a553c47f --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/dropdown_menu.py @@ -0,0 +1,170 @@ +import tkinter +import sys +import copy +import re +from typing import Union + +from ..theme_manager import ThemeManager +from ..appearance_mode_tracker import AppearanceModeTracker +from ..scaling_tracker import ScalingTracker + + +class DropdownMenu(tkinter.Menu): + def __init__(self, *args, + min_character_width=18, + fg_color="default_theme", + hover_color="default_theme", + text_color="default_theme", + text_font="default_theme", + command=None, + values=None, + **kwargs): + super().__init__(*args, **kwargs) + + ScalingTracker.add_widget(self.set_scaling, self) + self._widget_scaling = ScalingTracker.get_widget_scaling(self) + self._spacing_scaling = ScalingTracker.get_spacing_scaling(self) + + AppearanceModeTracker.add(self.set_appearance_mode, self) + self._appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + + self.min_character_width = min_character_width + self.fg_color = ThemeManager.theme["color"]["dropdown_color"] if fg_color == "default_theme" else fg_color + self.hover_color = ThemeManager.theme["color"]["dropdown_hover"] if hover_color == "default_theme" else hover_color + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + self.configure_menu_for_platforms() + + self.values = values + self.command = command + + self.add_menu_commands() + + def configure_menu_for_platforms(self): + """ apply platform specific appearance attributes """ + + if sys.platform == "darwin": + self.configure(tearoff=False, + font=self.apply_font_scaling(self.text_font)) + + elif sys.platform.startswith("win"): + self.configure(tearoff=False, + relief="flat", + activebackground=ThemeManager.single_color(self.hover_color, self._appearance_mode), + borderwidth=0, + activeborderwidth=self.apply_widget_scaling(4), + bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + activeforeground=ThemeManager.single_color(self.text_color, self._appearance_mode), + font=self.apply_font_scaling(self.text_font), + cursor="hand2") + + else: + self.configure(tearoff=False, + relief="flat", + activebackground=ThemeManager.single_color(self.hover_color, self._appearance_mode), + borderwidth=0, + activeborderwidth=0, + bg=ThemeManager.single_color(self.fg_color, self._appearance_mode), + fg=ThemeManager.single_color(self.text_color, self._appearance_mode), + activeforeground=ThemeManager.single_color(self.text_color, self._appearance_mode), + font=self.apply_font_scaling(self.text_font)) + + def add_menu_commands(self): + if sys.platform.startswith("linux"): + for value in self.values: + self.add_command(label=" " + value.ljust(self.min_character_width) + " ", + command=lambda v=value: self.button_callback(v), + compound="left") + else: + for value in self.values: + self.add_command(label=value.ljust(self.min_character_width), + command=lambda v=value: self.button_callback(v), + compound="left") + + def open(self, x: Union[int, float], y: Union[int, float]): + if sys.platform == "darwin": + y += self.apply_widget_scaling(8) + else: + y += self.apply_widget_scaling(3) + + if sys.platform == "darwin" or sys.platform.startswith("win"): + self.post(int(x), int(y)) + else: # Linux + self.tk_popup(int(x), int(y)) + + def button_callback(self, value): + if self.command is not None: + self.command(value) + + def configure(self, **kwargs): + if "values" in kwargs: + self.values = kwargs.pop("values") + self.delete(0, "end") # delete all old commands + self.add_menu_commands() + + if "fg_color" in kwargs: + self.fg_color = kwargs.pop("fg_color") + self.configure(bg=ThemeManager.single_color(self.fg_color, self._appearance_mode)) + + if "hover_color" in kwargs: + self.hover_color = kwargs.pop("hover_color") + self.configure(activebackground=ThemeManager.single_color(self.hover_color, self._appearance_mode)) + + if "text_color" in kwargs: + self.text_color = kwargs.pop("text_color") + self.configure(fg=ThemeManager.single_color(self.text_color, self._appearance_mode)) + + if "text_font" in kwargs: + self.text_font = kwargs.pop("text_font") + self.configure(font=self.apply_font_scaling(self.text_font)) + + super().configure(**kwargs) + + def apply_widget_scaling(self, value: Union[int, float, str]) -> Union[float, str]: + if isinstance(value, (int, float)): + return value * self._widget_scaling + else: + return value + + def apply_font_scaling(self, font): + if type(font) == tuple or type(font) == list: + font_list = list(font) + for i in range(len(font_list)): + if (type(font_list[i]) == int or type(font_list[i]) == float) and font_list[i] < 0: + font_list[i] = int(font_list[i] * self._widget_scaling) + return tuple(font_list) + + elif type(font) == str: + for negative_number in re.findall(r" -\d* ", font): + font = font.replace(negative_number, f" {int(int(negative_number) * self._widget_scaling)} ") + return font + + elif isinstance(font, tkinter.font.Font): + new_font_object = copy.copy(font) + if font.cget("size") < 0: + new_font_object.config(size=int(font.cget("size") * self._widget_scaling)) + return new_font_object + + else: + return font + + def set_scaling(self, new_widget_scaling, new_spacing_scaling, new_window_scaling): + self._widget_scaling = new_widget_scaling + self._spacing_scaling = new_spacing_scaling + + self.configure(font=self.apply_font_scaling(self.text_font)) + + if sys.platform.startswith("win"): + self.configure(activeborderwidth=self.apply_widget_scaling(4)) + + def set_appearance_mode(self, mode_string): + """ colors won't update on appearance mode change when dropdown is open, because it's not necessary """ + + if mode_string.lower() == "dark": + self._appearance_mode = 1 + elif mode_string.lower() == "light": + self._appearance_mode = 0 + + self.configure_menu_for_platforms() diff --git a/interfaces/UI/libraries/customtkinter/widgets/widget_base_class.py b/interfaces/UI/libraries/customtkinter/widgets/widget_base_class.py new file mode 100644 index 00000000..303a7d75 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/widgets/widget_base_class.py @@ -0,0 +1,234 @@ +import tkinter +import tkinter.ttk as ttk +import copy +import re +from typing import Callable, Union + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + +from ..windows.ctk_tk import CTk +from ..windows.ctk_toplevel import CTkToplevel +from ..appearance_mode_tracker import AppearanceModeTracker +from ..scaling_tracker import ScalingTracker +from ..theme_manager import ThemeManager + + +class CTkBaseClass(tkinter.Frame): + """ Base class of every CTk widget, handles the dimensions, bg_color, + appearance_mode changes, scaling, bg changes of master if master is not a CTk widget """ + + def __init__(self, + *args, + bg_color: Union[str, tuple] = None, + width: int, + height: int, + **kwargs): + + super().__init__(*args, width=width, height=height, **kwargs) # set desired size of underlying tkinter.Frame + + # dimensions + self._current_width = width # _current_width and _current_height in pixel, represent current size of the widget + self._current_height = height # _current_width and _current_height are independent of the scale + self._desired_width = width # _desired_width and _desired_height, represent desired size set by width and height + self._desired_height = height + + # scaling + ScalingTracker.add_widget(self.set_scaling, self) # add callback for automatic scaling changes + self._widget_scaling = ScalingTracker.get_widget_scaling(self) + self._spacing_scaling = ScalingTracker.get_spacing_scaling(self) + + super().configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + + # save latest geometry function and kwargs + class GeometryCallDict(TypedDict): + function: Callable + kwargs: dict + + self._last_geometry_manager_call: Union[GeometryCallDict, None] = None + + # add set_appearance_mode method to callback list of AppearanceModeTracker for appearance mode changes + AppearanceModeTracker.add(self.set_appearance_mode, self) + self._appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + + # background color + self.bg_color = self.detect_color_of_master() if bg_color is None else bg_color + + super().configure(bg=ThemeManager.single_color(self.bg_color, self._appearance_mode)) + + # overwrite configure methods of master when master is tkinter widget, so that bg changes get applied on child CTk widget as well + if isinstance(self.master, (tkinter.Tk, tkinter.Toplevel, tkinter.Frame)) and not isinstance(self.master, (CTkBaseClass, CTk, CTkToplevel)): + master_old_configure = self.master.config + + def new_configure(*args, **kwargs): + if "bg" in kwargs: + self.configure(bg_color=kwargs["bg"]) + elif "background" in kwargs: + self.configure(bg_color=kwargs["background"]) + + # args[0] is dict when attribute gets changed by widget[] syntax + elif len(args) > 0 and type(args[0]) == dict: + if "bg" in args[0]: + self.configure(bg_color=args[0]["bg"]) + elif "background" in args[0]: + self.configure(bg_color=args[0]["background"]) + master_old_configure(*args, **kwargs) + + self.master.config = new_configure + self.master.configure = new_configure + + def destroy(self): + AppearanceModeTracker.remove(self.set_appearance_mode) + super().destroy() + + def place(self, **kwargs): + self._last_geometry_manager_call = {"function": super().place, "kwargs": kwargs} + super().place(**self.apply_argument_scaling(kwargs)) + + def pack(self, **kwargs): + self._last_geometry_manager_call = {"function": super().pack, "kwargs": kwargs} + super().pack(**self.apply_argument_scaling(kwargs)) + + def grid(self, **kwargs): + self._last_geometry_manager_call = {"function": super().grid, "kwargs": kwargs} + super().grid(**self.apply_argument_scaling(kwargs)) + + def apply_argument_scaling(self, kwargs: dict) -> dict: + scaled_kwargs = copy.copy(kwargs) + + if "pady" in scaled_kwargs: + if isinstance(scaled_kwargs["pady"], (int, float, str)): + scaled_kwargs["pady"] = self.apply_spacing_scaling(scaled_kwargs["pady"]) + elif isinstance(scaled_kwargs["pady"], tuple): + scaled_kwargs["pady"] = tuple([self.apply_spacing_scaling(v) for v in scaled_kwargs["pady"]]) + if "padx" in kwargs: + if isinstance(scaled_kwargs["padx"], (int, float, str)): + scaled_kwargs["padx"] = self.apply_spacing_scaling(scaled_kwargs["padx"]) + elif isinstance(scaled_kwargs["padx"], tuple): + scaled_kwargs["padx"] = tuple([self.apply_spacing_scaling(v) for v in scaled_kwargs["padx"]]) + + if "x" in scaled_kwargs: + scaled_kwargs["x"] = self.apply_spacing_scaling(scaled_kwargs["x"]) + if "y" in scaled_kwargs: + scaled_kwargs["y"] = self.apply_spacing_scaling(scaled_kwargs["y"]) + + return scaled_kwargs + + def configure(self, require_redraw=False, **kwargs): + """ basic configure with bg_color support, to be overridden """ + + if "bg_color" in kwargs: + new_bg_color = kwargs.pop("bg_color") + if new_bg_color is None: + self.bg_color = self.detect_color_of_master() + else: + self.bg_color = new_bg_color + require_redraw = True + + super().configure(**kwargs) + + if require_redraw: + self.draw() + + def update_dimensions_event(self, event): + # only redraw if dimensions changed (for performance), independent of scaling + if round(self._current_width) != round(event.width / self._widget_scaling) or round(self._current_height) != round(event.height / self._widget_scaling): + self._current_width = (event.width / self._widget_scaling) # adjust current size according to new size given by event + self._current_height = (event.height / self._widget_scaling) # _current_width and _current_height are independent of the scale + + self.draw(no_color_updates=True) # faster drawing without color changes + + def detect_color_of_master(self, master_widget=None): + """ detect color of self.master widget to set correct bg_color """ + + if master_widget is None: + master_widget = self.master + + if isinstance(master_widget, (CTkBaseClass, CTk, CTkToplevel)) and hasattr(master_widget, "fg_color"): + if master_widget.fg_color is not None: + return master_widget.fg_color + + # if fg_color of master is None, try to retrieve fg_color from master of master + elif hasattr(master_widget.master, "master"): + return self.detect_color_of_master(master_widget.master) + + elif isinstance(master_widget, (ttk.Frame, ttk.LabelFrame, ttk.Notebook, ttk.Label)): # master is ttk widget + try: + ttk_style = ttk.Style() + return ttk_style.lookup(master_widget.winfo_class(), 'background') + except Exception: + return "#FFFFFF", "#000000" + + else: # master is normal tkinter widget + try: + return master_widget.cget("bg") # try to get bg color by .cget() method + except Exception: + return "#FFFFFF", "#000000" + + def set_appearance_mode(self, mode_string): + if mode_string.lower() == "dark": + self._appearance_mode = 1 + elif mode_string.lower() == "light": + self._appearance_mode = 0 + + self.draw() + + def set_scaling(self, new_widget_scaling, new_spacing_scaling, new_window_scaling): + self._widget_scaling = new_widget_scaling + self._spacing_scaling = new_spacing_scaling + + super().configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + + if self._last_geometry_manager_call is not None: + self._last_geometry_manager_call["function"](**self.apply_argument_scaling(self._last_geometry_manager_call["kwargs"])) + + def set_dimensions(self, width=None, height=None): + if width is not None: + self._desired_width = width + if height is not None: + self._desired_height = height + + super().configure(width=self.apply_widget_scaling(self._desired_width), + height=self.apply_widget_scaling(self._desired_height)) + + def apply_widget_scaling(self, value: Union[int, float, str]) -> Union[float, str]: + if isinstance(value, (int, float)): + return value * self._widget_scaling + else: + return value + + def apply_spacing_scaling(self, value: Union[int, float, str]) -> Union[float, str]: + if isinstance(value, (int, float)): + return value * self._spacing_scaling + else: + return value + + def apply_font_scaling(self, font): + if type(font) == tuple or type(font) == list: + font_list = list(font) + for i in range(len(font_list)): + if (type(font_list[i]) == int or type(font_list[i]) == float) and font_list[i] < 0: + font_list[i] = int(font_list[i] * self._widget_scaling) + return tuple(font_list) + + elif type(font) == str: + for negative_number in re.findall(r" -\d* ", font): + font = font.replace(negative_number, f" {int(int(negative_number) * self._widget_scaling)} ") + return font + + elif isinstance(font, tkinter.font.Font): + new_font_object = copy.copy(font) + if font.cget("size") < 0: + new_font_object.config(size=int(font.cget("size") * self._widget_scaling)) + return new_font_object + + else: + return font + + def draw(self, no_color_updates: bool = False): + """ abstract of draw method to be overridden """ + pass diff --git a/interfaces/UI/libraries/customtkinter/windows/__init__.py b/interfaces/UI/libraries/customtkinter/windows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/interfaces/UI/libraries/customtkinter/windows/ctk_input_dialog.py b/interfaces/UI/libraries/customtkinter/windows/ctk_input_dialog.py new file mode 100644 index 00000000..d0c204fb --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/windows/ctk_input_dialog.py @@ -0,0 +1,119 @@ +import tkinter +import time + +from ..widgets.ctk_label import CTkLabel +from ..widgets.ctk_entry import CTkEntry +from ..widgets.ctk_frame import CTkFrame +from ..windows.ctk_toplevel import CTkToplevel +from ..widgets.ctk_button import CTkButton +from ..appearance_mode_tracker import AppearanceModeTracker +from ..theme_manager import ThemeManager + + +class CTkInputDialog: + def __init__(self, + master=None, + title="CTkDialog", + text="CTkDialog", + fg_color="default_theme", + hover_color="default_theme", + border_color="default_theme"): + + self.appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + self.master = master + + self.user_input = None + self.running = False + + self.height = len(text.split("\n"))*20 + 150 + + self.text = text + self.window_bg_color = ThemeManager.theme["color"]["window_bg_color"] + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color + self.border_color = ThemeManager.theme["color"]["button_hover"] if border_color == "default_theme" else border_color + + self.top = CTkToplevel() + self.top.geometry(f"{280}x{self.height}") + self.top.minsize(280, self.height) + self.top.maxsize(280, self.height) + self.top.title(title) + self.top.lift() + self.top.focus_force() + self.top.grab_set() + + self.top.protocol("WM_DELETE_WINDOW", self.on_closing) + + self.top.after(10, self.create_widgets) # create widgets with slight delay, to avoid white flickering of background + + def create_widgets(self): + self.label_frame = CTkFrame(master=self.top, + corner_radius=0, + fg_color=self.window_bg_color, + width=300, + height=self.height-100) + self.label_frame.place(relx=0.5, rely=0, anchor=tkinter.N) + + self.button_and_entry_frame = CTkFrame(master=self.top, + corner_radius=0, + fg_color=self.window_bg_color, + width=300, + height=100) + self.button_and_entry_frame.place(relx=0.5, rely=1, anchor=tkinter.S) + + self.myLabel = CTkLabel(master=self.label_frame, + text=self.text, + width=300, + fg_color=None, + height=self.height-100) + self.myLabel.place(relx=0.5, rely=0.5, anchor=tkinter.CENTER) + + self.entry = CTkEntry(master=self.button_and_entry_frame, + width=230) + self.entry.place(relx=0.5, rely=0.15, anchor=tkinter.CENTER) + + self.ok_button = CTkButton(master=self.button_and_entry_frame, + text='Ok', + width=100, + command=self.ok_event, + fg_color=self.fg_color, + hover_color=self.hover_color, + border_color=self.border_color) + self.ok_button.place(relx=0.28, rely=0.65, anchor=tkinter.CENTER) + + self.cancel_button = CTkButton(master=self.button_and_entry_frame, + text='Cancel', + width=100, + command=self.cancel_event, + fg_color=self.fg_color, + hover_color=self.hover_color, + border_color=self.border_color) + self.cancel_button.place(relx=0.72, rely=0.65, anchor=tkinter.CENTER) + + self.entry.entry.focus_force() + self.entry.bind("", self.ok_event) + + def ok_event(self, event=None): + self.user_input = self.entry.get() + self.running = False + + def on_closing(self): + self.running = False + + def cancel_event(self): + self.running = False + + def get_input(self): + self.running = True + + while self.running: + try: + self.top.update() + except Exception: + return self.user_input + finally: + time.sleep(0.01) + + time.sleep(0.05) + self.top.destroy() + return self.user_input diff --git a/interfaces/UI/libraries/customtkinter/windows/ctk_tk.py b/interfaces/UI/libraries/customtkinter/windows/ctk_tk.py new file mode 100644 index 00000000..7c3b4b74 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/windows/ctk_tk.py @@ -0,0 +1,350 @@ +import tkinter +from distutils.version import StrictVersion as Version +import sys +import os +import platform +import ctypes +import re +from typing import Union, Tuple + +from ..appearance_mode_tracker import AppearanceModeTracker +from ..theme_manager import ThemeManager +from ..scaling_tracker import ScalingTracker +from ..settings import Settings + + +class CTk(tkinter.Tk): + def __init__(self, *args, + fg_color="default_theme", + **kwargs): + + ScalingTracker.activate_high_dpi_awareness() # make process DPI aware + self.enable_macos_dark_title_bar() + + super().__init__(*args, **kwargs) + + # add set_appearance_mode method to callback list of AppearanceModeTracker for appearance mode changes + AppearanceModeTracker.add(self.set_appearance_mode, self) + self.appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + + # add set_scaling method to callback list of ScalingTracker for automatic scaling changes + ScalingTracker.add_widget(self.set_scaling, self) + self.window_scaling = ScalingTracker.get_window_scaling(self) + + self.current_width = 600 # initial window size, always without scaling + self.current_height = 500 + self.min_width: int = 0 + self.min_height: int = 0 + self.max_width: int = 1_000_000 + self.max_height: int = 1_000_000 + self.last_resizable_args: Union[Tuple[list, dict], None] = None # (args, kwargs) + + self.fg_color = ThemeManager.theme["color"]["window_bg_color"] if fg_color == "default_theme" else fg_color + + if "bg" in kwargs: + self.fg_color = kwargs["bg"] + del kwargs["bg"] + elif "background" in kwargs: + self.fg_color = kwargs["background"] + del kwargs["background"] + + super().configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + super().title("CTk") + self.geometry(f"{self.current_width}x{self.current_height}") + + self.state_before_windows_set_titlebar_color = None + self.window_exists = False # indicates if the window is already shown through update() or mainloop() after init + self.withdraw_called_before_window_exists = False # indicates if withdraw() was called before window is first shown through update() or mainloop() + self.iconify_called_before_window_exists = False # indicates if iconify() was called before window is first shown through update() or mainloop() + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + self.bind('', self.update_dimensions_event) + + self.block_update_dimensions_event = False + + def update_dimensions_event(self, event=None): + if not self.block_update_dimensions_event: + detected_width = self.winfo_width() # detect current window size + detected_height = self.winfo_height() + + if self.current_width != round(detected_width / self.window_scaling) or self.current_height != round(detected_height / self.window_scaling): + self.current_width = round(detected_width / self.window_scaling) # adjust current size according to new size given by event + self.current_height = round(detected_height / self.window_scaling) # _current_width and _current_height are independent of the scale + + def set_scaling(self, new_widget_scaling, new_spacing_scaling, new_window_scaling): + self.window_scaling = new_window_scaling + + # block update_dimensions_event to prevent current_width and current_height to get updated + self.block_update_dimensions_event = True + + # force new dimensions on window by using min, max, and geometry + super().minsize(self.apply_window_scaling(self.current_width), self.apply_window_scaling(self.current_height)) + super().maxsize(self.apply_window_scaling(self.current_width), self.apply_window_scaling(self.current_height)) + super().geometry(f"{self.apply_window_scaling(self.current_width)}x"+f"{self.apply_window_scaling(self.current_height)}") + + # set new scaled min and max with 400ms delay (otherwise it won't work for some reason) + self.after(400, self.set_scaled_min_max) + + # release the blocking of update_dimensions_event after a small amount of time (slight delay is necessary) + def set_block_update_dimensions_event_false(): + self.block_update_dimensions_event = False + self.after(100, lambda: set_block_update_dimensions_event_false()) + + def set_scaled_min_max(self): + if self.min_width is not None or self.min_height is not None: + super().minsize(self.apply_window_scaling(self.min_width), self.apply_window_scaling(self.min_height)) + if self.max_width is not None or self.max_height is not None: + super().maxsize(self.apply_window_scaling(self.max_width), self.apply_window_scaling(self.max_height)) + + def destroy(self): + AppearanceModeTracker.remove(self.set_appearance_mode) + ScalingTracker.remove_window(self.set_scaling, self) + self.disable_macos_dark_title_bar() + super().destroy() + + def withdraw(self): + if self.window_exists is False: + self.withdraw_called_before_window_exists = True + super().withdraw() + + def iconify(self): + if self.window_exists is False: + self.iconify_called_before_window_exists = True + super().iconify() + + def update(self): + if self.window_exists is False: + self.window_exists = True + + if sys.platform.startswith("win"): + if not self.withdraw_called_before_window_exists and not self.iconify_called_before_window_exists: + # print("window dont exists -> deiconify in update") + self.deiconify() + + super().update() + + def mainloop(self, *args, **kwargs): + if not self.window_exists: + self.window_exists = True + + if sys.platform.startswith("win"): + if not self.withdraw_called_before_window_exists and not self.iconify_called_before_window_exists: + # print("window dont exists -> deiconify in mainloop") + self.deiconify() + + super().mainloop(*args, **kwargs) + + def resizable(self, *args, **kwargs): + super().resizable(*args, **kwargs) + self.last_resizable_args = (args, kwargs) + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + def minsize(self, width=None, height=None): + self.min_width = width + self.min_height = height + if self.current_width < width: self.current_width = width + if self.current_height < height: self.current_height = height + super().minsize(self.apply_window_scaling(self.min_width), self.apply_window_scaling(self.min_height)) + + def maxsize(self, width=None, height=None): + self.max_width = width + self.max_height = height + if self.current_width > width: self.current_width = width + if self.current_height > height: self.current_height = height + super().maxsize(self.apply_window_scaling(self.max_width), self.apply_window_scaling(self.max_height)) + + def geometry(self, geometry_string: str = None): + if geometry_string is not None: + super().geometry(self.apply_geometry_scaling(geometry_string)) + + # update width and height attributes + width, height, x, y = self.parse_geometry_string(geometry_string) + if width is not None and height is not None: + self.current_width = max(self.min_width, min(width, self.max_width)) # bound value between min and max + self.current_height = max(self.min_height, min(height, self.max_height)) + else: + return self.reverse_geometry_scaling(super().geometry()) + + @staticmethod + def parse_geometry_string(geometry_string: str) -> tuple: + # index: 1 2 3 4 5 6 + # regex group structure: ('x', '', '', '+-+-', '-', '-') + result = re.search(r"((\d+)x(\d+)){0,1}(\+{0,1}([+-]{0,1}\d+)\+{0,1}([+-]{0,1}\d+)){0,1}", geometry_string) + + width = int(result.group(2)) if result.group(2) is not None else None + height = int(result.group(3)) if result.group(3) is not None else None + x = int(result.group(5)) if result.group(5) is not None else None + y = int(result.group(6)) if result.group(6) is not None else None + + return width, height, x, y + + def apply_geometry_scaling(self, geometry_string: str) -> str: + width, height, x, y = self.parse_geometry_string(geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width * self.window_scaling)}x{round(height * self.window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width * self.window_scaling)}x{round(height * self.window_scaling)}+{x}+{y}" + + def reverse_geometry_scaling(self, scaled_geometry_string: str) -> str: + width, height, x, y = self.parse_geometry_string(scaled_geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width / self.window_scaling)}x{round(height / self.window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width / self.window_scaling)}x{round(height / self.window_scaling)}+{x}+{y}" + + def apply_window_scaling(self, value): + if isinstance(value, (int, float)): + return int(value * self.window_scaling) + else: + return value + + def config(self, *args, **kwargs): + self.configure(*args, **kwargs) + + def configure(self, *args, **kwargs): + bg_changed = False + + if "bg" in kwargs: + self.fg_color = kwargs["bg"] + bg_changed = True + kwargs["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "background" in kwargs: + self.fg_color = kwargs["background"] + bg_changed = True + kwargs["background"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + kwargs["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + del kwargs["fg_color"] + bg_changed = True + + elif len(args) > 0 and type(args[0]) == dict: + if "bg" in args[0]: + self.fg_color=args[0]["bg"] + bg_changed = True + args[0]["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "background" in args[0]: + self.fg_color=args[0]["background"] + bg_changed = True + args[0]["background"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + + if bg_changed: + from ..widgets.widget_base_class import CTkBaseClass + + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self.fg_color) + + super().configure(*args, **kwargs) + + @staticmethod + def enable_macos_dark_title_bar(): + if sys.platform == "darwin" and not Settings.deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults write -g NSRequiresAquaSystemAppearance -bool No") + # This command allows dark-mode for all programs + + @staticmethod + def disable_macos_dark_title_bar(): + if sys.platform == "darwin" and not Settings.deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults delete -g NSRequiresAquaSystemAppearance") + # This command reverts the dark-mode setting for all programs. + + def windows_set_titlebar_color(self, color_mode: str): + """ + Set the titlebar color of the window to light or dark theme on Microsoft Windows. + + Credits for this function: + https://stackoverflow.com/questions/23836000/can-i-change-the-title-bar-in-tkinter/70724666#70724666 + + MORE INFO: + https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute + """ + + if sys.platform.startswith("win") and not Settings.deactivate_windows_window_header_manipulation: + + if self.window_exists: + self.state_before_windows_set_titlebar_color = self.state() + # print("window_exists -> state_before_windows_set_titlebar_color: ", self.state_before_windows_set_titlebar_color) + + if self.state_before_windows_set_titlebar_color != "iconic" or self.state_before_windows_set_titlebar_color != "withdrawn": + super().withdraw() # hide window so that it can be redrawn after the titlebar change so that the color change is visible + else: + # print("window dont exists -> withdraw and update") + super().withdraw() + super().update() + + if color_mode.lower() == "dark": + value = 1 + elif color_mode.lower() == "light": + value = 0 + else: + return + + try: + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) != 0: + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20h1 + ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) + + except Exception as err: + print(err) + + if self.window_exists: + # print("window_exists -> return to original state: ", self.state_before_windows_set_titlebar_color) + if self.state_before_windows_set_titlebar_color == "normal": + self.deiconify() + elif self.state_before_windows_set_titlebar_color == "iconic": + self.iconify() + elif self.state_before_windows_set_titlebar_color == "zoomed": + self.state("zoomed") + else: + self.state(self.state_before_windows_set_titlebar_color) # other states + else: + pass # wait for update or mainloop to be called + + def set_appearance_mode(self, mode_string): + if mode_string.lower() == "dark": + self.appearance_mode = 1 + elif mode_string.lower() == "light": + self.appearance_mode = 0 + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + super().configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) diff --git a/interfaces/UI/libraries/customtkinter/windows/ctk_toplevel.py b/interfaces/UI/libraries/customtkinter/windows/ctk_toplevel.py new file mode 100644 index 00000000..4291ad27 --- /dev/null +++ b/interfaces/UI/libraries/customtkinter/windows/ctk_toplevel.py @@ -0,0 +1,317 @@ +import tkinter +from distutils.version import StrictVersion as Version +import sys +import os +import platform +import ctypes +import re +from typing import Union, Tuple + +from ..appearance_mode_tracker import AppearanceModeTracker +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..scaling_tracker import ScalingTracker + + +class CTkToplevel(tkinter.Toplevel): + def __init__(self, *args, + fg_color="default_theme", + **kwargs): + + self.enable_macos_dark_title_bar() + super().__init__(*args, **kwargs) + self.appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + + # add set_scaling method to callback list of ScalingTracker for automatic scaling changes + ScalingTracker.add_widget(self.set_scaling, self) + self.window_scaling = ScalingTracker.get_window_scaling(self) + + self.current_width = 200 # initial window size, always without scaling + self.current_height = 200 + self.min_width: int = 0 + self.min_height: int = 0 + self.max_width: int = 1_000_000 + self.max_height: int = 1_000_000 + self.last_resizable_args: Union[Tuple[list, dict], None] = None # (args, kwargs) + + self.fg_color = ThemeManager.theme["color"]["window_bg_color"] if fg_color == "default_theme" else fg_color + + if "bg" in kwargs: + self.fg_color = kwargs["bg"] + del kwargs["bg"] + elif "background" in kwargs: + self.fg_color = kwargs["background"] + del kwargs["background"] + + # add set_appearance_mode method to callback list of AppearanceModeTracker for appearance mode changes + AppearanceModeTracker.add(self.set_appearance_mode, self) + super().configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + super().title("CTkToplevel") + + self.state_before_windows_set_titlebar_color = None + self.windows_set_titlebar_color_called = False # indicates if windows_set_titlebar_color was called, stays True until revert_withdraw_after_windows_set_titlebar_color is called + self.withdraw_called_after_windows_set_titlebar_color = False # indicates if withdraw() was called after windows_set_titlebar_color + self.iconify_called_after_windows_set_titlebar_color = False # indicates if iconify() was called after windows_set_titlebar_color + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + self.bind('', self.update_dimensions_event) + + def update_dimensions_event(self, event=None): + detected_width = self.winfo_width() # detect current window size + detected_height = self.winfo_height() + + if self.current_width != round(detected_width / self.window_scaling) or self.current_height != round(detected_height / self.window_scaling): + self.current_width = round(detected_width / self.window_scaling) # adjust current size according to new size given by event + self.current_height = round(detected_height / self.window_scaling) # _current_width and _current_height are independent of the scale + + def set_scaling(self, new_widget_scaling, new_spacing_scaling, new_window_scaling): + self.window_scaling = new_window_scaling + + # force new dimensions on window by using min, max, and geometry + super().minsize(self.apply_window_scaling(self.current_width), self.apply_window_scaling(self.current_height)) + super().maxsize(self.apply_window_scaling(self.current_width), self.apply_window_scaling(self.current_height)) + super().geometry( + f"{self.apply_window_scaling(self.current_width)}x" + f"{self.apply_window_scaling(self.current_height)}") + + # set new scaled min and max with 400ms delay (otherwise it won't work for some reason) + self.after(400, self.set_scaled_min_max) + + def set_scaled_min_max(self): + if self.min_width is not None or self.min_height is not None: + super().minsize(self.apply_window_scaling(self.min_width), self.apply_window_scaling(self.min_height)) + if self.max_width is not None or self.max_height is not None: + super().maxsize(self.apply_window_scaling(self.max_width), self.apply_window_scaling(self.max_height)) + + def geometry(self, geometry_string: str = None): + if geometry_string is not None: + super().geometry(self.apply_geometry_scaling(geometry_string)) + + # update width and height attributes + width, height, x, y = self.parse_geometry_string(geometry_string) + if width is not None and height is not None: + self.current_width = max(self.min_width, min(width, self.max_width)) # bound value between min and max + self.current_height = max(self.min_height, min(height, self.max_height)) + else: + return self.reverse_geometry_scaling(super().geometry()) + + @staticmethod + def parse_geometry_string(geometry_string: str) -> tuple: + # index: 1 2 3 4 5 6 + # regex group structure: ('x', '', '', '+-+-', '-', '-') + result = re.search(r"((\d+)x(\d+)){0,1}(\+{0,1}([+-]{0,1}\d+)\+{0,1}([+-]{0,1}\d+)){0,1}", geometry_string) + + width = int(result.group(2)) if result.group(2) is not None else None + height = int(result.group(3)) if result.group(3) is not None else None + x = int(result.group(5)) if result.group(5) is not None else None + y = int(result.group(6)) if result.group(6) is not None else None + + return width, height, x, y + + def apply_geometry_scaling(self, geometry_string: str) -> str: + width, height, x, y = self.parse_geometry_string(geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width * self.window_scaling)}x{round(height * self.window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width * self.window_scaling)}x{round(height * self.window_scaling)}+{x}+{y}" + + def reverse_geometry_scaling(self, scaled_geometry_string: str) -> str: + width, height, x, y = self.parse_geometry_string(scaled_geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width / self.window_scaling)}x{round(height / self.window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width / self.window_scaling)}x{round(height / self.window_scaling)}+{x}+{y}" + + def apply_window_scaling(self, value): + if isinstance(value, (int, float)): + return int(value * self.window_scaling) + else: + return value + + def destroy(self): + AppearanceModeTracker.remove(self.set_appearance_mode) + ScalingTracker.remove_window(self.set_scaling, self) + self.disable_macos_dark_title_bar() + super().destroy() + + def withdraw(self): + if self.windows_set_titlebar_color_called: + self.withdraw_called_after_windows_set_titlebar_color = True + super().withdraw() + + def iconify(self): + if self.windows_set_titlebar_color_called: + self.iconify_called_after_windows_set_titlebar_color = True + super().iconify() + + def resizable(self, *args, **kwargs): + super().resizable(*args, **kwargs) + self.last_resizable_args = (args, kwargs) + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + def minsize(self, width=None, height=None): + self.min_width = width + self.min_height = height + if self.current_width < width: self.current_width = width + if self.current_height < height: self.current_height = height + super().minsize(self.apply_window_scaling(self.min_width), self.apply_window_scaling(self.min_height)) + + def maxsize(self, width=None, height=None): + self.max_width = width + self.max_height = height + if self.current_width > width: self.current_width = width + if self.current_height > height: self.current_height = height + super().maxsize(self.apply_window_scaling(self.max_width), self.apply_window_scaling(self.max_height)) + + def config(self, *args, **kwargs): + self.configure(*args, **kwargs) + + def configure(self, *args, **kwargs): + bg_changed = False + + if "bg" in kwargs: + self.fg_color = kwargs["bg"] + bg_changed = True + kwargs["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "background" in kwargs: + self.fg_color = kwargs["background"] + bg_changed = True + kwargs["background"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + kwargs["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + del kwargs["fg_color"] + bg_changed = True + + elif len(args) > 0 and type(args[0]) == dict: + if "bg" in args[0]: + self.fg_color=args[0]["bg"] + bg_changed = True + args[0]["bg"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + elif "background" in args[0]: + self.fg_color=args[0]["background"] + bg_changed = True + args[0]["background"] = ThemeManager.single_color(self.fg_color, self.appearance_mode) + + if bg_changed: + from ..widgets.widget_base_class import CTkBaseClass + + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self.fg_color) + + super().configure(*args, **kwargs) + + @staticmethod + def enable_macos_dark_title_bar(): + if sys.platform == "darwin" and not Settings.deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults write -g NSRequiresAquaSystemAppearance -bool No") + + @staticmethod + def disable_macos_dark_title_bar(): + if sys.platform == "darwin" and not Settings.deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults delete -g NSRequiresAquaSystemAppearance") + # This command reverts the dark-mode setting for all programs. + + def windows_set_titlebar_color(self, color_mode: str): + """ + Set the titlebar color of the window to light or dark theme on Microsoft Windows. + + Credits for this function: + https://stackoverflow.com/questions/23836000/can-i-change-the-title-bar-in-tkinter/70724666#70724666 + + MORE INFO: + https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute + """ + + if sys.platform.startswith("win") and not Settings.deactivate_windows_window_header_manipulation: + + self.state_before_windows_set_titlebar_color = self.state() + super().withdraw() # hide window so that it can be redrawn after the titlebar change so that the color change is visible + super().update() + + if color_mode.lower() == "dark": + value = 1 + elif color_mode.lower() == "light": + value = 0 + else: + return + + try: + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) != 0: + # try with DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20h1 + ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) + + except Exception as err: + print(err) + + self.windows_set_titlebar_color_called = True + self.after(5, self.revert_withdraw_after_windows_set_titlebar_color) + + def revert_withdraw_after_windows_set_titlebar_color(self): + """ if in a short time (5ms) after """ + if self.windows_set_titlebar_color_called: + + if self.withdraw_called_after_windows_set_titlebar_color: + pass # leave it withdrawed + elif self.iconify_called_after_windows_set_titlebar_color: + super().iconify() + else: + if self.state_before_windows_set_titlebar_color == "normal": + self.deiconify() + elif self.state_before_windows_set_titlebar_color == "iconic": + self.iconify() + elif self.state_before_windows_set_titlebar_color == "zoomed": + self.state("zoomed") + else: + self.state(self.state_before_windows_set_titlebar_color) # other states + + self.windows_set_titlebar_color_called = False + self.withdraw_called_after_windows_set_titlebar_color = False + self.iconify_called_after_windows_set_titlebar_color = False + + def set_appearance_mode(self, mode_string): + if mode_string.lower() == "dark": + self.appearance_mode = 1 + elif mode_string.lower() == "light": + self.appearance_mode = 0 + + if sys.platform.startswith("win"): + if self.appearance_mode == 1: + self.windows_set_titlebar_color("dark") + else: + self.windows_set_titlebar_color("light") + + super().configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) diff --git a/interfaces/UI/libraries/tktooltip/tooltip.py b/interfaces/UI/libraries/tktooltip/tooltip.py new file mode 100644 index 00000000..8bb4de80 --- /dev/null +++ b/interfaces/UI/libraries/tktooltip/tooltip.py @@ -0,0 +1,145 @@ +""" +Module defining the ToolTip widget +""" +from __future__ import annotations + +import time +import tkinter as tk +from typing import Callable + +# This code is based on Tucker Beck's implementation licensed under an MIT License +# Original code: http://code.activestate.com/recipes/576688-tooltip-for-tkinter/ + + +class ToolTip(tk.Toplevel): + """ + Creates a ToolTip (pop-up) widget for tkinter + """ + + def __init__( + self, + widget: tk.Widget, + msg: str | Callable = None, + delay: float = 0.0, + follow: bool = True, + refresh: float = 1.0, + x_offset: int = +10, + y_offset: int = +10, + parent_kwargs: dict = {"bg": "black", "padx": 1, "pady": 1}, + **message_kwargs, + ): + """Create a ToolTip. Allows for `**kwargs` to be passed on both + the parent frame and the ToolTip message + + Parameters + ---------- + widget : tk.Widget + The widget this ToolTip is assigned to + msg : `Union[str, Callable]`, optional + A string message (can be dynamic) assigned to the ToolTip. + Alternatively, it can be set to a function thatreturns a string, + by default None + delay : `float`, optional + Delay in seconds before the ToolTip appears, by default 0.0 + follow : `bool`, optional + ToolTip follows motion, otherwise hides, by default True + refresh : `float`, optional + Refresh rate in seconds for strings and functions when mouse is + stationary and inside the widget, by default 1.0 + x_offset : `int`, optional + x-coordinate offset for the ToolTip, by default +10 + y_offset : `int`, optional + y-coordinate offset for the ToolTip, by default +10 + parent_kwargs : `dict`, optional + Optional kwargs to be passed into the parent frame, + by default `{"bg": "black", "padx": 1, "pady": 1}` + **message_kwargs : tkinter `**kwargs` passed directly into the ToolTip + """ + self.widget = widget + # ToolTip should have the same parent as the widget unless stated + # otherwise in the `parent_kwargs` + tk.Toplevel.__init__(self, **parent_kwargs) + self.withdraw() # Hide initially in case there is a delay + # Disable ToolTip's title bar + self.overrideredirect(True) + + # StringVar instance for msg string|function + self.msgVar = tk.StringVar() + # This can be a string or a function + # Do not bother doing any sort of checks here since it sometimes results + # into multiple spawn-hide calls being made when swapping between tooltips + self.msg = msg + self.delay = delay + self.follow = follow + self.refresh = refresh + self.x_offset = x_offset + self.y_offset = y_offset + # visibility status of the ToolTip inside|outside|visible + self.status = "outside" + self.last_moved = 0 + # use Message widget to host ToolTip + tk.Message(self, textvariable=self.msgVar, aspect=1000, **message_kwargs).grid() + # Add bindings to the widget without overriding the existing ones + self.widget.bind("", self.on_enter, add="+") + self.widget.bind("", self.on_leave, add="+") + self.widget.bind("", self.on_enter, add="+") + self.widget.bind("", self.on_leave, add="+") + + def on_enter(self, event) -> None: + """ + Processes motion within the widget including entering and moving. + """ + self.last_moved = time.time() + + # Set the status as inside for the very first time + if self.status == "outside": + self.status = "inside" + + # If the follow flag is not set, motion within the widget will + # make the ToolTip dissapear + if not self.follow: + self.status = "inside" + self.withdraw() + + # Offsets the ToolTip using the coordinates od an event as an origin + self.geometry(f"+{event.x_root + self.x_offset}+{event.y_root + self.y_offset}") + + # Time is integer and in milliseconds + self.after(int(self.delay * 1000), self._show) + + def on_leave(self, event=None) -> None: + """ + Hides the ToolTip. + """ + self.status = "outside" + self.withdraw() + + def _show(self) -> None: + """ + Displays the ToolTip. + + Recursively queues `_show` in the scheduler every `self.refresh` seconds + """ + if self.status == "inside" and time.time() - self.last_moved > self.delay: + self.status = "visible" + + if self.status == "visible": + # Update the string with the latest function call + # Try and call self.msg as a function, if msg is not callable try and + # set it as a normal string if that fails throw an error + try: + self.msgVar.set(self.msg()) + except TypeError: + # Intentionally do not check if msg is str, can be a list of str + self.msgVar.set(self.msg) + except: + raise ( + "Error: ToolTip `msg` must be a string or string returning " + + f"function instead `msg` of type {type(self.msg)} was input" + ) + self.deiconify() + + # Recursively call _show to update ToolTip with the newest value of msg + # This is a race condition which only exits when upon a binding change + # that in turn changes the `status` to outside + self.after(int(self.refresh * 1000), self._show) diff --git a/requirements.txt b/requirements.txt index 2140b20b..bdb42f28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ rdkit psutil padelpy plotly -customtkinter<5.0.0 -tkinter-tooltip mhfp +darkdetect +mordredcommunity +openpyxl diff --git a/setup.py b/setup.py index 19d3ab84..2a8739d6 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def get_version(rel_path): setup( name="aimsim", - python_requires=">=3.7", + python_requires=">=3.8", version=get_version("aimsim/__init__.py"), description=desc, long_description=README, @@ -37,14 +37,7 @@ def get_version(rel_path): license="MIT", classifiers=["Programming Language :: Python :: 3"], install_requires=read("requirements.txt").split("\n"), - extras_require={ - "mordred": [ - "mordred==1.2.0", - "networkx==2.*", - "openpyxl", - ], - }, - packages=find_packages(), + packages=find_packages(exclude=["docs", "test"]), include_package_data=True, entry_points={ "console_scripts": [