├── docs ├── controls.png ├── scan_results.png ├── settings_window.png ├── scan_results_highlight.png ├── scan_results_recipe_tree.png └── scan_results_display_inventory_items.png ├── pictures ├── Hexer.png ├── Toxic.png ├── Trash.png ├── Assassin.png ├── Deadeye.png ├── Dynamo.png ├── Echoist.png ├── Effigy.png ├── Frenzied.png ├── Hasted.png ├── Opulent.png ├── Sentinel.png ├── Vampiric.png ├── Berserker.png ├── Bombardier.png ├── Corrupter.png ├── Entangler.png ├── Gargantuan.png ├── Ice Prison.png ├── Incendiary.png ├── Juggernaut.png ├── Permafrost.png ├── Soul Eater.png ├── Trickster.png ├── Arcane Buffer.png ├── Bloodletter.png ├── Bonebreaker.png ├── Chaosweaver.png ├── Consecrator.png ├── Evocationist.png ├── Executioner.png ├── Flame Strider.png ├── Flameweaver.png ├── Frost Strider.png ├── Frostweaver.png ├── Invulnerable.png ├── Magma Barrier.png ├── Malediction.png ├── Mana Siphoner.png ├── Mirror Image.png ├── Necromancer.png ├── Overcharged.png ├── Rejuvenating.png ├── Soul Conduit.png ├── Steel-Infused.png ├── Storm Strider.png ├── Stormweaver.png ├── Treant Horde.png ├── Crystal-Skinned.png ├── Drought Bringer.png ├── Kitava-Touched.png ├── Lunaris-Touched.png ├── Shakari-Touched.png ├── Solaris-Touched.png ├── Temporal Bubble.png ├── Abberath-Touched.png ├── Arakaali-Touched.png ├── Brine King-Touched.png ├── Corpse Detonator.png ├── Empowered Elements.png ├── Empowered Minions.png ├── Heralding Minions.png ├── Innocence-Touched.png └── Tukohama-Touched.png ├── requirements.txt ├── src ├── constants.py ├── DataClasses.py ├── RecipeShopper.test.py ├── RecipeShopper.py ├── poe_arch_scanner.py ├── ImageScanner.py ├── ArchnemesisItemsMap.py └── UIOverlay.py ├── .gitignore ├── README.md └── LICENSE /docs/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/controls.png -------------------------------------------------------------------------------- /pictures/Hexer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Hexer.png -------------------------------------------------------------------------------- /pictures/Toxic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Toxic.png -------------------------------------------------------------------------------- /pictures/Trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Trash.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | opencv-python==4.5.5.62 2 | Pillow==9.0.1 3 | pywin32==303 4 | keyboard==0.13.5 5 | -------------------------------------------------------------------------------- /docs/scan_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/scan_results.png -------------------------------------------------------------------------------- /pictures/Assassin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Assassin.png -------------------------------------------------------------------------------- /pictures/Deadeye.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Deadeye.png -------------------------------------------------------------------------------- /pictures/Dynamo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Dynamo.png -------------------------------------------------------------------------------- /pictures/Echoist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Echoist.png -------------------------------------------------------------------------------- /pictures/Effigy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Effigy.png -------------------------------------------------------------------------------- /pictures/Frenzied.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Frenzied.png -------------------------------------------------------------------------------- /pictures/Hasted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Hasted.png -------------------------------------------------------------------------------- /pictures/Opulent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Opulent.png -------------------------------------------------------------------------------- /pictures/Sentinel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Sentinel.png -------------------------------------------------------------------------------- /pictures/Vampiric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Vampiric.png -------------------------------------------------------------------------------- /pictures/Berserker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Berserker.png -------------------------------------------------------------------------------- /pictures/Bombardier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Bombardier.png -------------------------------------------------------------------------------- /pictures/Corrupter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Corrupter.png -------------------------------------------------------------------------------- /pictures/Entangler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Entangler.png -------------------------------------------------------------------------------- /pictures/Gargantuan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Gargantuan.png -------------------------------------------------------------------------------- /pictures/Ice Prison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Ice Prison.png -------------------------------------------------------------------------------- /pictures/Incendiary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Incendiary.png -------------------------------------------------------------------------------- /pictures/Juggernaut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Juggernaut.png -------------------------------------------------------------------------------- /pictures/Permafrost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Permafrost.png -------------------------------------------------------------------------------- /pictures/Soul Eater.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Soul Eater.png -------------------------------------------------------------------------------- /pictures/Trickster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Trickster.png -------------------------------------------------------------------------------- /docs/settings_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/settings_window.png -------------------------------------------------------------------------------- /pictures/Arcane Buffer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Arcane Buffer.png -------------------------------------------------------------------------------- /pictures/Bloodletter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Bloodletter.png -------------------------------------------------------------------------------- /pictures/Bonebreaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Bonebreaker.png -------------------------------------------------------------------------------- /pictures/Chaosweaver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Chaosweaver.png -------------------------------------------------------------------------------- /pictures/Consecrator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Consecrator.png -------------------------------------------------------------------------------- /pictures/Evocationist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Evocationist.png -------------------------------------------------------------------------------- /pictures/Executioner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Executioner.png -------------------------------------------------------------------------------- /pictures/Flame Strider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Flame Strider.png -------------------------------------------------------------------------------- /pictures/Flameweaver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Flameweaver.png -------------------------------------------------------------------------------- /pictures/Frost Strider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Frost Strider.png -------------------------------------------------------------------------------- /pictures/Frostweaver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Frostweaver.png -------------------------------------------------------------------------------- /pictures/Invulnerable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Invulnerable.png -------------------------------------------------------------------------------- /pictures/Magma Barrier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Magma Barrier.png -------------------------------------------------------------------------------- /pictures/Malediction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Malediction.png -------------------------------------------------------------------------------- /pictures/Mana Siphoner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Mana Siphoner.png -------------------------------------------------------------------------------- /pictures/Mirror Image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Mirror Image.png -------------------------------------------------------------------------------- /pictures/Necromancer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Necromancer.png -------------------------------------------------------------------------------- /pictures/Overcharged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Overcharged.png -------------------------------------------------------------------------------- /pictures/Rejuvenating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Rejuvenating.png -------------------------------------------------------------------------------- /pictures/Soul Conduit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Soul Conduit.png -------------------------------------------------------------------------------- /pictures/Steel-Infused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Steel-Infused.png -------------------------------------------------------------------------------- /pictures/Storm Strider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Storm Strider.png -------------------------------------------------------------------------------- /pictures/Stormweaver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Stormweaver.png -------------------------------------------------------------------------------- /pictures/Treant Horde.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Treant Horde.png -------------------------------------------------------------------------------- /pictures/Crystal-Skinned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Crystal-Skinned.png -------------------------------------------------------------------------------- /pictures/Drought Bringer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Drought Bringer.png -------------------------------------------------------------------------------- /pictures/Kitava-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Kitava-Touched.png -------------------------------------------------------------------------------- /pictures/Lunaris-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Lunaris-Touched.png -------------------------------------------------------------------------------- /pictures/Shakari-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Shakari-Touched.png -------------------------------------------------------------------------------- /pictures/Solaris-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Solaris-Touched.png -------------------------------------------------------------------------------- /pictures/Temporal Bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Temporal Bubble.png -------------------------------------------------------------------------------- /docs/scan_results_highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/scan_results_highlight.png -------------------------------------------------------------------------------- /pictures/Abberath-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Abberath-Touched.png -------------------------------------------------------------------------------- /pictures/Arakaali-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Arakaali-Touched.png -------------------------------------------------------------------------------- /pictures/Brine King-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Brine King-Touched.png -------------------------------------------------------------------------------- /pictures/Corpse Detonator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Corpse Detonator.png -------------------------------------------------------------------------------- /pictures/Empowered Elements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Empowered Elements.png -------------------------------------------------------------------------------- /pictures/Empowered Minions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Empowered Minions.png -------------------------------------------------------------------------------- /pictures/Heralding Minions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Heralding Minions.png -------------------------------------------------------------------------------- /pictures/Innocence-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Innocence-Touched.png -------------------------------------------------------------------------------- /pictures/Tukohama-Touched.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/pictures/Tukohama-Touched.png -------------------------------------------------------------------------------- /docs/scan_results_recipe_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/scan_results_recipe_tree.png -------------------------------------------------------------------------------- /docs/scan_results_display_inventory_items.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4rtzel/poe-archnemesis-scanner/HEAD/docs/scan_results_display_inventory_items.png -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | COLOR_BG = 'grey19' 2 | COLOR_FG_WHITE = 'snow' 3 | COLOR_FG_GREEN = 'green3' 4 | COLOR_FG_LIGHT_GREEN = 'DarkOliveGreen3' 5 | COLOR_FG_ORANGE = 'orange2' 6 | FONT_BIG = ('Consolas', '14') 7 | FONT_SMALL = ('Consolas', '9') 8 | INVENTORY_SIZE = 8 9 | -------------------------------------------------------------------------------- /src/DataClasses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, List 3 | 4 | 5 | @dataclass 6 | class RecipeItemNode: 7 | item: str 8 | components: List[Any] 9 | 10 | 11 | @dataclass 12 | class PoeWindowInfo: 13 | x: int = 0 14 | y: int = 0 15 | width: int = 0 16 | height: int = 0 17 | client_width: int = 0 18 | client_height: int = 0 19 | title_bar_height: int = 0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | settings.ini 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /src/RecipeShopper.test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import tkinter as tk 3 | 4 | from ArchnemesisItemsMap import ArchnemesisItemsMap 5 | from RecipeShopper import RecipeShopper 6 | 7 | class TestStringMethods(unittest.TestCase): 8 | def test_owned_items(self): 9 | item_map = ArchnemesisItemsMap(1) 10 | self._shopper = RecipeShopper(item_map) 11 | desired_items = ["Effigy", "Corrupter"] 12 | inventory = { 13 | "Effigy": [ [1, 2] ], 14 | "Corrupter": [ [1, 2] ] 15 | } 16 | 17 | missing_items = self._shopper.get_missing_items(desired_items, inventory) 18 | self.assertListEqual(missing_items, []) 19 | 20 | def test_missing_simple_items(self): 21 | item_map = ArchnemesisItemsMap(1) 22 | self._shopper = RecipeShopper(item_map) 23 | desired_items = ["Effigy", "Berserker"] 24 | inventory = { 25 | "Effigy": [ [1, 2] ], 26 | } 27 | 28 | missing_items = self._shopper.get_missing_items(desired_items, inventory) 29 | self.assertListEqual(missing_items, ["Berserker"]) 30 | 31 | def test_missing_complex_items(self): 32 | item_map = ArchnemesisItemsMap(1) 33 | self._shopper = RecipeShopper(item_map) 34 | desired_items = ["Effigy"] 35 | inventory = {} 36 | 37 | missing_items = self._shopper.get_missing_items(desired_items, inventory) 38 | self.assertListEqual(missing_items, ['Effigy', 'Hexer', 'Malediction', 'Corrupter', 'Chaosweaver', 'Echoist', 'Bloodletter', 'Chaosweaver']) 39 | 40 | def test_missing_complex_items_with_partial_inventory(self): 41 | item_map = ArchnemesisItemsMap(1) 42 | self._shopper = RecipeShopper(item_map) 43 | desired_items = ["Effigy"] 44 | inventory = { 45 | "Corrupter": [ [1, 2] ], 46 | "Echoist": [ [1, 2] ] 47 | } 48 | 49 | missing_items = self._shopper.get_missing_items(desired_items, inventory) 50 | self.assertListEqual(missing_items, ['Effigy', 'Hexer', 'Malediction', 'Chaosweaver']) 51 | 52 | def test_missing_requiring_duplicates_with_partial_inventory(self): 53 | item_map = ArchnemesisItemsMap(1) 54 | self._shopper = RecipeShopper(item_map) 55 | desired_items = ["Assassin", "Assassin"] 56 | inventory = { 57 | "Deadeye": [ [1, 2], [1, 2] ], 58 | "Vampiric": [ [1, 2] ] 59 | } 60 | 61 | missing_items = self._shopper.get_missing_items(desired_items, inventory) 62 | self.assertListEqual(missing_items, ['Assassin', 'Assassin', 'Vampiric']) 63 | 64 | if __name__ == '__main__': 65 | root = tk.Tk() 66 | unittest.main() 67 | -------------------------------------------------------------------------------- /src/RecipeShopper.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Dict, List, Tuple 3 | from ArchnemesisItemsMap import ArchnemesisItemsMap 4 | from DataClasses import RecipeItemNode 5 | import numpy as np 6 | 7 | class RecipeShopper: 8 | def __init__(self, item_map: ArchnemesisItemsMap): 9 | self._item_map = item_map 10 | 11 | def get_missing_items(self, desired_items: List[str], current_inventory: Dict[str, List[Tuple[int, int]]]): 12 | missing_items = list() 13 | # clone the inventory, because we're going to take things out of it 14 | # as owned items are identified in case dupes are needed 15 | working_inventory = deepcopy(current_inventory) 16 | 17 | # identify which items are not already owned, 18 | # and remove owned items from the working inventory 19 | for item in desired_items: 20 | if not is_item_owned(working_inventory, item): 21 | missing_items.append(item) 22 | else: 23 | remove_item_from_inventory(working_inventory, item) 24 | 25 | # recursively call this function passing the ingredients for missing items 26 | complex_missing_items = list(item for item in missing_items if len(self._item_map.get_components_for(item)) > 0) 27 | if len(complex_missing_items) > 0: 28 | complex_item_ingredients = list() 29 | for item in complex_missing_items: 30 | complex_item_ingredients.extend(self._item_map.get_components_for(item)) 31 | nested_missing_items = self.get_missing_items(complex_item_ingredients, working_inventory) 32 | missing_items.extend(nested_missing_items) 33 | 34 | return missing_items 35 | 36 | def get_trash_inventory(self, desired_items: List[str], inventory: Dict[str, List[Tuple[int, int]]]): 37 | full_shopping_list = self._get_full_shopping_list(desired_items) 38 | trash_inventory = deepcopy(inventory) 39 | 40 | for item in full_shopping_list: 41 | if item in trash_inventory.keys(): 42 | del trash_inventory[item] 43 | 44 | # TODO: trash_inventory might still contain way too many of a needed item, they can be trashed too 45 | 46 | return trash_inventory 47 | 48 | def _get_full_shopping_list(self, desired_items: List[str]): 49 | full_shopping_list = list(map(lambda item: self._item_map.get_subtree_for(item), desired_items)) 50 | return self._flatten_item_trees(full_shopping_list) 51 | 52 | def _flatten_item_trees(self, trees: List[RecipeItemNode]): 53 | def flatten_node(node: RecipeItemNode): 54 | flattened = [node.item] 55 | if (len(node.components)): 56 | flattened.extend(self._flatten_item_trees(node.components)) 57 | return list(flattened) 58 | 59 | flattened = list(map(flatten_node, trees)) 60 | if flattened: 61 | return list(np.concatenate(flattened).ravel()) 62 | else: 63 | return [] 64 | 65 | def is_item_owned(inventory: Dict[str, List[Tuple[int, int]]], item: str) -> bool: 66 | return item in inventory.keys() and len(inventory[item]) > 0 67 | 68 | def remove_item_from_inventory(inventory: Dict[str, List[Tuple[int, int]]], item: str): 69 | return inventory[item].pop() 70 | -------------------------------------------------------------------------------- /src/poe_arch_scanner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | from PIL import ImageGrab 4 | 5 | from ArchnemesisItemsMap import ArchnemesisItemsMap 6 | from DataClasses import PoeWindowInfo 7 | from ImageScanner import ImageScanner 8 | from UIOverlay import UIOverlay 9 | 10 | import win32gui 11 | from win32clipboard import * 12 | 13 | import tkinter as tk 14 | from tkinter import messagebox 15 | 16 | from PIL import ImageGrab 17 | 18 | from RecipeShopper import RecipeShopper 19 | 20 | import constants 21 | 22 | def show_warning(text: str) -> None: 23 | messagebox.showwarning('poe-archnemesis-scanner', text) 24 | 25 | def show_error_and_die(text: str) -> None: 26 | # Dealing with inconveniences as Perl would 27 | messagebox.showerror('poe-archnemesis-scanner', text) 28 | sys.exit() 29 | 30 | def get_poe_window_info() -> PoeWindowInfo: 31 | info = PoeWindowInfo() 32 | hwnd = win32gui.FindWindow(None, 'Path of Exile') 33 | if hwnd == 0: 34 | show_error_and_die('Path of Exile is not running.') 35 | 36 | x0, y0, x1, y1 = win32gui.GetWindowRect(hwnd) 37 | info.x = x0 38 | info.y = y0 39 | info.width = x1 - x0 40 | info.height = y1 - y0 41 | x0, y0, x1, y1 = win32gui.GetClientRect(hwnd) 42 | info.client_width = x1 - x0 43 | info.client_height = y1 - y0 44 | 45 | if info.client_width == 0 or info.client_height == 0: 46 | show_warning("Unable to detect Path of Exile resolution. Make sure it isn't running in the Fullscreen mode.\n\nThe tool will use your screen resolution for calculations instead.") 47 | screen = ImageGrab.grab() 48 | info.x = 0 49 | info.y = 0 50 | info.width, info.height = screen.size 51 | info.client_width, info.client_height = screen.size 52 | info.title_bar_height = info.height - info.client_height 53 | return info 54 | 55 | def calculate_default_scale(info: PoeWindowInfo) -> float: 56 | """ 57 | TODO: validate the math for non 16:9 resolutions (e.g. ultrawide monitors) 58 | """ 59 | 60 | # Assume that all source images have 78x78 size 61 | source_image_height = 78.0 62 | 63 | # Take 0.91 as a golden standard for 2560x1440 resolution and calculate 64 | # scales for other resolutions based on that 65 | constant = 1440.0 / (source_image_height * 0.91) 66 | scale = info.client_height / (source_image_height * constant) 67 | return scale 68 | 69 | parser = argparse.ArgumentParser(description='Path of Exile archnemesis scanner') 70 | parser.add_argument( 71 | '--show-capture-image', 72 | help='shows the captured screen image that the program uses for scanning', 73 | dest='show_capture_image', 74 | action='store_true') 75 | 76 | parser.add_argument('--scanner-window-x', help='x position of scanner window', dest='scanner_window_x', type=int, default=-1) 77 | parser.add_argument('--scanner-window-y', help='y position of scanner window', dest='scanner_window_y', type=int, default=-1) 78 | parser.add_argument('--scanner-window-width', help='width of scanner window', dest='scanner_window_width', type=int, default=-1) 79 | parser.add_argument('--scanner-window-height', help='height of scanner window', dest='scanner_window_height', type=int, default=-1) 80 | args = parser.parse_args() 81 | 82 | # Create root as early as possible to initialize some modules (e.g. ImageTk) 83 | root = tk.Tk() 84 | root.withdraw() 85 | 86 | info = get_poe_window_info() 87 | 88 | items_map = ArchnemesisItemsMap(calculate_default_scale(info)) 89 | 90 | image_scanner = ImageScanner(info, items_map, args) 91 | 92 | recipe_shopper = RecipeShopper(items_map) 93 | 94 | overlay = UIOverlay(root, info, items_map, image_scanner, recipe_shopper) 95 | overlay.run() 96 | -------------------------------------------------------------------------------- /src/ImageScanner.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import numpy as np 3 | import cv2 4 | 5 | from typing import Dict, List, Tuple 6 | from ArchnemesisItemsMap import ArchnemesisItemsMap 7 | from DataClasses import PoeWindowInfo 8 | from PIL import ImageGrab 9 | from constants import INVENTORY_SIZE 10 | 11 | 12 | class ImageScanner: 13 | """ 14 | Implements scanning algorithm with OpenCV. Maintans the scanning window to speed up the scanning. 15 | """ 16 | def __init__(self, info: PoeWindowInfo, items_map: ArchnemesisItemsMap, args): 17 | self._args = args 18 | # I wasn't able to find the actual function that would give me the right sizes so 19 | # I got these numbers empirically. They are far from perfect, but should work for most 20 | # resolutions. Probably the better way is to have a mapping for all resolutions 21 | # and their inventory window sizes, and fallback to these calculations only when needed. 22 | width_and_height = round(info.client_height * 0.407) 23 | high_res_compensation = 7 24 | if info.client_height >= 1440: 25 | # This is my awful attempt to fix the calculations for high resolutions. 26 | high_res_compensation = round(info.client_height * 0.006) 27 | x = args.scanner_window_x if args.scanner_window_x != -1 else info.x + int(info.client_height * 0.115) - high_res_compensation 28 | y = args.scanner_window_y if args.scanner_window_y != -1 else info.y + (info.height - info.client_height) + int(info.client_height * 0.313) + high_res_compensation 29 | w = args.scanner_window_width if args.scanner_window_width != -1 else width_and_height 30 | h = args.scanner_window_height if args.scanner_window_height != -1 else width_and_height 31 | self._scanner_window_size = ( 32 | x, 33 | y, 34 | w, 35 | h 36 | ) 37 | print(f'Scanner window: x={x}, y={y}, width={w}, height={h}') 38 | 39 | self._slot_size = width_and_height / INVENTORY_SIZE 40 | self._items_map = items_map 41 | self._confidence_threshold = 0.88 42 | 43 | def scan(self) -> Dict[str, List[Tuple[int, int]]]: 44 | bbox = ( 45 | self._scanner_window_size[0], 46 | self._scanner_window_size[1], 47 | self._scanner_window_size[0] + self._scanner_window_size[2], 48 | self._scanner_window_size[1] + self._scanner_window_size[3] 49 | ) 50 | screen = ImageGrab.grab(bbox=bbox) 51 | screen = np.array(screen) 52 | screen = cv2.cvtColor(screen, cv2.COLOR_RGB2BGR) 53 | if self._args.show_capture_image: 54 | cv2.imshow('', screen) 55 | cv2.waitKey(0) 56 | 57 | results = defaultdict(list) 58 | 59 | slots = [[None for _ in range(INVENTORY_SIZE)] for _ in range(INVENTORY_SIZE)] 60 | 61 | for item in self._items_map.items(): 62 | template = self._items_map.get_scan_image(item) 63 | heat_map = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) 64 | 65 | findings = np.where(heat_map >= self._confidence_threshold) 66 | if len(findings[0]) > 0: 67 | rectangles = [] 68 | ht, wt = template.shape[0], template.shape[1] 69 | for (x, y) in zip(findings[1], findings[0]): 70 | # Add every box to the list twice in order to retain single (non-overlapping) boxes 71 | rectangles.append([int(x), int(y), int(wt), int(ht)]) 72 | rectangles.append([int(x), int(y), int(wt), int(ht)]) 73 | 74 | rectangles, _ = cv2.groupRectangles(rectangles, 1, 0.1) 75 | for (x, y) in [(rect[0], rect[1]) for rect in rectangles]: 76 | column = int(x / self._slot_size) 77 | row = int(y / self._slot_size) 78 | confidence = heat_map[y][x] 79 | if slots[row][column] is None or slots[row][column][1] < confidence: 80 | slots[row][column] = (item, confidence, x, y) 81 | total_items_found = 0 82 | for row in range(INVENTORY_SIZE): 83 | for column in range(INVENTORY_SIZE): 84 | if slots[row][column] is not None: 85 | print(f'row={row+1}, column={column+1}, item={slots[row][column][0]}, confidence={slots[row][column][1]}') 86 | results[slots[row][column][0]].append((slots[row][column][2], slots[row][column][3])) 87 | total_items_found += 1 88 | else: 89 | print(f'row={row+1}, column={column+1} is empty') 90 | print(f'Items found: {total_items_found}/{INVENTORY_SIZE * INVENTORY_SIZE}') 91 | return results 92 | 93 | @property 94 | def scanner_window_size(self) -> Tuple[int, int, int, int]: 95 | return self._scanner_window_size 96 | 97 | @property 98 | def confidence_threshold(self) -> float: 99 | return self._confidence_threshold 100 | 101 | @confidence_threshold.setter 102 | def confidence_threshold(self, value) -> None: 103 | self._confidence_threshold = value 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # poe-archnemesis-scanner 2 | Tool for Path of Exile game to automatically scan Archemesis inventory and display related information 3 | 4 | ## Features 5 | 6 | ### Controls 7 | When you start the program three small buttons will pop up in the top left corner of your screen. 8 | 9 | ![controls](docs/controls.png) 10 | 11 | * '[X]' button just closes the program. 12 | * 'Settings' button open settings window (see below). 13 | * 'Scan' button does all the magic. Once you press it, the program will enter the scanning mode and the button will change to 'Scanning...'. It will scan your archnemesis inventory and will create a list of all possible recipes. After the scan completes, the button will change again to 'Hide'. Once you examine the scan result, click the 'Hide' button to hide them. 14 | 15 | You could also hold the right mouse button to drag the controls around. 16 | 17 | ### Settings 18 | 19 | The setting window allows you to adjust some parameters to improve the searching efficiency or change the display settings: 20 | 21 | ![settings_window](docs/settings_window.png) 22 | 23 | * 'Set image scale' button sets the scaling factor for the source images. The current search algorithm expects the source image and the image on the screen to be the same size. Thus, we'll need to scale down/up the source images in order to get reliable results. 24 | 25 | The default calculated automatically based on the screen resolution and should work for most of the people. However, if you have some non-standard resolution, the search algorithm may not work properly, so you'll need to adjust this parameter manually. 26 | 27 | * 'Set confidence threshold' button sets the threshold used by the search algorithm to filter the results. If the algorithm was able to find an area with confidence value higher than the confidence threshold then it will treat it as a match. The default value is 0.94 (or 94%) and should work in most of the cases. 28 | 29 | * 'Set scan/hide hotkey' button sets the keyboard hotkey for the 'Scan'/'Hide' button. It accepts a text that represents a hotkey and its modifiers. Examples: 'F11', 'ctrl+shift+s', 'space', 'comma', 'plus', etc. 30 | 31 | * 'Display inventory items' checkbox turns additional display setting for scan window. The scan results will also include a list of all of your archnemesis items in the inventory. 32 | 33 | * 'Display unavailable recipes' checkbox turns the display of all possible recipes even if you can't currently build them. The unavailable recipes will be indicated by the white color (light green if you already have it in your inventory). 34 | 35 | * 'Copy recipe to clipboard' checkbox copies a recipee string like `^(Malediction|Deadeye)` to your clipboard when clicking on items in the list or tree views. This allows you to paste into the search box and use the in-game highlighting. 36 | 37 | * 'Run as overlay' checkbox allows you to run the program as the overlay (default) or as a simple window (scan results will still show up on top of the other windows). This is useful with scan hotkey feature to allow you to completely hide the program and rely only on hotkey to show up the scan results. 38 | 39 | * 'Shopping List Mode' checkbox tells the program to filter the recipes based on your shopping list parameter. The trash icon will also occur to display the recipes that are not in the list but could be completed. 40 | 41 | * 'Set shopping list' button will save your recipe list. When the scan completes, only the recipes in your shopping list, and the recipes needed to create these recipes, will show up. The form has a strict format. You will need to enter the recipes names separated by commas, keeping the right letter case. 42 | 43 | The settings are persistent and will be saved/loaded from settings.ini file. 44 | 45 | ### Scan results 46 | The scan result will be displayed at the top of the screen like that: 47 | 48 | ![scan_results](docs/scan_results.png) 49 | 50 | It shows you all available recipes that you can create right now. If the text is green, then that means you already have such item in the inventory. If the text is orange, then this item doesn't exist in your inventory. 51 | 52 | You could then hover over any of the recipes to highlight the items in your inventory that could be combined to create it: 53 | 54 | ![scan_results_highlight](docs/scan_results_highlight.png) 55 | 56 | If you checked 'Display inventory items' box, then your scan results will also include a list of all of your items in inventory (colored in white): 57 | 58 | ![scan_results_display_inventory_items](docs/scan_results_display_inventory_items.png) 59 | 60 | Again, hover over any items to display them in your inventory. 61 | 62 | If you checked 'Display unavailable recipes' box, then your scan results will also include the recipes that you cannot complete right now. This is useful for planning the next step especially with the next feature. 63 | 64 | If you click at any recipe icon, then the recipe tree will open: 65 | 66 | ![docs/scan_results_recipe_tree](docs/scan_results_recipe_tree.png) 67 | 68 | If the icon is highlighted in green, then that means you have this item in your inventory. You could hover of highlighted items to display them in your inventory. The tree is also interactable. You could click at other nodes to zoom in. 69 | 70 | Above the tree, a list of recipes (next to 'Used in:') that the selected recipe could be used in will be displayed 71 | 72 | Both recipe list and recipe tree could be also moved with the right mouse button held. 73 | 74 | ## Installation 75 | 76 | ### Standalone 77 | You could download a standalone version from release page: https://github.com/4rtzel/poe-archnemesis-scanner/releases. The package was created using `pyinstaller`. 78 | 79 | ### Manual 80 | You'll need to install Python and all project dependencies. Python could be installed from Microsoft Store and from the main site: https://www.python.org/downloads/ (doesn't include `pip`, so you'll have to install it separately). 81 | 82 | Once the Python and pip are installed, run this command from the project directory to install all project dependencies: 83 | 84 | ```cmd 85 | pip.exe install -r requirements.txt 86 | ``` 87 | 88 | and then start the program 89 | 90 | ```cmd 91 | python.exe src/poe_arch_scanner.py 92 | ``` 93 | 94 | ## Known Issues 95 | 96 | * Doesn't work if the game is in the fullscreen. 97 | * Only works for the primary monitor (Tk limitation). 98 | * Occasionally hangs. 99 | 100 | ## Other languages 101 | Chinese -- https://github.com/njes9701/poe_arch_scanner_zh_tw 102 | -------------------------------------------------------------------------------- /src/ArchnemesisItemsMap.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from PIL import ImageTk, Image 3 | import cv2 4 | import numpy as np 5 | 6 | from DataClasses import RecipeItemNode 7 | 8 | 9 | class ArchnemesisItemsMap: 10 | """ 11 | Holds the information about all archnemesis items, recipes, images and map them together 12 | """ 13 | def __init__(self, scale: float): 14 | # Put everything into the list so we could maintain the display order 15 | self._arch_items = [ 16 | ('Kitava-Touched', ['Tukohama-Touched', 'Abberath-Touched', 'Corrupter', 'Corpse Detonator']), 17 | ('Innocence-Touched', ['Lunaris-Touched', 'Solaris-Touched', 'Mirror Image', 'Mana Siphoner']), 18 | ('Shakari-Touched', ['Entangler', 'Soul Eater', 'Drought Bringer']), 19 | ('Abberath-Touched', ['Flame Strider', 'Frenzied', 'Rejuvenating']), 20 | ('Tukohama-Touched', ['Bonebreaker', 'Executioner', 'Magma Barrier']), 21 | ('Brine King-Touched', ['Ice Prison', 'Storm Strider', 'Heralding Minions']), 22 | ('Arakaali-Touched', ['Corpse Detonator', 'Entangler', 'Assassin']), 23 | ('Solaris-Touched', ['Invulnerable', 'Magma Barrier', 'Empowered Minions']), 24 | ('Lunaris-Touched', ['Invulnerable', 'Frost Strider', 'Empowered Minions']), 25 | ('Effigy', ['Hexer', 'Malediction', 'Corrupter']), 26 | ('Empowered Elements', ['Evocationist', 'Steel-Infused', 'Chaosweaver']), 27 | ('Crystal-Skinned', ['Permafrost', 'Rejuvenating', 'Berserker']), 28 | ('Invulnerable', ['Sentinel', 'Juggernaut', 'Consecrator']), 29 | ('Corrupter', ['Bloodletter', 'Chaosweaver']), 30 | ('Mana Siphoner', ['Consecrator', 'Dynamo']), 31 | ('Storm Strider', ['Stormweaver', 'Hasted']), 32 | ('Mirror Image', ['Echoist', 'Soul Conduit']), 33 | ('Magma Barrier', ['Incendiary', 'Bonebreaker']), 34 | ('Evocationist', ['Flameweaver', 'Frostweaver', 'Stormweaver']), 35 | ('Corpse Detonator', ['Necromancer', 'Incendiary']), 36 | ('Flame Strider', ['Flameweaver', 'Hasted']), 37 | ('Soul Eater', ['Soul Conduit', 'Necromancer', 'Gargantuan']), 38 | ('Ice Prison', ['Permafrost', 'Sentinel']), 39 | ('Frost Strider', ['Frostweaver', 'Hasted']), 40 | ('Treant Horde', ['Toxic', 'Sentinel', 'Steel-Infused']), 41 | ('Temporal Bubble', ['Juggernaut', 'Hexer', 'Arcane Buffer']), 42 | ('Entangler', ['Toxic', 'Bloodletter']), 43 | ('Drought Bringer', ['Malediction', 'Deadeye']), 44 | ('Hexer', ['Chaosweaver', 'Echoist']), 45 | ('Executioner', ['Frenzied', 'Berserker']), 46 | ('Rejuvenating', ['Gargantuan', 'Vampiric']), 47 | ('Necromancer', ['Bombardier', 'Overcharged']), 48 | ('Trickster', ['Overcharged', 'Assassin', 'Echoist']), 49 | ('Assassin', ['Deadeye', 'Vampiric']), 50 | ('Empowered Minions', ['Necromancer', 'Executioner', 'Gargantuan']), 51 | ('Heralding Minions', ['Dynamo', 'Arcane Buffer']), 52 | ('Arcane Buffer', []), 53 | ('Berserker', []), 54 | ('Bloodletter', []), 55 | ('Bombardier', []), 56 | ('Bonebreaker', []), 57 | ('Chaosweaver', []), 58 | ('Consecrator', []), 59 | ('Deadeye', []), 60 | ('Dynamo', []), 61 | ('Echoist', []), 62 | ('Flameweaver', []), 63 | ('Frenzied', []), 64 | ('Frostweaver', []), 65 | ('Gargantuan', []), 66 | ('Hasted', []), 67 | ('Incendiary', []), 68 | ('Juggernaut', []), 69 | ('Malediction', []), 70 | ('Opulent', []), 71 | ('Overcharged', []), 72 | ('Permafrost', []), 73 | ('Sentinel', []), 74 | ('Soul Conduit', []), 75 | ('Steel-Infused', []), 76 | ('Stormweaver', []), 77 | ('Toxic', []), 78 | ('Vampiric', []), 79 | ('Trash', []) 80 | ] 81 | self._images = dict() 82 | self._small_image_size = 30 83 | self._update_images(scale) 84 | 85 | def _update_images(self, scale): 86 | self._scale = scale 87 | for item, _ in self._arch_items: 88 | self._images[item] = dict() 89 | image = self._load_image(item, scale) 90 | self._image_size = image.size 91 | self._images[item]['scan-image'] = self._create_scan_image(image) 92 | # Convert the image to Tk image because we're going to display it 93 | self._images[item]['display-image'] = ImageTk.PhotoImage(image=image) 94 | image = image.resize((self._small_image_size, self._small_image_size)) 95 | self._images[item]['display-small-image'] = ImageTk.PhotoImage(image=image) 96 | 97 | def _load_image(self, item: str, scale: float): 98 | image = Image.open(f'pictures/{item}.png') 99 | # Scale the image according to the input parameter 100 | return image.resize((int(image.width * scale), int(image.height * scale))) 101 | 102 | def _create_scan_image(self, image): 103 | # Remove alpha channel and replace it with predefined background color 104 | background = Image.new('RGBA', image.size, (10, 10, 32)) 105 | image_without_alpha = Image.alpha_composite(background, image) 106 | scan_template = cv2.cvtColor(np.array(image_without_alpha), cv2.COLOR_RGB2BGR) 107 | w, h, _ = scan_template.shape 108 | 109 | # Crop the image to help with scanning 110 | return scan_template[int(h * 0.16):int(h * 0.75), int(w * 0.16):int(w * 0.85)] 111 | 112 | 113 | def get_scan_image(self, item): 114 | return self._images[item]['scan-image'] 115 | 116 | def get_display_image(self, item): 117 | return self._images[item]['display-image'] 118 | 119 | def get_display_small_image(self, item): 120 | return self._images[item]['display-small-image'] 121 | 122 | def items(self): 123 | for item, _ in self._arch_items: 124 | yield item 125 | 126 | def recipes(self): 127 | for item, recipe in self._arch_items: 128 | if recipe: 129 | yield (item, recipe) 130 | 131 | def get_subtree_for(self, item: str): 132 | tree = RecipeItemNode(item, []) 133 | nodes = [tree] 134 | while len(nodes) > 0: 135 | node = nodes.pop(0) 136 | children = self.get_components_for(node.item) 137 | if len(children) > 0: 138 | node.components = [RecipeItemNode(c, []) for c in children] 139 | nodes.extend(node.components) 140 | return tree 141 | 142 | def get_parent_recipes_for(self, item: str) -> List[str]: 143 | parents = list() 144 | for parent, components in self._arch_items: 145 | if item in components: 146 | parents.append(parent) 147 | return parents 148 | 149 | def get_components_for(self, item) -> List[str]: 150 | return next(l for x, l in self._arch_items if x == item) 151 | 152 | @property 153 | def image_size(self): 154 | return self._image_size 155 | 156 | @property 157 | def scale(self) -> float: 158 | return self._scale 159 | 160 | @scale.setter 161 | def scale(self, scale: float) -> None: 162 | self._update_images(scale) 163 | 164 | @property 165 | def small_image_size(self): 166 | return self._small_image_size 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/UIOverlay.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | import keyboard 3 | import tkinter as tk 4 | import sys 5 | from win32clipboard import OpenClipboard, EmptyClipboard, SetClipboardText, CloseClipboard 6 | 7 | from typing import Dict, List, Tuple 8 | from DataClasses import PoeWindowInfo 9 | from ArchnemesisItemsMap import ArchnemesisItemsMap 10 | from ImageScanner import ImageScanner 11 | from DataClasses import RecipeItemNode 12 | from RecipeShopper import RecipeShopper 13 | from constants import COLOR_BG, COLOR_FG_GREEN, COLOR_FG_LIGHT_GREEN, COLOR_FG_ORANGE, COLOR_FG_WHITE, FONT_BIG, FONT_SMALL 14 | 15 | 16 | class UIOverlay: 17 | """ 18 | Overlay window using tkinter '-topmost' property 19 | """ 20 | def __init__(self, root: tk.Tk, info: PoeWindowInfo, items_map: ArchnemesisItemsMap, image_scanner: ImageScanner, recipe_shopper: RecipeShopper): 21 | self._window_info = info 22 | self._items_map = items_map 23 | self._image_scanner = image_scanner 24 | self._root = root 25 | self._recipe_shopper = recipe_shopper 26 | self._scan_results_window = None 27 | self._recipe_browser_window = None 28 | self._recipe_browser_current_root = '' 29 | self._tooltip_window = None 30 | self._highlight_windows_to_show = list() 31 | self._scan_results_window_saved_position = (-1, 0) 32 | 33 | 34 | self._settings = Settings(root, items_map, image_scanner, on_scan_hotkey=self._hotkey_pressed) 35 | 36 | self._create_controls() 37 | 38 | self._root.configure(bg='') 39 | self._root.geometry(f'+{info.x + 5}+{info.y + info.title_bar_height + 5}') 40 | if self._settings.should_run_as_overlay(): 41 | self._root.overrideredirect(True) 42 | self._root.wm_attributes('-topmost', True) 43 | self._root.deiconify() 44 | 45 | @staticmethod 46 | def create_toplevel_window(bg=''): 47 | w = tk.Toplevel() 48 | w.configure(bg=bg) 49 | # Hide window outline/controls 50 | w.overrideredirect(True) 51 | # Make sure the window is always on top 52 | w.wm_attributes('-topmost', True) 53 | return w 54 | 55 | def _hotkey_pressed(self) -> None: 56 | if self._scan_label_text.get() == 'Scan': 57 | self._scan(None) 58 | elif self._scan_label_text.get() == 'Hide': 59 | self._hide(None) 60 | 61 | def _create_controls(self) -> None: 62 | l = tk.Button(self._root, text='[X]', fg=COLOR_FG_GREEN, bg=COLOR_BG, font=FONT_SMALL) 63 | l.bind('', sys.exit) 64 | l.bind('', lambda event: self._drag(self._root, -5, -5, event)) 65 | l.grid(row=0, column=0) 66 | 67 | settings = tk.Button(self._root, text='Settings', fg=COLOR_FG_GREEN, bg=COLOR_BG, font=FONT_SMALL) 68 | settings.bind('', lambda _: self._settings.show()) 69 | settings.bind('', lambda event: self._drag(self._root, -5, -5, event)) 70 | settings.grid(row=0, column=1) 71 | 72 | self._scan_label_text = tk.StringVar(self._root, value='Scan') 73 | self._scan_label = tk.Button(self._root, textvariable=self._scan_label_text, fg=COLOR_FG_GREEN, bg=COLOR_BG, font=FONT_SMALL) 74 | self._scan_label.bind("", self._scan) 75 | self._scan_label.bind('', lambda event: self._drag(self._root, -5, -5, event)) 76 | self._scan_label.grid(row=0, column=2) 77 | 78 | def _drag(self, window, offset_x: int, offset_y: int, event) -> Tuple[int, int]: 79 | x = offset_x + event.x + window.winfo_x() 80 | y = offset_y + event.y + window.winfo_y() 81 | window.geometry(f'+{x}+{y}') 82 | return (x, y) 83 | 84 | def _scan(self, _) -> None: 85 | self._scan_label_text.set('Scanning...') 86 | self._root.update() 87 | results = self._image_scanner.scan() 88 | 89 | shopping_list_mode = self._settings.is_shopping_list_mode() is True 90 | desired_items = [x for x in self._settings.get_shopping_list().split(",") if x] 91 | shopping_list = self._recipe_shopper.get_missing_items(desired_items, results) 92 | print("Missing Items:", shopping_list) 93 | 94 | main_recipe_list = self._items_map.recipes() 95 | if shopping_list_mode: 96 | recipe_list = [x for x in self._items_map.recipes() if x[0] in self._recipe_shopper._get_full_shopping_list(desired_items)] 97 | else: 98 | recipe_list = main_recipe_list 99 | if len(results) > 0: 100 | recipes = list() 101 | for item, recipe in recipe_list: 102 | screen_items = [results.get(x) for x in recipe] 103 | if (all(screen_items) or self._settings.should_display_unavailable_recipes()): 104 | recipes.append((item, [x[0] for x in screen_items if x is not None], item in results, all(screen_items))) 105 | 106 | if shopping_list_mode: 107 | trash_inventory = self._recipe_shopper.get_trash_inventory(desired_items, results) 108 | trash_recipe_items = [None] * min(4, len(trash_inventory.keys())) 109 | trash_recipe_items = [trash_inventory[list(trash_inventory.keys())[i]][0] for i,x in enumerate(trash_recipe_items)] 110 | trash_recipe = ('Trash', trash_recipe_items, False, True) 111 | recipes.append(trash_recipe) 112 | 113 | self._show_scan_results(results, recipes) 114 | 115 | self._scan_label_text.set('Hide') 116 | self._scan_label.bind('', self._hide) 117 | else: 118 | self._hide(None) 119 | 120 | def _hide(self, _) -> None: 121 | if self._scan_results_window is not None: 122 | self._scan_results_window.destroy() 123 | if self._recipe_browser_window is not None: 124 | self._recipe_browser_window.destroy() 125 | if self._tooltip_window is not None: 126 | self._tooltip_window.destroy() 127 | self._clear_highlights(None) 128 | self._scan_label_text.set('Scan') 129 | self._scan_label.bind('', self._scan) 130 | 131 | def _show_scan_results(self, results: Dict[str, List[Tuple[int, int]]], recipes: List[Tuple[str, List[Tuple[int, int]], bool, bool]]) -> None: 132 | self._scan_results_window = UIOverlay.create_toplevel_window() 133 | x, y = self._scan_results_window_saved_position 134 | if x == -1: 135 | x = self._window_info.x + int(self._window_info.client_width / 3) 136 | y = self._window_info.y + self._window_info.title_bar_height 137 | self._scan_results_window.geometry(f'+{x}+{y}') 138 | 139 | last_column = 0 140 | if self._settings.should_display_inventory_items(): 141 | last_column = self._show_inventory_list(results) 142 | self._show_recipes_list(results, recipes, last_column + 2) 143 | 144 | def _show_inventory_list(self, results: Dict[str, List[Tuple[int, int]]]) -> int: 145 | row = 0 146 | column = 0 147 | 148 | for item in self._items_map.items(): 149 | inventory_items = results.get(item) 150 | if inventory_items is not None: 151 | row, column = self._show_image_and_label(item, results, inventory_items, COLOR_FG_WHITE, f'x{len(inventory_items)} {item}', True, row, column) 152 | return column 153 | 154 | def _show_recipes_list(self, results: Dict[str, List[Tuple[int, int]]], recipes: List[Tuple[str, List[Tuple[int, int]], bool, bool]], column: int) -> None: 155 | row = 0 156 | 157 | for item, inventory_items, exists_in_inventory, available in recipes: 158 | if exists_in_inventory: 159 | if available: 160 | fg = COLOR_FG_GREEN 161 | else: 162 | fg = COLOR_FG_LIGHT_GREEN 163 | else: 164 | if available: 165 | fg = COLOR_FG_ORANGE 166 | else: 167 | fg = COLOR_FG_WHITE 168 | row, column = self._show_image_and_label(item, results, inventory_items, fg, item, available, row, column) 169 | 170 | def _show_image_and_label(self, item: str, results: Dict[str, List[Tuple[int, int]]], inventory_items: Tuple[int, int], highlight_color: str, label_text: str, highlight, row: int, column: int) -> Tuple[int, int]: 171 | image = tk.Label(self._scan_results_window, image=self._items_map.get_display_small_image(item), bg=COLOR_BG, pady=5) 172 | if highlight: 173 | image.bind('', lambda _, arg=inventory_items, color=highlight_color: self._highlight_items_in_inventory(arg, color)) 174 | image.bind('', self._clear_highlights) 175 | image.bind('', lambda _, arg1=item, arg2=results: self._show_recipe_browser_tree(arg1, arg2)) 176 | image.bind('', self._scan_results_window_drag_and_save) 177 | image.grid(row=row, column=column) 178 | tk.Label(self._scan_results_window, text=label_text, font=FONT_BIG, fg=highlight_color, bg=COLOR_BG).grid(row=row, column=column + 1, sticky='w', padx=5) 179 | row += 1 180 | if row % 10 == 0: 181 | column += 2 182 | row = 0 183 | return (row, column) 184 | 185 | def _scan_results_window_drag_and_save(self, event) -> None: 186 | self._scan_results_window_saved_position = self._drag(self._scan_results_window, -5, -5, event) 187 | 188 | def _show_recipe_browser_tree(self, item: str, results: Dict[str, List[Tuple[int, int]]]) -> None: 189 | if self._recipe_browser_window is not None: 190 | self._recipe_browser_window.destroy() 191 | self._destroy_tooltip_and_clear_highlights(None) 192 | # If the user clicks on the current root then close the tree 193 | if self._recipe_browser_current_root == item: 194 | return 195 | self._recipe_browser_current_root = item 196 | self._recipe_browser_window = UIOverlay.create_toplevel_window() 197 | self._recipe_browser_window.geometry(f'+{self._scan_results_window.winfo_x()}+{self._scan_results_window.winfo_y() + self._scan_results_window.winfo_height() + 40}') 198 | 199 | tree = self._items_map.get_subtree_for(item) 200 | if self._settings.should_copy_recipe_to_clipboard(): 201 | self._copy_tree_items_to_clipboard(tree) 202 | 203 | def draw_tree(node, row, column): 204 | children_column = column 205 | for c in node.components: 206 | children_column = draw_tree(c, row + 2, children_column) 207 | columnspan = max(1, children_column - column) 208 | if node.item in results: 209 | bg = COLOR_FG_GREEN 210 | else: 211 | bg = COLOR_BG 212 | l = tk.Label(self._recipe_browser_window, image=self._items_map.get_display_small_image(node.item), bg=bg, relief=tk.SUNKEN) 213 | l.bind('', lambda _, arg1=node.item, arg2=results: self._show_recipe_browser_tree(arg1, arg2)) 214 | l.bind('', lambda event: self._drag(self._recipe_browser_window, -5, -5, event)) 215 | l.bind('', lambda _, arg1=self._recipe_browser_window, arg2=results.get(node.item), arg3=node.item: self._create_tooltip_and_highlight(arg1, arg2, arg3)) 216 | l.bind('', self._destroy_tooltip_and_clear_highlights) 217 | l.grid(row=row, column=column, columnspan=columnspan) 218 | if len(node.components) > 0: 219 | f = tk.Frame(self._recipe_browser_window, bg=COLOR_BG, width=(self._items_map.small_image_size + 4) * columnspan, height=3) 220 | f.grid(row=row + 1, column=column, columnspan=columnspan) 221 | return children_column + 1 222 | total_columns = draw_tree(tree, 1, 0) 223 | for c in range(total_columns): 224 | self._recipe_browser_window.grid_columnconfigure(c, minsize=self._items_map.small_image_size) 225 | # Show parents on row 0 226 | parents = [RecipeItemNode(p, []) for p in self._items_map.get_parent_recipes_for(item)] 227 | if len(parents) > 0: 228 | tk.Label(self._recipe_browser_window, text='Used in:', bg=COLOR_BG, fg=COLOR_FG_GREEN, font=FONT_BIG).grid(row=0, column=0) 229 | for column, p in enumerate(parents): 230 | # Reuse the same function for convenience 231 | draw_tree(p, 0, column + 1) 232 | 233 | def _highlight_items_in_inventory(self, inventory_items: List[Tuple[int, int]], color: str) -> None: 234 | self._highlight_windows_to_show = list() 235 | for (x, y) in inventory_items: 236 | x_offset, y_offset, _, _ = self._image_scanner.scanner_window_size 237 | x += x_offset 238 | y += y_offset 239 | width = int(self._items_map.image_size[0] * 0.7) 240 | height = int(self._items_map.image_size[1] * 0.7) 241 | w = UIOverlay.create_toplevel_window(bg=color) 242 | w.geometry(f'{width}x{height}+{x}+{y}') 243 | self._highlight_windows_to_show.append(w) 244 | 245 | def _clear_highlights(self, _) -> None: 246 | for w in self._highlight_windows_to_show: 247 | w.destroy() 248 | 249 | def _create_tooltip_and_highlight(self, window, inventory_items, text) -> None: 250 | if self._tooltip_window is not None: 251 | self._tooltip_window.destroy() 252 | self._tooltip_window = UIOverlay.create_toplevel_window() 253 | self._tooltip_window.geometry(f'+{window.winfo_x()}+{window.winfo_y() - 40}') 254 | tk.Label(self._tooltip_window, text=text, font=FONT_BIG, bg=COLOR_BG, fg=COLOR_FG_GREEN).pack() 255 | 256 | if inventory_items is not None: 257 | self._highlight_items_in_inventory(inventory_items, COLOR_FG_GREEN) 258 | 259 | def _copy_tree_items_to_clipboard(self, tree): 260 | if len(tree.components) > 0: 261 | search_string = '|'.join((str(x.item) for x in tree.components)) 262 | else: 263 | search_string = tree.item 264 | 265 | search_string = search_string.replace(' ', '\\s') 266 | 267 | OpenClipboard() 268 | EmptyClipboard() 269 | SetClipboardText('^('+search_string+')') 270 | CloseClipboard() 271 | 272 | def _destroy_tooltip_and_clear_highlights(self, _) -> None: 273 | if self._tooltip_window is not None: 274 | self._tooltip_window.destroy() 275 | self._clear_highlights(None) 276 | 277 | def run(self) -> None: 278 | self._root.mainloop() 279 | 280 | class Settings: 281 | def __init__(self, root: tk.Tk, items_map: ArchnemesisItemsMap, image_scanner, on_scan_hotkey): 282 | self._root = root 283 | self._items_map = items_map 284 | self._image_scanner = image_scanner 285 | self._on_scan_hotkey = on_scan_hotkey 286 | self._window = None 287 | 288 | self._config = ConfigParser() 289 | self._config_file = 'settings.ini' 290 | 291 | self._config.read(self._config_file) 292 | if 'settings' not in self._config: 293 | self._config.add_section('settings') 294 | s = self._config['settings'] 295 | 296 | self._items_map.scale = float(s.get('image_scale', self._items_map.scale)) 297 | self._image_scanner.confidence_threshold = float(s.get('confidence_threshold', self._image_scanner.confidence_threshold)) 298 | b = s.get('display_inventory_items') 299 | self._display_inventory_items = True if b is not None and b == 'True' else False 300 | b = s.get('display_unavailable_recipes') 301 | self._display_unavailable_recipes = True if b is not None and b == 'True' else False 302 | b = s.get('copy_recipe_to_clipboard') 303 | self._copy_recipe_to_clipboard = True if b is not None and b == 'True' else False 304 | b = s.get('scan_hotkey') 305 | self._scan_hotkey = b if b is not None else '' 306 | self._set_scan_hotkey() 307 | b = s.get('run_as_overlay') 308 | self._run_as_overlay = True if b is None or b == 'True' else False 309 | b = s.get('shopping_list_mode') 310 | self._shopping_list_mode = False if b is None or b == 'False' else True 311 | b = s.get('shopping_list') 312 | self._shopping_list = '' if b is None else b 313 | 314 | 315 | def show(self) -> None: 316 | if self._window is not None: 317 | return 318 | self._window = tk.Toplevel() 319 | 320 | self._window.geometry('+100+200') 321 | self._window.protocol('WM_DELETE_WINDOW', self._close) 322 | 323 | v = tk.DoubleVar(self._window, value=self._items_map.scale) 324 | self._scale_entry = tk.Entry(self._window, textvariable=v) 325 | self._scale_entry.grid(row=1, column=0) 326 | tk.Button(self._window, text='Set image scale', command=self._update_scale).grid(row=1, column=1) 327 | 328 | v = tk.DoubleVar(self._window, value=self._image_scanner.confidence_threshold) 329 | self._confidence_threshold_entry = tk.Entry(self._window, textvariable=v) 330 | self._confidence_threshold_entry.grid(row=2, column=0) 331 | tk.Button(self._window, text='Set confidence threshold', command=self._update_confidence_threshold).grid(row=2, column=1) 332 | 333 | v = tk.StringVar(self._window, value=self._scan_hotkey) 334 | self._scan_hotkey_entry = tk.Entry(self._window, textvariable=v) 335 | self._scan_hotkey_entry.grid(row=3, column=0) 336 | tk.Button(self._window, text='Set scan/hide hotkey', command=self._update_scan_hotkey).grid(row=3, column=1) 337 | 338 | c = tk.Checkbutton(self._window, text='Display inventory items', command=self._update_display_inventory_items) 339 | c.grid(row=4, column=0, columnspan=2) 340 | if self._display_inventory_items: 341 | c.select() 342 | 343 | c = tk.Checkbutton(self._window, text='Display unavailable recipes', command=self._update_display_unavailable_recipes) 344 | c.grid(row=5, column=0, columnspan=2) 345 | if self._display_unavailable_recipes: 346 | c.select() 347 | 348 | c = tk.Checkbutton(self._window, text='Copy recipe to clipboard', command=self._update_copy_recipe_to_clipboard) 349 | c.grid(row=6, column=0, columnspan=2) 350 | if self._copy_recipe_to_clipboard: 351 | c.select() 352 | 353 | c = tk.Checkbutton(self._window, text='Run as overlay', command=self._update_run_as_overlay) 354 | c.grid(row=7, column=0, columnspan=2) 355 | if self._run_as_overlay: 356 | c.select() 357 | 358 | c = tk.Checkbutton(self._window, text='Shopping List Mode', command=self._update_shopping_list_mode) 359 | c.grid(row=8, column=0, columnspan=2) 360 | if self._shopping_list_mode: 361 | c.select() 362 | 363 | self._shopping_list_label = tk.StringVar() 364 | self._shopping_list_label.set("Enter a comma separated list of items") 365 | c = tk.Label(self._window, textvariable=self._shopping_list_label).grid(row=9, column=0, columnspan=2) 366 | 367 | v = tk.StringVar(self._window, value=self._shopping_list) 368 | self._shopping_list_entry = tk.Entry(self._window, textvariable=v) 369 | self._shopping_list_entry.grid(row=10, column=0) 370 | tk.Button(self._window, text='Set shopping list', command=self._update_shopping_list).grid(row=10, column=1) 371 | 372 | def _close(self) -> None: 373 | if self._window is not None: 374 | self._window.destroy() 375 | self._window = None 376 | 377 | def _save_config(self) -> None: 378 | self._config['settings']['image_scale'] = str(self._items_map.scale) 379 | self._config['settings']['confidence_threshold'] = str(self._image_scanner.confidence_threshold) 380 | self._config['settings']['display_inventory_items'] = str(self._display_inventory_items) 381 | self._config['settings']['display_unavailable_recipes'] = str(self._display_unavailable_recipes) 382 | self._config['settings']['copy_recipe_to_clipboard'] = str(self._copy_recipe_to_clipboard) 383 | self._config['settings']['scan_hotkey'] = str(self._scan_hotkey) 384 | self._config['settings']['run_as_overlay'] = str(self._run_as_overlay) 385 | self._config['settings']['shopping_list_mode'] = str(self._shopping_list_mode) 386 | self._config['settings']['shopping_list'] = str(self._shopping_list) 387 | with open(self._config_file, 'w') as f: 388 | self._config.write(f) 389 | 390 | def _update_scale(self) -> None: 391 | try: 392 | new_scale = float(self._scale_entry.get()) 393 | except ValueError: 394 | print('Unable to parse image scale parameter') 395 | return 396 | self._items_map.scale = new_scale 397 | self._save_config() 398 | 399 | def _update_confidence_threshold(self) -> None: 400 | try: 401 | new_threshold = float(self._confidence_threshold_entry.get()) 402 | except ValueError: 403 | print('Unable to parse confidence threshold parameter') 404 | return 405 | self._image_scanner.confidence_threshold = new_threshold 406 | self._save_config() 407 | 408 | def _update_display_inventory_items(self) -> None: 409 | self._display_inventory_items = not self._display_inventory_items 410 | self._save_config() 411 | 412 | def _update_display_unavailable_recipes(self) -> None: 413 | self._display_unavailable_recipes = not self._display_unavailable_recipes 414 | self._save_config() 415 | 416 | def _update_copy_recipe_to_clipboard(self) -> None: 417 | self._copy_recipe_to_clipboard = not self._copy_recipe_to_clipboard 418 | self._save_config() 419 | 420 | def _update_scan_hotkey(self) -> None: 421 | try: 422 | keyboard.remove_hotkey(self._scan_hotkey) 423 | except KeyError: 424 | # The hotkey didn't exist or self._scan_hotkey had invalid hotkey 425 | pass 426 | self._scan_hotkey = self._scan_hotkey_entry.get() 427 | self._set_scan_hotkey() 428 | self._save_config() 429 | 430 | def _set_scan_hotkey(self) -> None: 431 | if self._scan_hotkey: 432 | try: 433 | keyboard.add_hotkey(self._scan_hotkey, self._on_scan_hotkey) 434 | except ValueError: 435 | # TODO: show the error in the ui 436 | print('Invalid scan hotkey!') 437 | 438 | def _update_run_as_overlay(self) -> None: 439 | self._run_as_overlay = not self._run_as_overlay 440 | self._save_config() 441 | 442 | def _update_shopping_list_mode(self) -> None: 443 | self._shopping_list_mode = not self._shopping_list_mode 444 | self._save_config() 445 | 446 | def _update_shopping_list(self) -> None: 447 | shopping_list = list(map(lambda x: x.strip(), self._shopping_list_entry.get().split(","))) 448 | if len(shopping_list) == 0 or len(self._shopping_list_entry.get().strip()) == 0: 449 | self._update_shopping_list_label("Error: Must enter at least one item") 450 | return 451 | for item in shopping_list: 452 | if item not in self._items_map.items(): 453 | self._update_shopping_list_label('Error: unknown item "{0}"'.format(item)) 454 | return 455 | self._update_shopping_list_label("Shopping list updated!") 456 | self._shopping_list = ",".join(shopping_list) 457 | self._save_config() 458 | 459 | def _update_shopping_list_label(self, value) -> None: 460 | self._shopping_list_label.set(value) 461 | self._window.update_idletasks() 462 | 463 | def should_display_inventory_items(self) -> bool: 464 | return self._display_inventory_items 465 | 466 | def should_display_unavailable_recipes(self) -> bool: 467 | return self._display_unavailable_recipes 468 | 469 | def should_copy_recipe_to_clipboard(self) -> bool: 470 | return self._copy_recipe_to_clipboard 471 | 472 | def should_run_as_overlay(self) -> bool: 473 | return self._run_as_overlay 474 | 475 | def is_shopping_list_mode(self) -> bool: 476 | return self._shopping_list_mode 477 | 478 | def get_shopping_list(self) -> str: 479 | return self._shopping_list 480 | --------------------------------------------------------------------------------