├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation-review.md │ └── feature_request.md ├── .gitignore ├── LICENSE ├── README.md ├── VERSION ├── build.py ├── doc_src ├── docs │ ├── formulas.md │ └── index.md ├── hex_theme │ └── img │ │ └── grid.png └── mkdocs.yml ├── hexsheets.spec ├── requirements.txt ├── screenshots └── main-screen.PNG ├── src ├── controller.py ├── core │ ├── __init__.py │ └── formula_parser.py ├── gui │ ├── __init__.py │ ├── widgets │ │ ├── __init__.py │ │ └── hex_cells.py │ └── windows │ │ ├── __init__.py │ │ └── main_window │ │ ├── __init__.py │ │ ├── menu.py │ │ ├── spreadsheet_area.py │ │ └── top_area.py ├── hexsheets.py └── tk_mvc │ ├── __init__.py │ ├── controller.py │ ├── event.py │ ├── view.py │ ├── window.py │ └── window_part.py └── tests ├── __init__.py └── model ├── __init__.py └── test_formula_parser.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 0.1.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-review.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Review 3 | about: Documentation should be reviewed for every release. 4 | title: Review documentation for [vX.X.X] 5 | labels: documentation, release 6 | assignees: '' 7 | 8 | --- 9 | 10 | Ensure that documentation is up-to-date for new release. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /venv 2 | /.idea 3 | __pycache__/ 4 | *.pyc 5 | /build 6 | /dist 7 | /src/docs 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Peter L. Adams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HexSheets 2 | [![Version](https://img.shields.io/github/v/release/pladams9/hexsheets)](https://github.com/pladams9/hexsheets/releases) 3 | [![Last Commit](https://img.shields.io/github/last-commit/pladams9/hexsheets)](https://github.com/pladams9/hexsheets/commits) 4 | 5 | ## What is HexSheets? 6 | HexSheets is a basic spreadsheet application with hexagonal cells and a pet project of mine. 7 | It was inspired by this post: http://www.secretgeek.net/hexcel. 8 | 9 | ![Screenshot](/screenshots/main-screen.PNG) 10 | 11 | ## Getting HexSheets 12 | 13 | ### Option 1: "Compiled" Release 14 | 15 | *Currently only for Windows* 16 | 17 | 1. Go to [Releases](https://github.com/pladams9/hexsheets/releases) and download a ZIP file of 18 | the latest release. 19 | 2. Unzip the archive. 20 | 3. Open `hexsheets-x.x.x.exe` 21 | 22 | ### Option 2: From Source 23 | *Assumes you have some understanding of git .* 24 | 1. Clone the repository* to your local machine. 25 | 2. From the base directory, run `pip install -r requirements.txt`. Optionally, create a `venv`. 26 | 3. From the `/doc_src`, run `mkdocs build` to build the documentation (under the `/src/docs/` folder.) 27 | 4. From `/src` run `python hexsheets.py` to start HexSheets. 28 | 29 | \* There are two main branches in the repository: `master`, which represents the most recent "stable" 30 | release, and `develop`, which houses the most recent development version. 31 | 32 | ## Building HexSheets 33 | If you have the HexSheets source code on your local machine, you can run `python build.py` from the 34 | base directory, and it will build (or re-build) the documentation (using `mkdocs`) and then freeze 35 | the python code into an executable file (using `pyinstaller`). The resulting files will be placed in 36 | `/dist`. This has currently only been tested on a Windows 10 machine - if you are able to successfully 37 | build on another OS, please let me know! 38 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.0-dev 2 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import shutil 4 | 5 | # Build docs 6 | os.chdir('doc_src') 7 | subprocess.run(['mkdocs', 'build', '--clean']) 8 | os.chdir('..') 9 | 10 | # Build executable/bundle 11 | subprocess.run(['pyinstaller', '--clean', '--noconfirm', 'hexsheets.spec']) 12 | 13 | # Clean up build directories 14 | try: 15 | shutil.rmtree('build') 16 | except Exception: 17 | print('Error removing build folders.') 18 | -------------------------------------------------------------------------------- /doc_src/docs/formulas.md: -------------------------------------------------------------------------------- 1 | # Formulas 2 | 3 | ## Basic Syntax 4 | 5 | Cell formulas start with an `=` character. 6 | 7 | HexSheets currently recognizes three data types: 8 | 9 | * `Strings` like `"abc"` 10 | * `Integers` like `42` 11 | * `Floats` like `3.14` 12 | 13 | Four operands: 14 | 15 | * Addition `+` 16 | * Subtraction `-` 17 | * Multiplication `*` 18 | * Division `/` 19 | 20 | And three types of brackets: 21 | 22 | * String Quotes: `" "` 23 | * Parentheses: `( )` 24 | * Cell Addresses: `[ , ]` 25 | 26 | ## Order of Operations 27 | 28 | HexSheets currently does not support basic order of operations. Parentheses are respected, but the basic operands are 29 | calculated from left to right. This will likely change in future versions. 30 | 31 | ## Example formulas 32 | 33 | * Not A Formula: `2 + 2` → `2 + 2` 34 | * A Formula: `=2 + 2` → `4` 35 | * No Parentheses: `=2 + 2 / 2` → `2` 36 | * Parentheses: `=2 + (2 / 2)` → `3` 37 | * String: `="Test" + 2` → `Test2` 38 | * Cell Address: `=[3, 2]` → Value of Cell at [3, 2] 39 | * Cell Address Evaluation: `=[1, (2 + 3)]` → Value of Cell at [1, 5] 40 | -------------------------------------------------------------------------------- /doc_src/docs/index.md: -------------------------------------------------------------------------------- 1 | title: Home 2 | 3 | # HexSheets 4 | 5 | HexSheets is spreadsheet software for people who know that hexagons are best. 6 | 7 | ### Quick Links 8 | 9 | * [Formulas](formulas.md) -------------------------------------------------------------------------------- /doc_src/hex_theme/img/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pladams9/hexsheets/e46f21f080a0c864f3a2c88dc1aafa59e7060686/doc_src/hex_theme/img/grid.png -------------------------------------------------------------------------------- /doc_src/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: HexSheets 2 | use_directory_urls: false 3 | repo_url: https://github.com/pladams9/hexsheets/ 4 | site_dir: ../src/docs 5 | theme: 6 | name: mkdocs 7 | custom_dir: hex_theme/ 8 | -------------------------------------------------------------------------------- /hexsheets.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | block_cipher = None 4 | 5 | with open('VERSION', 'r') as file: 6 | version_number = file.readline().strip() 7 | 8 | a = Analysis(['src/hexsheets.py'], 9 | pathex=[''], 10 | binaries=[], 11 | datas=[('src/docs', 'docs')], 12 | hiddenimports=[], 13 | hookspath=[], 14 | runtime_hooks=[], 15 | excludes=[], 16 | win_no_prefer_redirects=False, 17 | win_private_assemblies=False, 18 | cipher=block_cipher, 19 | noarchive=False) 20 | pyz = PYZ(a.pure, a.zipped_data, 21 | cipher=block_cipher) 22 | exe = EXE(pyz, 23 | a.scripts, 24 | [], 25 | exclude_binaries=True, 26 | name=('hexsheets-'+ version_number), 27 | debug=False, 28 | bootloader_ignore_signals=False, 29 | strip=False, 30 | upx=True, 31 | console=False ) 32 | coll = COLLECT(exe, 33 | a.binaries, 34 | a.zipfiles, 35 | a.datas, 36 | strip=False, 37 | upx=True, 38 | upx_exclude=[], 39 | name='hexsheets') 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.2.3 2 | PyInstaller==5.13.1 3 | Pillow==10.2.0 4 | -------------------------------------------------------------------------------- /screenshots/main-screen.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pladams9/hexsheets/e46f21f080a0c864f3a2c88dc1aafa59e7060686/screenshots/main-screen.PNG -------------------------------------------------------------------------------- /src/controller.py: -------------------------------------------------------------------------------- 1 | import tk_mvc 2 | from gui.windows import MainWindow 3 | import core 4 | 5 | 6 | class Controller(tk_mvc.BaseController): 7 | def __init__(self): 8 | super().__init__() 9 | 10 | self.model = core.HexSheetsCore() 11 | 12 | self._view.add_window('MainWindow', MainWindow) 13 | self._view.show_window('MainWindow') 14 | 15 | self._add_event_handlers({ 16 | 'FormulaChanged': self._formula_changed, 17 | 'CellSelected': self._cell_selected, 18 | 'RowResized': self._row_resized, 19 | 'ColumnResized': self._column_resized, 20 | 'NewFile': self._new_file, 21 | 'OpenFile': self._open_file, 22 | 'SaveFile': self._save_file, 23 | 'SaveFileAs': self._save_file_as, 24 | 'ToggleBold': self._toggle_bold, 25 | 'SetCellColor': self._set_cell_color, 26 | 'SetFontColor': self._set_font_color, 27 | 'SetFontSize': self._set_font_size 28 | }) 29 | 30 | self._view.set_value('title', self.model.get_file_title()) 31 | self._new_file() 32 | 33 | def _formula_changed(self, e): 34 | self.model.set_selected_cell_formula(e.data['formula']) 35 | self.model.editing_cell = True 36 | self._view.set_value('cell_values', self.model.get_cell_values()) 37 | self._view.set_value('title', self.model.get_file_title()) 38 | 39 | def _cell_selected(self, e): 40 | xy = e.data['address'] 41 | self.model.select_cell(xy[0], xy[1]) 42 | 43 | if self.model.editing_cell: 44 | self._view.set_value('cell_values', self.model.get_cell_values()) 45 | self.model.editing_cell = False 46 | 47 | self._view.set_value('formula_box', self.model.get_selected_cell_formula()) 48 | self._view.set_value('status_bar', str(xy)) 49 | 50 | self._view.set_value('current_cell_color', self.model.get_current_cell_color()) 51 | self._view.set_value('current_cell_font_color', self.model.get_current_cell_font_color()) 52 | self._view.set_value('current_cell_font_size', self.model.get_current_cell_font_size()) 53 | 54 | def _row_resized(self, e): 55 | self.model.set_row_size(e.data['row'], e.data['height']) 56 | 57 | def _column_resized(self, e): 58 | self.model.set_column_size(e.data['column'], e.data['width']) 59 | 60 | def _new_file(self, e=None): 61 | self.model.new_file() 62 | self._view.set_value('cell_values', self.model.get_cell_values()) 63 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 64 | self._view.set_value('current_cell_color', self.model.get_current_cell_color()) 65 | self._view.set_value('current_cell_font_color', self.model.get_current_cell_font_color()) 66 | self._view.set_value('current_cell_font_size', self.model.get_current_cell_font_size()) 67 | self._view.set_value('row_sizes', self.model.get_row_sizes()) 68 | self._view.set_value('column_sizes', self.model.get_column_sizes()) 69 | self._view.set_value('title', self.model.get_file_title()) 70 | self._view.set_value('save_option', self.model.save_file_exists()) 71 | 72 | def _open_file(self, e): 73 | self.model.open_file(e.data['filename']) 74 | self._view.set_value('row_sizes', self.model.get_row_sizes()) 75 | self._view.set_value('column_sizes', self.model.get_column_sizes()) 76 | self._view.set_value('cell_values', self.model.get_cell_values()) 77 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 78 | self._view.set_value('title', self.model.get_file_title()) 79 | self._view.set_value('save_option', self.model.save_file_exists()) 80 | 81 | def _save_file_as(self, e): 82 | self.model.save_file(filename=e.data['filename']) 83 | self._view.set_value('title', self.model.get_file_title()) 84 | self._view.set_value('save_option', self.model.save_file_exists()) 85 | 86 | def _save_file(self, e): 87 | self.model.save_file(overwrite=True) 88 | self._view.set_value('title', self.model.get_file_title()) 89 | 90 | def _toggle_bold(self, e): 91 | self.model.toggle_bold() 92 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 93 | 94 | def _set_cell_color(self, e): 95 | self.model.set_cell_color(e.data['color']) 96 | self._view.set_value('current_cell_color', e.data['color']) 97 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 98 | 99 | def _set_font_color(self, e): 100 | self.model.set_cell_font_color(e.data['color']) 101 | self._view.set_value('current_cell_font_color', e.data['color']) 102 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 103 | 104 | def _set_font_size(self, e): 105 | self.model.set_cell_font_size(e.data['font_size']) 106 | self._view.set_value('cell_formats', self.model.get_cell_formats()) 107 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The 'core' package defines all the parts of HexSheet's actual functionality, starting with the HexSheetsCore class in 3 | this file. 4 | """ 5 | 6 | import core.formula_parser as fp 7 | import json 8 | import ast 9 | 10 | 11 | class HexSheetsCore: 12 | """ 13 | HexSheetsCore is the model in HexSheet's MVC structure. All functionality is accessed through this class. 14 | """ 15 | 16 | # CONSTANTS 17 | ROW_SIZES_DEFAULT = { 18 | -1: 40 19 | } 20 | COLUMN_SIZES_DEFAULT = { 21 | -1: 30 22 | } 23 | DEFAULT_FORMAT = { 24 | 'bold': False, 25 | 'italic': False, 26 | 'underline': False, 27 | 'cell_color': '#EEE', 28 | 'font_color': '#000', 29 | 'font_size': 14 30 | } 31 | 32 | def __init__(self): 33 | self._cell_formulas = {} 34 | self._cell_formats = {} 35 | self._row_sizes = HexSheetsCore.ROW_SIZES_DEFAULT.copy() 36 | self._column_sizes = HexSheetsCore.COLUMN_SIZES_DEFAULT.copy() 37 | 38 | self._selected_cell = None 39 | self.editing_cell = False 40 | 41 | self._file_saved = False 42 | self._file_path = None 43 | 44 | def select_cell(self, x, y): 45 | self._selected_cell = (x, y) 46 | 47 | def set_selected_cell_formula(self, formula): 48 | if self._selected_cell: 49 | self._cell_formulas[self._selected_cell] = formula 50 | self._file_saved = False 51 | 52 | def get_selected_cell_formula(self): 53 | if self._selected_cell and (self._selected_cell in self._cell_formulas): 54 | return self._cell_formulas[self._selected_cell] 55 | else: 56 | return '' 57 | 58 | def get_cell_values(self): 59 | parser = fp.FormulaParser() 60 | parser.update_nodes(self._cell_formulas) 61 | values = {} 62 | for cell in self._cell_formulas: 63 | if cell == self._selected_cell and self.editing_cell: 64 | values[cell] = self._cell_formulas[cell] 65 | else: 66 | values[cell] = parser.get_node_value(cell) 67 | return values 68 | 69 | def get_row_sizes(self): 70 | return self._row_sizes 71 | 72 | def set_row_size(self, row_number=-1, size=20): 73 | self._row_sizes[row_number] = size 74 | 75 | def get_column_sizes(self): 76 | return self._column_sizes 77 | 78 | def set_column_size(self, column_number=-1, size=20): 79 | self._column_sizes[column_number] = size 80 | 81 | def new_file(self): 82 | self._cell_formulas = {} 83 | self._cell_formats = {} 84 | self._row_sizes = HexSheetsCore.ROW_SIZES_DEFAULT.copy() 85 | self._column_sizes = HexSheetsCore.COLUMN_SIZES_DEFAULT.copy() 86 | self._file_saved = False 87 | self._file_path = None 88 | 89 | def open_file(self, filename): 90 | if filename: 91 | with open(filename, 'r') as file: 92 | data = json.load(file) 93 | 94 | for row in data['rows']: 95 | self._row_sizes[int(row)] = data['rows'][row]['size'] 96 | 97 | for column in data['columns']: 98 | self._column_sizes[int(column)] = data['columns'][column]['size'] 99 | 100 | self._cell_formulas = {} 101 | self._cell_formats = {} 102 | for cell_coord in data['cells']: 103 | if 'formula' in data['cells'][cell_coord]: 104 | self._cell_formulas[ast.literal_eval(cell_coord)] = data['cells'][cell_coord]['formula'] 105 | if 'format' in data['cells'][cell_coord]: 106 | self._cell_formats[ast.literal_eval(cell_coord)] = data['cells'][cell_coord]['format'] 107 | self._file_saved = True 108 | self._file_path = filename 109 | 110 | def save_file(self, filename='', overwrite=False): 111 | if (not filename) and overwrite: 112 | filename = self._file_path 113 | 114 | if filename: 115 | data = { 116 | 'cells': {}, 117 | 'rows': {}, 118 | 'columns': {} 119 | } 120 | for row in self._row_sizes: 121 | data['rows'][row] = { 122 | 'size': self._row_sizes[row] 123 | } 124 | for column in self._column_sizes: 125 | data['columns'][column] = { 126 | 'size': self._column_sizes[column] 127 | } 128 | all_cells = set(self._cell_formulas.keys()) 129 | all_cells.update(self._cell_formats.keys()) 130 | for cell_coord in all_cells: 131 | data['cells'][str(cell_coord)] = {} 132 | if cell_coord in self._cell_formulas: 133 | data['cells'][str(cell_coord)]['formula'] = self._cell_formulas[cell_coord] 134 | if cell_coord in self._cell_formats: 135 | data['cells'][str(cell_coord)]['format'] = self._cell_formats[cell_coord] 136 | 137 | with open(filename, 'w') as file: 138 | json.dump(data, file) 139 | self._file_saved = True 140 | self._file_path = filename 141 | 142 | def get_file_title(self): 143 | if self._file_path: 144 | file_title = self._file_path[self._file_path.rfind('/') + 1:] 145 | else: 146 | file_title = 'untitled.hxs' 147 | file_title = '[' + file_title + ']' 148 | if not self._file_saved: 149 | file_title += '*' 150 | return file_title 151 | 152 | def save_file_exists(self): 153 | if self._file_path: 154 | return True 155 | else: 156 | return False 157 | 158 | def toggle_bold(self): 159 | if self._selected_cell not in self._cell_formats: 160 | self._cell_formats[self._selected_cell] = self.DEFAULT_FORMAT.copy() 161 | self._cell_formats[self._selected_cell]['bold'] = not self._cell_formats[self._selected_cell]['bold'] 162 | 163 | def get_cell_formats(self): 164 | return self._cell_formats 165 | 166 | def set_cell_color(self, color: str): 167 | if self._selected_cell not in self._cell_formats: 168 | self._cell_formats[self._selected_cell] = self.DEFAULT_FORMAT.copy() 169 | self._cell_formats[self._selected_cell]['cell_color'] = color 170 | 171 | def get_current_cell_color(self): 172 | if self._selected_cell not in self._cell_formats: 173 | return self.DEFAULT_FORMAT['cell_color'] 174 | else: 175 | return self._cell_formats[self._selected_cell]['cell_color'] 176 | 177 | def set_cell_font_color(self, color: str): 178 | if self._selected_cell not in self._cell_formats: 179 | self._cell_formats[self._selected_cell] = self.DEFAULT_FORMAT.copy() 180 | self._cell_formats[self._selected_cell]['font_color'] = color 181 | 182 | def get_current_cell_font_color(self): 183 | if self._selected_cell not in self._cell_formats: 184 | return self.DEFAULT_FORMAT['font_color'] 185 | else: 186 | return self._cell_formats[self._selected_cell]['font_color'] 187 | 188 | def set_cell_font_size(self, size: int): 189 | if self._selected_cell not in self._cell_formats: 190 | self._cell_formats[self._selected_cell] = self.DEFAULT_FORMAT.copy() 191 | self._cell_formats[self._selected_cell]['font_size'] = size 192 | 193 | def get_current_cell_font_size(self): 194 | if self._selected_cell not in self._cell_formats: 195 | return self.DEFAULT_FORMAT['font_size'] 196 | else: 197 | return self._cell_formats[self._selected_cell]['font_size'] -------------------------------------------------------------------------------- /src/core/formula_parser.py: -------------------------------------------------------------------------------- 1 | class FormulaParser: 2 | def __init__(self): 3 | self._nodes = {} 4 | self._start_node = None 5 | 6 | def update_nodes(self, nodes): 7 | for node in nodes: 8 | self._nodes[node] = str(nodes[node]) 9 | 10 | def clear_nodes(self): 11 | self._nodes = {} 12 | 13 | def get_node_value(self, node): 14 | if node == self._start_node: 15 | self._start_node = None 16 | return '#CIRCULAR' 17 | else: 18 | if self._start_node is None: 19 | self._start_node = node 20 | 21 | formula = self._nodes.get(node, '') 22 | 23 | if len(formula) == 0: 24 | return '' 25 | 26 | if formula[0] == '=': 27 | value = self._parse_formula(formula[1:]) 28 | else: 29 | value = self._cast_value(formula) 30 | 31 | if node == self._start_node: 32 | self._start_node = None 33 | 34 | return value 35 | 36 | @staticmethod 37 | def _tokenize(string): 38 | operators = ['+', '-', '*', '/'] 39 | brackets = { 40 | '"': '"', 41 | '(': ')', 42 | '[': ']' 43 | } 44 | 45 | tokens = [] 46 | sub_start = 0 47 | current_bracket = None 48 | for index, char in enumerate(string): 49 | if current_bracket: 50 | if char == brackets[current_bracket]: 51 | tokens.append(('BRACKET', current_bracket, string[sub_start:index].strip())) 52 | current_bracket = None 53 | sub_start = index + 1 54 | continue 55 | 56 | if char in brackets: 57 | current_bracket = char 58 | value = string[sub_start:index].strip() 59 | if value != '': 60 | tokens.append(('VALUE', value)) 61 | sub_start = index + 1 62 | elif char in operators: 63 | value = string[sub_start:index].strip() 64 | if value != '': 65 | tokens.append(('VALUE', value)) 66 | tokens.append(('OPERATOR', char)) 67 | sub_start = index + 1 68 | else: 69 | value = string[sub_start:].strip() 70 | if value != '': 71 | tokens.append(('VALUE', value)) 72 | 73 | return tokens 74 | 75 | def _parse_tokens(self, tokens): 76 | values_and_operators = [] 77 | for token in tokens: 78 | if token[0] == 'VALUE': 79 | values_and_operators.append(('VALUE', self._cast_value(token[1]))) 80 | elif token[0] == 'OPERATOR': 81 | values_and_operators.append(token) 82 | elif token[0] == 'BRACKET': 83 | if token[1] == '"': 84 | values_and_operators.append(('VALUE', token[2])) 85 | elif token[1] == '(': 86 | values_and_operators.append(('VALUE', self._parse_formula(token[2]))) 87 | elif token[1] == '[': 88 | address = self._parse_address(token[2]) 89 | values_and_operators.append(('VALUE', self.get_node_value(address))) 90 | 91 | return values_and_operators 92 | 93 | @staticmethod 94 | def _calculate_tokens(tokens): 95 | if len(tokens) < 1: 96 | return '' 97 | if tokens[0][0] != 'VALUE': 98 | return '#ERROR' 99 | else: 100 | value_types = [type(y[1]) for y in tokens if y[0] == 'VALUE'] 101 | if str in value_types: 102 | for i in range(len(tokens)): 103 | if tokens[i][0] == 'VALUE': 104 | tokens[i] = ('VALUE', str(tokens[i][1])) 105 | try: 106 | return_value = tokens.pop(0)[1] 107 | while len(tokens) > 0: 108 | operator = tokens.pop(0)[1] 109 | if operator == '+': 110 | return_value += tokens.pop(0)[1] 111 | elif operator == '-': 112 | return_value -= tokens.pop(0)[1] 113 | elif operator == '*': 114 | return_value *= tokens.pop(0)[1] 115 | elif operator == '/': 116 | return_value /= tokens.pop(0)[1] 117 | except TypeError: 118 | return_value = '#ERROR' 119 | return return_value 120 | 121 | def _parse_formula(self, formula): 122 | tokens = self._tokenize(formula) 123 | values_and_operators = self._parse_tokens(tokens) 124 | return_value = self._calculate_tokens(values_and_operators) 125 | return return_value 126 | 127 | def _parse_address(self, address): 128 | if address.count(',') != 1: 129 | return -1, -1 130 | 131 | coordinates = address.split(',') 132 | 133 | for i in range(2): 134 | coordinates[i] = self._parse_formula(coordinates[i]) 135 | if not isinstance(coordinates[i], int): 136 | return -1, -1 137 | return tuple(coordinates) 138 | 139 | @staticmethod 140 | def _cast_value(value): 141 | if (value.replace('.', '').replace('-', '').isdigit()) and value.count('.') <= 1: 142 | if value.count('.') == 1: 143 | return float(value) 144 | else: 145 | return int(value) 146 | else: 147 | return value 148 | -------------------------------------------------------------------------------- /src/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pladams9/hexsheets/e46f21f080a0c864f3a2c88dc1aafa59e7060686/src/gui/__init__.py -------------------------------------------------------------------------------- /src/gui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from gui.widgets.hex_cells import HexCells 2 | -------------------------------------------------------------------------------- /src/gui/widgets/hex_cells.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from PIL import Image, ImageTk, ImageDraw, ImageFont 3 | 4 | 5 | class HexCells(tk.Frame): 6 | def __init__(self, master, **kwargs): 7 | self._default_column_width = 30 8 | self._default_row_height = 40 9 | self._point_width = 10 10 | self._hex_columns = 20 11 | self._hex_rows = 20 12 | 13 | self._canvas_ready = False 14 | 15 | self._tk_images = [] 16 | 17 | self._cell_coords = {} 18 | self.current_cell = None 19 | self._internal_cell_selection = None 20 | self._select_command = None 21 | 22 | self._resize_row_command = None 23 | self._resize_column_command = None 24 | 25 | self._cell_values = {} 26 | self._cell_formats = {} 27 | 28 | super().__init__(master, **(self._custom_options(**kwargs))) 29 | 30 | # Colors 31 | self.colors = { 32 | 'bg': '#BBB', 33 | 'cell-bg': '#EEE', 34 | 'cell-line': '#888', 35 | 'active-cell-line': '#555' 36 | } 37 | 38 | # Row/Column Widgets 39 | self._column_shelf = tk.Canvas(self, height=20) 40 | self._column_shelf.grid(column=1, row=0, sticky='nsew') 41 | self._column_handles = [] 42 | self._column_ids = {} 43 | self._column_widths = [self._default_column_width for i in range(self._hex_columns)] 44 | 45 | self._row_shelf = tk.Canvas(self, width=20) 46 | self._row_shelf.grid(column=0, row=1, sticky='nsew') 47 | self._row_handles = [] 48 | self._row_ids = [] 49 | self._row_heights = [self._default_row_height for i in range(self._hex_rows)] 50 | 51 | self.resizing_id = None 52 | self.resize_coord = None 53 | 54 | # Canvas 55 | self._canvas = tk.Canvas(self) 56 | self._canvas.config(bg=self.colors['bg']) 57 | self._canvas.grid(column=1, row=1, sticky='nsew') 58 | self._canvas.bind('', self._cell_click) 59 | self._canvas_ready = True 60 | self.columnconfigure(1, weight=1) 61 | self.rowconfigure(1, weight=1) 62 | 63 | # Build out canvas 64 | self._build_canvas_items() 65 | 66 | # Scrollbars 67 | self._v_scroll = tk.Scrollbar(self) 68 | self._v_scroll.grid(column=2, row=1, sticky='nsew') 69 | self._h_scroll = tk.Scrollbar(self, orient=tk.HORIZONTAL) 70 | self._h_scroll.grid(column=1, row=2, sticky='nsew') 71 | 72 | self._canvas.config( 73 | xscrollcommand=self._h_scroll.set, 74 | yscrollcommand=self._v_scroll.set 75 | ) 76 | 77 | def y_scroll(*args): 78 | self._canvas.yview(*args) 79 | self._row_shelf.yview(*args) 80 | 81 | self._v_scroll.config(command=y_scroll) 82 | self._canvas.yview_moveto(0) 83 | self._row_shelf.yview_moveto(0) 84 | 85 | def x_scroll(*args): 86 | self._canvas.xview(*args) 87 | self._column_shelf.xview(*args) 88 | 89 | self._h_scroll.config(command=x_scroll) 90 | self._canvas.xview_moveto(0) 91 | self._column_shelf.xview_moveto(0) 92 | 93 | # Hidden entry box TODO: Remove this entry box and move entry outside of widget 94 | self.hidden_entry = tk.Entry(self) 95 | self.hidden_entry.place(x=-100, y=-100) 96 | 97 | def config(self, **kwargs): 98 | super().config(**(self._custom_options(**kwargs))) 99 | 100 | def _custom_options(self, **kwargs): 101 | redraw_needed = False 102 | # Hex Dimensions 103 | if 'hex_width' in kwargs: 104 | self._default_column_width = kwargs['hex_width'] 105 | kwargs.pop('hex_width') 106 | redraw_needed = True 107 | if 'hex_height' in kwargs: 108 | self._default_row_height = kwargs['hex_height'] 109 | self._point_width = self._default_row_height / 4 110 | kwargs.pop('hex_height') 111 | redraw_needed = True 112 | 113 | # Rows/Columns 114 | if 'hex_columns' in kwargs: 115 | self._hex_columns = kwargs['hex_columns'] 116 | kwargs.pop('hex_columns') 117 | redraw_needed = True 118 | if 'hex_rows' in kwargs: 119 | self._hex_rows = kwargs['hex_rows'] 120 | kwargs.pop('hex_rows') 121 | redraw_needed = True 122 | 123 | if self._canvas_ready and redraw_needed: 124 | self._build_canvas_items() 125 | 126 | # Click Command 127 | if 'select_command' in kwargs: 128 | self._select_command = kwargs['select_command'] 129 | kwargs.pop('select_command') 130 | 131 | # Resize Commands 132 | if 'resize_row_command' in kwargs: 133 | self.resize_row_command = kwargs['resize_row_command'] 134 | kwargs.pop('resize_row_command') 135 | 136 | if 'resize_column_command' in kwargs: 137 | self._resize_column_command = kwargs['resize_column_command'] 138 | kwargs.pop('resize_column_command') 139 | 140 | return kwargs 141 | 142 | def _create_hex(self, x, y, width, top_height, bottom_height, cell_color='#EEE'): 143 | full_height = top_height + bottom_height 144 | hex_coords = [ 145 | x, y, 146 | x + width, y, 147 | x + width + self._point_width, y + top_height, 148 | x + width, y + full_height, 149 | x, y + full_height, 150 | x - self._point_width, y + top_height 151 | ] 152 | hex_shape = self._canvas.create_polygon(hex_coords, 153 | fill=cell_color, 154 | outline=self.colors['cell-line'], 155 | tag='hex' 156 | ) 157 | return hex_shape 158 | 159 | def _create_cell_text_image(self, width, height, text='', format_options=None): 160 | cell_text_image = Image.new('RGBA', (int(width), int(height)), (0, 0, 0, 0)) 161 | 162 | if format_options is None: 163 | format_options = { 164 | 'font_size': 14, 165 | 'font_color': '#000' 166 | } 167 | cell_font = ImageFont.truetype('arial.ttf', format_options['font_size']) 168 | 169 | draw = ImageDraw.Draw(cell_text_image) 170 | draw.text((0, (height - format_options['font_size']) / 2), str(text), fill=format_options['font_color'], outline='#F00', font=cell_font) 171 | 172 | cell_tk_image = ImageTk.PhotoImage(image=cell_text_image) 173 | self._tk_images.append(cell_tk_image) 174 | 175 | return cell_tk_image 176 | 177 | def _create_cell_text(self, x, y, width, height): 178 | cell_text_canvas_item = self._canvas.create_image(x, y, image=None, anchor=tk.NW, tag='text') 179 | 180 | return cell_text_canvas_item 181 | 182 | def _create_hex_grid(self): 183 | self._canvas.delete(tk.ALL) 184 | self._tk_images = [] 185 | self._cell_coords = {} 186 | 187 | for cell_y in range(self._hex_rows): 188 | for cell_x in range(self._hex_columns): 189 | canvas_x = sum(w + self._point_width for w in self._column_widths[0:cell_x]) 190 | canvas_y = sum(self._row_heights[0:cell_y]) + [0, self._row_heights[cell_y] / 2][cell_x % 2] 191 | 192 | if (cell_x, cell_y) in self._cell_formats: 193 | cell_color = self._cell_formats[(cell_x, cell_y)]['cell_color'] 194 | else: 195 | cell_color = '#EEE' 196 | 197 | if ((cell_x % 2) != 0) and (cell_y + 1 != self._hex_rows): 198 | h = self._create_hex(canvas_x, canvas_y, self._column_widths[cell_x], self._row_heights[cell_y] / 2, 199 | self._row_heights[cell_y + 1] / 2, cell_color) 200 | t = self._create_cell_text(canvas_x, 201 | canvas_y, 202 | self._column_widths[cell_x], 203 | (self._row_heights[cell_y] + self._row_heights[cell_y + 1]) / 2) 204 | else: 205 | h = self._create_hex(canvas_x, canvas_y, self._column_widths[cell_x], self._row_heights[cell_y] / 2, 206 | self._row_heights[cell_y] / 2, cell_color) 207 | t = self._create_cell_text(canvas_x, canvas_y, self._column_widths[cell_x], 208 | self._row_heights[cell_y]) 209 | 210 | self._canvas.addtag_withtag(''.join(['col', str(cell_x)]), h) 211 | self._canvas.addtag_withtag(''.join(['row', str(cell_y)]), h) 212 | self._cell_coords[h] = (cell_x, cell_y) 213 | 214 | self._canvas.addtag_withtag(''.join(['col', str(cell_x)]), t) 215 | self._canvas.addtag_withtag(''.join(['row', str(cell_y)]), t) 216 | 217 | limits = ( 218 | -self._point_width - 1, 219 | -1, 220 | sum(self._column_widths) + (self._hex_columns * self._point_width) + 1, 221 | sum(self._row_heights) + (self._row_heights[-1] / 2) + 1 222 | ) 223 | self._canvas.config(scrollregion=limits) 224 | 225 | def _create_column_handles(self): 226 | self._column_shelf.delete(tk.ALL) 227 | for column in self._column_handles: 228 | column['handle'].destroy() 229 | column['sash'].destroy() 230 | self._column_handles = [] 231 | self._column_ids = {} 232 | 233 | for x in range(self._hex_columns): 234 | new_column_handle = { 235 | 'handle': tk.Frame(self._column_shelf, relief=tk.RAISED, bd=2), 236 | 'sash': tk.Frame(self._column_shelf, width=self._point_width, bg='#BBB', cursor='sb_h_double_arrow') 237 | } 238 | 239 | self._column_handles.append(new_column_handle) 240 | self._column_ids[new_column_handle['sash'].winfo_name()] = x 241 | 242 | self._column_handles[x]['sash'].bind('', self._start_column_resize) 243 | self._column_handles[x]['sash'].bind('', self._finish_column_resize) 244 | 245 | handle = self._column_shelf.create_window( 246 | sum(self._column_widths[:x]) + (x * self._point_width), 0, 247 | anchor=tk.NW, width=self._column_widths[x], height=20, 248 | window=new_column_handle['handle'], tag='col-handle') 249 | self._column_shelf.addtag_withtag(''.join(['col', str(x)]), handle) 250 | 251 | sash = self._column_shelf.create_window( 252 | sum(self._column_widths[:(x + 1)]) + (x * self._point_width), 0, 253 | anchor=tk.NW, width=self._point_width, height=20, 254 | window=new_column_handle['sash'], tag='col-sash') 255 | self._column_shelf.addtag_withtag(''.join(['col', str(x)]), sash) 256 | 257 | limits = ( 258 | -self._point_width - 1, 259 | 0, 260 | sum(self._column_widths) + (self._hex_columns * self._point_width), 261 | 20 262 | ) 263 | self._column_shelf.config(scrollregion=limits) 264 | 265 | def _create_row_handles(self): 266 | self._row_shelf.delete(tk.ALL) 267 | for row in self._row_handles: 268 | row['handle'].destroy() 269 | row['sash'].destroy() 270 | self._row_handles = [] 271 | self._row_ids = {} 272 | 273 | for y in range(self._hex_rows): 274 | new_row_handle = { 275 | 'handle': tk.Frame(self._row_shelf, relief=tk.RAISED, bd=2), 276 | 'sash': tk.Frame(self._row_shelf, bg='#BBB', cursor='sb_v_double_arrow') 277 | } 278 | self._row_handles.append(new_row_handle) 279 | self._row_ids[new_row_handle['sash'].winfo_name()] = y 280 | 281 | self._row_handles[y]['sash'].bind('', self._start_row_resize) 282 | self._row_handles[y]['sash'].bind('', self._finish_row_resize) 283 | 284 | handle = self._row_shelf.create_window( 285 | 0, sum(self._row_heights[:y]) + 2, 286 | anchor=tk.NW, height=self._row_heights[y] - 4, width=20, 287 | window=new_row_handle['handle'], tag='col-handle') 288 | self._row_shelf.addtag_withtag(''.join(['col', str(y)]), handle) 289 | 290 | sash = self._row_shelf.create_window( 291 | 0, sum(self._row_heights[:(y + 1)]) - 2, 292 | anchor=tk.NW, height=4, width=20, 293 | window=new_row_handle['sash'], tag='row-handle') 294 | self._row_shelf.addtag_withtag(''.join(['col', str(y)]), sash) 295 | 296 | limits = ( 297 | 0, 298 | -2, 299 | 20, 300 | sum(self._row_heights) + (self._row_heights[-1] / 2) + 4 301 | ) 302 | self._row_shelf.config(scrollregion=limits) 303 | 304 | def _build_canvas_items(self): 305 | self._create_hex_grid() 306 | self._create_column_handles() 307 | self._create_row_handles() 308 | 309 | self._update_cells() 310 | 311 | def _update_cell_colors(self): 312 | self._canvas.itemconfig('hex', fill='#EEE') 313 | for coord in self._cell_formats: 314 | items = self._canvas.find_withtag('hex&&col{0}&&row{1}'.format(coord[0], coord[1])) 315 | if items: 316 | self._canvas.itemconfig(items[0], fill=self._cell_formats[coord]['cell_color']) 317 | 318 | def _cell_click(self, e): 319 | canvas_x = self._canvas.canvasx(e.x) 320 | canvas_y = self._canvas.canvasy(e.y) 321 | 322 | self._canvas.dtag('clicked', 'clicked') 323 | self._canvas.addtag_overlapping('clicked', canvas_x, canvas_y, canvas_x, canvas_y) 324 | 325 | self._canvas.itemconfig('hex', width=1, outline=self.colors['cell-line']) 326 | 327 | matching_cells = self._canvas.find_withtag('clicked && hex') 328 | if matching_cells: 329 | cell = matching_cells[0] 330 | self._canvas.itemconfig(cell, width=2, outline=self.colors['active-cell-line']) 331 | self._canvas.tag_raise(cell) 332 | 333 | self._canvas.tag_raise('text') 334 | 335 | self.current_cell = self._cell_coords[cell] 336 | 337 | if self._select_command: 338 | self._select_command(self.current_cell) 339 | 340 | self.hidden_entry.focus_set() 341 | 342 | def set_cell_formats(self, formats): 343 | self._cell_formats = formats 344 | self._update_cell_colors() 345 | self._update_cells() 346 | 347 | def set_cell_values(self, values): 348 | self._cell_values = values 349 | self._update_cells() 350 | 351 | def _update_cells(self): 352 | items = self._canvas.find_withtag('text&&has_value') 353 | self._tk_images = [] 354 | for item in items: 355 | self._canvas.itemconfig(item, image=None) 356 | self._canvas.dtag(items, 'has_value') 357 | 358 | for coord in self._cell_values: 359 | items = self._canvas.find_withtag('text&&col{0}&&row{1}'.format(coord[0], coord[1])) 360 | if items: 361 | if coord in self._cell_formats: 362 | cell_format = self._cell_formats[coord] 363 | else: 364 | cell_format = None 365 | cell_text_image = self._create_cell_text_image(self._column_widths[coord[0]], 366 | self._row_heights[coord[1]], 367 | self._cell_values[coord], 368 | cell_format) 369 | self._canvas.itemconfig(items[0], image=cell_text_image) 370 | self._canvas.addtag_withtag('has_value', items[0]) 371 | 372 | def _start_column_resize(self, e): 373 | self.resizing_id = self._column_ids[e.widget.winfo_name()] 374 | self.resize_coord = e.x 375 | 376 | def update_line(): 377 | self._canvas.delete('resize_line') 378 | 379 | if self.resizing_id is not None: 380 | x = self._canvas.canvasx(self.winfo_pointerx() - self._canvas.winfo_rootx()) 381 | 382 | line = self._canvas.create_line(x, self._canvas.canvasy(0), 383 | x, self._canvas.canvasy(self._canvas.winfo_height()), 384 | width=2, fill='#555') 385 | self._canvas.addtag_withtag('resize_line', line) 386 | 387 | self.after(33, update_line) 388 | 389 | update_line() 390 | 391 | def _finish_column_resize(self, e): 392 | if self.resize_coord is not None: 393 | diff = e.x - self.resize_coord 394 | width = self._column_widths[self.resizing_id] 395 | width += diff 396 | width = max(10, width) 397 | self._column_widths[self.resizing_id] = width 398 | 399 | self._build_canvas_items() 400 | 401 | if self._resize_column_command: 402 | self._resize_column_command(self.resizing_id, width) 403 | 404 | self.resize_coord = None 405 | self.resizing_id = None 406 | 407 | def set_column_sizes(self, column_sizes): 408 | self._column_widths = [] 409 | if -1 in column_sizes: 410 | self._default_column_width = column_sizes[-1] 411 | 412 | for column in range(self._hex_columns): 413 | if column in column_sizes: 414 | self._column_widths.append(column_sizes[column]) 415 | else: 416 | self._column_widths.append(self._default_column_width) 417 | 418 | self._build_canvas_items() 419 | 420 | def _start_row_resize(self, e): 421 | self.resizing_id = self._row_ids[e.widget.winfo_name()] 422 | self.resize_coord = e.y 423 | 424 | def update_line(): 425 | self._canvas.delete('resize_line') 426 | 427 | if self.resizing_id is not None: 428 | y = self._canvas.canvasy(self.winfo_pointery() - self._canvas.winfo_rooty()) 429 | 430 | line = self._canvas.create_line(self._canvas.canvasx(0), y, 431 | self._canvas.canvasx(self._canvas.winfo_width()), y, 432 | width=2, fill='#555') 433 | self._canvas.addtag_withtag('resize_line', line) 434 | 435 | self.after(33, update_line) 436 | 437 | update_line() 438 | 439 | def _finish_row_resize(self, e): 440 | if self.resize_coord is not None: 441 | diff = e.y - self.resize_coord 442 | height = self._row_heights[self.resizing_id] 443 | height += diff 444 | height = max(10, height) 445 | self._row_heights[self.resizing_id] = height 446 | 447 | self._build_canvas_items() 448 | 449 | if self._resize_row_command: 450 | self._resize_row_command(self.resizing_id, height) 451 | 452 | self.resize_coord = None 453 | self.resizing_id = None 454 | 455 | def set_row_sizes(self, row_sizes): 456 | self._row_heights = [] 457 | if -1 in row_sizes: 458 | self._default_row_height = row_sizes[-1] 459 | 460 | for row in range(self._hex_rows): 461 | if row in row_sizes: 462 | self._row_heights.append(row_sizes[row]) 463 | else: 464 | self._row_heights.append(self._default_row_height) 465 | 466 | self._build_canvas_items() 467 | 468 | 469 | if __name__ == '__main__': 470 | root = tk.Tk() 471 | 472 | screen_width = root.winfo_screenwidth() 473 | screen_height = root.winfo_screenheight() 474 | geo_string = str(int(screen_width * 0.7)) + 'x' + str(int(screen_height * 0.7)) + \ 475 | '+' + str(int(screen_width * 0.15)) + '+' + str(int(screen_height * 0.15)) 476 | root.geometry(geo_string) 477 | 478 | hc = HexCells(root, hex_columns=20, hex_rows=20, relief=tk.SUNKEN, bd=2) 479 | hc.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) 480 | hc.set_cell_values({ 481 | (1, 2): 'Test', 482 | (2, 2): 1, 483 | (3, 4): 42 484 | }) 485 | 486 | root.mainloop() 487 | -------------------------------------------------------------------------------- /src/gui/windows/__init__.py: -------------------------------------------------------------------------------- 1 | from gui.windows.main_window import MainWindow 2 | -------------------------------------------------------------------------------- /src/gui/windows/main_window/__init__.py: -------------------------------------------------------------------------------- 1 | from tk_mvc import BaseWindow 2 | import tkinter as tk 3 | from tk_mvc import Event 4 | from gui.windows.main_window.top_area import TopArea 5 | from gui.windows.main_window.spreadsheet_area import SpreadsheetArea 6 | from gui.windows.main_window.menu import MainWindowMenu 7 | 8 | 9 | class MainWindow(BaseWindow): 10 | def __init__(self, view, window): 11 | super().__init__(view, window) 12 | 13 | self._view.add_observer('title', self.update_title) 14 | self._window.protocol("WM_DELETE_WINDOW", self._window.quit) 15 | 16 | self._window.geometry('800x600') 17 | self._window.rowconfigure(0, weight=1) 18 | self._window.columnconfigure(0, weight=1) 19 | self.grid(sticky='nsew') 20 | self.rowconfigure(1, weight=1) 21 | self.columnconfigure(0, weight=1) 22 | 23 | # Menu Bar 24 | self.menu = MainWindowMenu(self._view, self._window) 25 | 26 | # Top Area 27 | self.top_area = TopArea(self._view, self) 28 | self.top_area.grid(column=0, row=0, sticky='nsew') 29 | self._view.add_observer('formula_box', self.update_formula_box) 30 | 31 | # Spreadsheet Area 32 | self.spreadsheet_area = SpreadsheetArea(self._view, self) 33 | self.spreadsheet_area.grid(column=0, row=1, sticky='nsew') 34 | 35 | # Formula Box Commands 36 | self._formula_boxes = [ 37 | self.top_area.formula_box, 38 | self.spreadsheet_area.hidden_entry 39 | ] 40 | vcmd = (self.register(self._enter_formula), '%W', '%P') 41 | for box in self._formula_boxes: 42 | box.config(vcmd=vcmd) 43 | box.bind("", lambda e: e.widget.config(validate='key')) 44 | box.bind("", lambda e: e.widget.config(validate='none')) 45 | 46 | # Status Bar 47 | self.status_bar = tk.Label(self, relief=tk.GROOVE, anchor=tk.W) 48 | self.status_bar.grid(column=0, row=2, sticky=(tk.W, tk.E)) 49 | self._view.add_observer('status_bar', self.update_status_bar) 50 | 51 | def update_status_bar(self, text): 52 | self.status_bar.config(text=text) 53 | 54 | def update_title(self, text): 55 | self._window.title('HexSheets - ' + text) 56 | 57 | def _enter_formula(self, widget, new_text): 58 | for box in self._formula_boxes: 59 | if box != self._window.nametowidget(widget): 60 | box.delete(0, tk.END) 61 | box.insert(0, new_text) 62 | self._view.add_event(Event('FormulaChanged', {'formula': new_text})) 63 | 64 | return True 65 | 66 | def update_formula_box(self, text): 67 | for box in self._formula_boxes: 68 | validation = box.cget('validate') 69 | box.config(validate='none') 70 | box.delete(0, tk.END) 71 | box.insert(0, text) 72 | box.config(validate=validation) 73 | -------------------------------------------------------------------------------- /src/gui/windows/main_window/menu.py: -------------------------------------------------------------------------------- 1 | from tkinter import Menu 2 | import tkinter.filedialog as fd 3 | import webbrowser 4 | import os 5 | from tk_mvc import Event 6 | 7 | 8 | class MainWindowMenu: 9 | def __init__(self, view, window): 10 | self._view = view 11 | 12 | menu_bar = Menu(window) 13 | window.config(menu=menu_bar) 14 | 15 | # File Menu 16 | self.file_menu = Menu(menu_bar, tearoff=0) 17 | menu_bar.add_cascade(label="File", menu=self.file_menu) 18 | 19 | self.file_menu.add_command(label="New", command=self._new_file) 20 | self.file_menu.add_command(label="Open...", command=self._open_file) 21 | 22 | self._save_option_allowed = False 23 | self._view.add_observer('save_option', self.update_save_option) 24 | self.file_menu.add_command(label="Save", command=self._save_file) 25 | self.file_menu.add_command(label="Save As...", command=self._save_file_as) 26 | 27 | self.file_menu.add_separator() 28 | 29 | self.file_menu.add_command(label="Exit", command=window.quit) 30 | 31 | # Help Command 32 | menu_bar.add_command(label='Help', command=self._help) 33 | 34 | def _new_file(self): 35 | self._view.add_event(Event('NewFile')) 36 | 37 | def _open_file(self): 38 | open_file_name = fd.askopenfilename(filetypes=(('HexSheets', '*.hxs'), 39 | ('All Files', '*.*'))) 40 | self._view.add_event(Event('OpenFile', {'filename': open_file_name})) 41 | 42 | def update_save_option(self, option): 43 | self._save_option_allowed = option 44 | 45 | def _save_file(self): 46 | if self._save_option_allowed: 47 | self._view.add_event(Event('SaveFile')) 48 | else: 49 | self._save_file_as() 50 | 51 | def _save_file_as(self): 52 | save_file_name = fd.asksaveasfilename(defaultextension='hxs', 53 | filetypes=(('HexSheets', '*.hxs'), 54 | ('All Files', '*.*'))) 55 | self._view.add_event(Event('SaveFileAs', {'filename': save_file_name})) 56 | 57 | def _help(self): 58 | webbrowser.open_new(os.getcwd() + '/docs/index.html') 59 | -------------------------------------------------------------------------------- /src/gui/windows/main_window/spreadsheet_area.py: -------------------------------------------------------------------------------- 1 | from tk_mvc import WindowPart 2 | from tk_mvc import Event 3 | from gui.widgets.hex_cells import HexCells 4 | 5 | 6 | class SpreadsheetArea(WindowPart): 7 | def _build(self): 8 | spreadsheet = HexCells(self, 9 | hex_rows=20, hex_columns=20, 10 | select_command=self._select_cell, 11 | resize_row_command=self._resize_row, 12 | resize_column_command=self._resize_column 13 | ) 14 | spreadsheet.pack(fill='both', expand=True) 15 | self.hidden_entry = spreadsheet.hidden_entry 16 | 17 | self._view.add_observer('cell_values', spreadsheet.set_cell_values) 18 | self._view.add_observer('cell_formats', spreadsheet.set_cell_formats) 19 | self._view.add_observer('row_sizes', spreadsheet.set_row_sizes) 20 | self._view.add_observer('column_sizes', spreadsheet.set_column_sizes) 21 | 22 | def _select_cell(self, address): 23 | self._view.add_event(Event('CellSelected', {'address': address})) 24 | 25 | def _resize_row(self, row, height): 26 | self._view.add_event(Event('RowResized', { 27 | 'row': row, 28 | 'height': height 29 | })) 30 | 31 | def _resize_column(self, column, width): 32 | self._view.add_event(Event('ColumnResized', { 33 | 'column': column, 34 | 'width': width 35 | })) 36 | -------------------------------------------------------------------------------- /src/gui/windows/main_window/top_area.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tkinter.ttk as ttk 3 | import tkinter.font as tkf 4 | import tkinter.colorchooser as tkc 5 | from tk_mvc import WindowPart, Event 6 | 7 | class TopArea(WindowPart): 8 | def _build(self): 9 | self.columnconfigure(0, weight=1) 10 | 11 | # Tool Bar 12 | tool_bar = tk.Frame(self) 13 | tool_bar.grid(column=0, row=0, sticky='we') 14 | buttons = [ 15 | tk.Button(tool_bar, text='B', font=tkf.Font(weight='bold'), state=tk.DISABLED, 16 | command=lambda: self._view.add_event(Event('ToggleBold'))), 17 | tk.Button(tool_bar, text='I', font=tkf.Font(slant='italic'), state=tk.DISABLED, 18 | command=lambda: self._view.add_event(Event('ToggleItalic'))), 19 | tk.Button(tool_bar, text='U', font=tkf.Font(underline=1), state=tk.DISABLED, 20 | command=lambda: self._view.add_event(Event('ToggleUnderline'))) 21 | ] 22 | for button in buttons: 23 | button.pack(side=tk.LEFT, fill=tk.Y) 24 | 25 | self._font_size = tk.StringVar() 26 | self._font_size.set('14') 27 | self.font_trace = self._font_size.trace('w', self._change_font_size) 28 | font_size_options = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '32', '48', '72'] 29 | font_size_dropdown = ttk.Combobox(tool_bar, textvariable=self._font_size, values=font_size_options, 30 | justify=tk.RIGHT, width=3) 31 | font_size_dropdown.pack(side=tk.LEFT) 32 | self._view.add_observer('current_cell_font_size', self.update_current_cell_font_size) 33 | 34 | self._font_color_button = tk.Frame(tool_bar, bg='#EEE', bd=2, relief=tk.SUNKEN, width=32, height=32) 35 | self._font_color_button.pack_propagate(0) 36 | self._font_A = tk.Label(self._font_color_button, text='A', bg='#EEE') 37 | self._font_A.pack(expand=True, fill=tk.BOTH) 38 | self._font_color_button.bind('', self._click_font_color) 39 | self._font_A.bind('', self._click_font_color) 40 | self._font_color_button.pack(side=tk.LEFT) 41 | self._view.add_observer('current_cell_font_color', self.update_current_cell_font_color) 42 | 43 | self._cell_color_button = tk.Frame(tool_bar, bg='#EEE', bd=2, relief=tk.SUNKEN, width=32, height=32) 44 | self._cell_color_button.bind('', self._click_cell_color) 45 | self._cell_color_button.pack(side=tk.LEFT) 46 | self._view.add_observer('current_cell_color', self.update_current_cell_color) 47 | 48 | # Formula Bar 49 | formula_bar = tk.Frame(self) 50 | formula_bar.grid(column=0, row=1, sticky='we') 51 | tk.Label(formula_bar, text='Formula:').pack(side=tk.LEFT) 52 | 53 | self.formula_box = tk.Entry(formula_bar) 54 | self.formula_box.pack(fill=tk.X) 55 | 56 | def _click_cell_color(self, e): 57 | new_color = tkc.askcolor(title='Set Cell Color', color=e.widget.cget('color')) 58 | self._view.add_event(Event( 59 | 'SetCellColor', 60 | {'color': new_color[1]} 61 | )) 62 | 63 | def update_current_cell_color(self, color): 64 | self._cell_color_button.config(bg=color) 65 | 66 | def _click_font_color(self, e): 67 | new_color = tkc.askcolor(title='Set Cell Color', color=self._font_color_button.cget('color')) 68 | self._view.add_event(Event( 69 | 'SetFontColor', 70 | {'color': new_color[1]} 71 | )) 72 | 73 | def update_current_cell_font_color(self, color): 74 | self._font_A.config(fg=color) 75 | 76 | def _change_font_size(self, *args, **kwargs): 77 | self._view.add_event(Event('SetFontSize', data={ 78 | 'font_size': int(self._font_size.get()) 79 | })) 80 | 81 | def update_current_cell_font_size(self, size: int): 82 | self._font_size.trace_vdelete('w', self.font_trace) 83 | self._font_size.set(str(size)) 84 | self.font_trace = self._font_size.trace('w', self._change_font_size) -------------------------------------------------------------------------------- /src/hexsheets.py: -------------------------------------------------------------------------------- 1 | import controller 2 | 3 | app = controller.Controller() 4 | app.start() 5 | -------------------------------------------------------------------------------- /src/tk_mvc/__init__.py: -------------------------------------------------------------------------------- 1 | from tk_mvc.view import View 2 | from tk_mvc.window import BaseWindow 3 | from tk_mvc.window_part import WindowPart 4 | from tk_mvc.controller import BaseController 5 | from tk_mvc.event import Event 6 | -------------------------------------------------------------------------------- /src/tk_mvc/controller.py: -------------------------------------------------------------------------------- 1 | from .view import View 2 | from .event import Event 3 | from typing import Dict, Callable 4 | 5 | 6 | class BaseController: 7 | """ 8 | BaseController defines a relationship with View and provides an event handler loop. This class should be 9 | derived from before use. 10 | """ 11 | 12 | EVENT_HANDLER_INTERVAL = 100 13 | 14 | def __init__(self) -> None: 15 | self._view = View() 16 | 17 | self._event_handlers: Dict[str, Callable[[Event], None]] = {} 18 | self._view.add_loop_hook(self._handle_events, self.EVENT_HANDLER_INTERVAL) 19 | 20 | def start(self) -> None: 21 | self._view.start_mainloop() 22 | 23 | def _handle_events(self) -> None: 24 | """ 25 | Processes any events from _view. This function is added into _view's mainloop from __init__. 26 | """ 27 | for e in self._view.get_events(): 28 | if e.type in self._event_handlers: 29 | self._event_handlers[e.type](e) 30 | else: 31 | print('No handler registered for', e.type) 32 | 33 | def _add_event_handlers(self, event_handlers: Dict[str, Callable[[Event], None]]) -> None: 34 | for event, handler in event_handlers.items(): 35 | self._event_handlers[event] = handler 36 | -------------------------------------------------------------------------------- /src/tk_mvc/event.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | 4 | class Event: 5 | """ 6 | Basic event class. 7 | """ 8 | def __init__(self, event_type: str = 'GenericEvent', data: Optional[Dict[str, Any]] = None) -> None: 9 | self.type = event_type 10 | 11 | if data is None: 12 | self.data = {} 13 | else: 14 | self.data = data 15 | 16 | def __repr__(self) -> str: 17 | return ''.join(['Event<', self.type, '> ', str(self.data)]) 18 | -------------------------------------------------------------------------------- /src/tk_mvc/view.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from typing import List, Dict, Callable, Any, Tuple, Iterator, Type 3 | from .event import Event 4 | from .window import BaseWindow 5 | 6 | LoopHookCallback = Callable[[], None] 7 | ObserverCallback = Callable[[Any], None] 8 | 9 | 10 | class ViewError(Exception): 11 | pass 12 | 13 | 14 | class View: 15 | """ 16 | View is a concrete class that provides a layer between tkinter and a controller class. View owns the tk root window 17 | and starts its mainloop. 18 | 19 | Custom window classes are passed to View to be created and then shown/hidden as directed. 20 | 21 | Windows can trigger Events through View that will be handled by a controller class. The controller class hooks into 22 | the program loop through add_loop_hook. Windows also add observers to View which are triggered when a controller 23 | updates those values. 24 | """ 25 | 26 | def __init__(self) -> None: 27 | self._tk_root = tk.Tk() 28 | self._tk_root.overrideredirect(1) 29 | self._tk_root.withdraw() 30 | 31 | self._observers: Dict[str, ObserverCallback] = {} 32 | self._events: List[Event] = [] 33 | self._windows: Dict[str, tk.Toplevel] = {} 34 | self._loop_hooks: List[Tuple[LoopHookCallback, int]] = [] 35 | 36 | def start_mainloop(self) -> None: 37 | for func, interval in self._loop_hooks: 38 | self._tk_root.after(interval, lambda: self._run_loop_hook(func, interval)) 39 | self._tk_root.mainloop() 40 | 41 | def add_loop_hook(self, func: LoopHookCallback, interval: int) -> None: 42 | self._loop_hooks.append((func, interval)) 43 | 44 | def _run_loop_hook(self, func: LoopHookCallback, interval: int) -> None: 45 | func() 46 | self._tk_root.after(interval, lambda: self._run_loop_hook(func, interval)) 47 | 48 | def add_observer(self, name: str, callback: ObserverCallback) -> None: 49 | self._observers[name] = callback 50 | 51 | def set_value(self, name: str, value: Any) -> None: 52 | self._observers[name](value) 53 | 54 | def add_event(self, event: Event) -> None: 55 | self._events.append(event) 56 | 57 | def get_events(self, n: int = 0) -> Iterator[Event]: 58 | """ 59 | Yields the next n events. If n == 0, yields all events. 60 | """ 61 | if n == 0: 62 | n = len(self._events) 63 | for i in range(n): 64 | if len(self._events) > 0: 65 | yield self._events.pop(0) 66 | else: 67 | yield None 68 | 69 | def add_window(self, window_name: str, window_cls: Type[BaseWindow], *args, **kwargs) -> None: 70 | if window_name not in self._windows: 71 | new_window = tk.Toplevel(self._tk_root) 72 | new_window.withdraw() 73 | window_cls(self, new_window, *args, **kwargs) 74 | self._windows[window_name] = new_window 75 | else: 76 | raise ViewError('Window name already exists.') 77 | 78 | def show_window(self, window_name: str) -> None: 79 | if window_name in self._windows: 80 | self._windows[window_name].deiconify() 81 | 82 | def hide_window(self, window_name: str) -> None: 83 | if window_name in self._windows: 84 | self._windows[window_name].withdraw() 85 | -------------------------------------------------------------------------------- /src/tk_mvc/window.py: -------------------------------------------------------------------------------- 1 | from tkinter import Frame 2 | from tkinter import Toplevel 3 | 4 | 5 | class BaseWindow(Frame): 6 | """ 7 | All windows in tk_mvc derive from BaseWindow. It is simply a Frame that View will place inside a TopLevel 8 | upon creation. 9 | """ 10 | 11 | def __init__(self, view, parent_toplevel: Toplevel, *args, **kwargs) -> None: 12 | super().__init__(parent_toplevel) 13 | self._view = view 14 | self._window = parent_toplevel 15 | -------------------------------------------------------------------------------- /src/tk_mvc/window_part.py: -------------------------------------------------------------------------------- 1 | from tkinter import Frame 2 | from tk_mvc import View 3 | 4 | class WindowPart(Frame): 5 | """ 6 | The WindowPart class is a helper for splitting up windows into multiple files. Derived classes should override the 7 | _build method, and the Window in question can add the WindowPart the same way they would a frame (with the exception 8 | that a View must passed to it). 9 | """ 10 | 11 | def __init__(self, view: View, *args, **kwargs) -> None: 12 | super().__init__(*args, **kwargs) 13 | self._view = view 14 | self._build() 15 | 16 | def _build(self): 17 | """ 18 | Any widgets that need to be created as part of the WindowPart should be added in the derived class's _build. 19 | """ 20 | pass 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pladams9/hexsheets/e46f21f080a0c864f3a2c88dc1aafa59e7060686/tests/__init__.py -------------------------------------------------------------------------------- /tests/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pladams9/hexsheets/e46f21f080a0c864f3a2c88dc1aafa59e7060686/tests/model/__init__.py -------------------------------------------------------------------------------- /tests/model/test_formula_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import core.formula_parser as fp 3 | 4 | 5 | class TestFormulaParser(unittest.TestCase): 6 | def setUp(self): 7 | self.cells_a = { 8 | (1, 1): 'Test', 9 | (1, 3): 24, 10 | (2, 4): '=1+1', 11 | (3, 1): '=[1, 3] - 2', 12 | (2, 2): '=[10, 10]', 13 | (4, 4): '=[5,5]', 14 | (5, 5): '=[4,4]' 15 | } 16 | self.cells_b = { 17 | (1, 1): 'TEST_2', 18 | (5, 6): -5 19 | } 20 | self.cells_a_plus_b = { 21 | (1, 1): 'TEST_2', 22 | (1, 3): 24, 23 | (2, 4): '=1+1', 24 | (3, 1): '=[1, 3] - 2', 25 | (5, 6): -5, 26 | (2, 2): '=[10, 10]', 27 | (4, 4): '=[5,5]', 28 | (5, 5): '=[4,4]' 29 | } 30 | self.cells_a_values = { 31 | (1, 1): 'Test', 32 | (1, 3): 24, 33 | (2, 4): 2, 34 | (3, 1): 22, 35 | (2, 2): '', 36 | (4, 4): '#CIRCULAR', 37 | (5, 5): '#CIRCULAR' 38 | } 39 | self.parser = fp.FormulaParser() 40 | 41 | def test_update_nodes(self): 42 | self.parser.update_nodes(self.cells_a) 43 | self.assertEqual(self.parser._nodes.keys(), self.cells_a.keys()) 44 | for cell in self.cells_a: 45 | self.assertEqual(self.parser._nodes[cell], str(self.cells_a[cell])) 46 | 47 | self.parser.update_nodes(self.cells_b) 48 | self.assertEqual(self.parser._nodes.keys(), self.cells_a_plus_b.keys()) 49 | for cell in self.cells_a_plus_b: 50 | self.assertEqual(self.parser._nodes[cell], str(self.cells_a_plus_b[cell])) 51 | 52 | def test_clear_nodes(self): 53 | self.parser.update_nodes(self.cells_a) 54 | self.parser.clear_nodes() 55 | self.assertEqual(self.parser._nodes, {}) 56 | 57 | def test_get_node_value(self): 58 | self.parser.update_nodes(self.cells_a) 59 | for cell in self.cells_a: 60 | with self.subTest(cell=cell, formula=self.cells_a[cell]): 61 | self.assertEqual(self.parser.get_node_value(cell), self.cells_a_values[cell]) 62 | 63 | def test_cast_value(self): 64 | values = { 65 | 'test': 'test', 66 | '1': int(1), 67 | '1.234': float(1.234), 68 | '0': int(0), 69 | '1.0': float(1.0), 70 | '-0': int(0), 71 | '-1.678': float(-1.678), 72 | 'A123': 'A123', 73 | '12.34.56': '12.34.56' 74 | } 75 | for value in values: 76 | with self.subTest(string_value=value, type=type(values[value])): 77 | self.assertEqual(self.parser._cast_value(value), values[value]) 78 | self.assertIsInstance(self.parser._cast_value(value), type(values[value])) 79 | 80 | def test_tokenize(self): 81 | strings = { 82 | '': [], 83 | 'abc': [('VALUE', 'abc')], 84 | '1 + 2': [('VALUE', '1'), ('OPERATOR', '+'), ('VALUE', '2')], 85 | '1+2': [('VALUE', '1'), ('OPERATOR', '+'), ('VALUE', '2')], 86 | '1 + 2 + 3': [('VALUE', '1'), ('OPERATOR', '+'), ('VALUE', '2'), ('OPERATOR', '+'), ('VALUE', '3')], 87 | '2.5 - 1': [('VALUE', '2.5'), ('OPERATOR', '-'), ('VALUE', '1')], 88 | '2 + -1': [('VALUE', '2'), ('OPERATOR', '+'), ('OPERATOR', '-'), ('VALUE', '1')], 89 | '"1 + 2"': [('BRACKET', '"', '1 + 2')], 90 | '(1 * 2)': [('BRACKET', '(', '1 * 2')], 91 | '[1, 2]': [('BRACKET', '[', '1, 2')], 92 | '1* 2': [('VALUE', '1'), ('OPERATOR', '*'), ('VALUE', '2')], 93 | '1 /2': [('VALUE', '1'), ('OPERATOR', '/'), ('VALUE', '2')] 94 | } 95 | for string in strings: 96 | with self.subTest(string=string): 97 | self.assertEqual(self.parser._tokenize(string), strings[string]) 98 | 99 | def test_parse_formula(self): 100 | self.parser.update_nodes(self.cells_a) 101 | formulas = { 102 | '1+1': 2, 103 | '1 + 1': 2, 104 | '[1,3]': 24, 105 | '[1, 3]': 24, 106 | '+ 10': '#ERROR', 107 | '"Cool" + "Neat"': 'CoolNeat', 108 | '1 + "Test"': '1Test', 109 | '"Cool" + 2': 'Cool2', 110 | '1 / "Test"': '#ERROR', 111 | 'Text + 1': 'Text1', 112 | '"T+xt" + 1': 'T+xt1' 113 | } 114 | for formula in formulas: 115 | with self.subTest(formula=formula): 116 | self.assertEqual(self.parser._parse_formula(formula), formulas[formula]) 117 | 118 | def test_parse_address(self): 119 | addresses = { 120 | '1,1': (1, 1), 121 | '1, 1': (1, 1), 122 | '-1, -1': (-1, -1), 123 | '1, -5': (-1, -1), 124 | '1.5, 1': (-1, -1), 125 | '1 + 1, 1': (2, 1), 126 | '1': (-1, -1), 127 | '1, 1, 1': (-1, -1), 128 | '1, ': (-1, -1) 129 | } 130 | for address in addresses: 131 | with self.subTest(address=address): 132 | self.assertEqual(self.parser._parse_address(address), addresses[address]) 133 | 134 | 135 | if __name__ == '__main__': 136 | unittest.main() 137 | --------------------------------------------------------------------------------