├── tests ├── __init__.py ├── test_SevenSegment.py ├── test_MultiSlotFrame.py ├── test_SmartCheckbutton.py ├── test_LabelGrid.py ├── test_basic.py ├── test_ButtonGrid.py ├── test_KeyValueEntry.py ├── test_SmartOptionMenu.py ├── test_EntryGrid.py └── test_BinaryLabel.py ├── examples ├── __init__.py ├── img │ ├── led.gif │ ├── graph.png │ ├── calendar.png │ ├── dropdown.png │ ├── key-value.png │ ├── button-grid.png │ ├── byte-label.png │ ├── entry-grid.png │ ├── label-grid.png │ ├── rotary-scale.png │ └── smartlistbox.png ├── smart_checkbutton.py ├── multislotframe.py ├── tooltip.py ├── user_calendar.py ├── smart_optionmenu.py ├── binary_label.py ├── smart_listbox.py ├── smart_spinbox.py ├── label_grid.py ├── key_value_test.py ├── seven_segment.py ├── button_grid.py ├── graph.py ├── button_grid_rowlabels.py ├── key_value.py ├── leds.py ├── entry_grid.py ├── rotary_scale.py └── gauge.py ├── tk_tools ├── version.py ├── __init__.py ├── tooltips.py ├── widgets.py ├── canvas.py └── groups.py ├── docs ├── img │ ├── led.gif │ ├── gauges.png │ ├── graph.png │ ├── calendar.png │ ├── dropdown.png │ ├── gaugedoc.png │ ├── button-grid.png │ ├── byte-label.png │ ├── entry-grid.png │ ├── key-value.png │ ├── label-grid.png │ ├── rotary-scale.png │ ├── multi-slot-frame.png │ └── seven-segment-display.png ├── readme.md ├── tooltips.rst ├── canvas_widgets.rst ├── installation.rst ├── smart_widgets.rst ├── widget_groups.rst ├── index.rst └── conf.py ├── images ├── minus.png ├── led-grey.png ├── led-red.png ├── led-green.png ├── led-red-on.png ├── led-yellow.png ├── led-green-on.png ├── led-yellow-on.png ├── rotary-scale.png ├── rotary-gauge-bg.png ├── rotary-gauge-bar.png └── rotary-gauge-volt.png ├── .directory ├── .gitignore ├── .github └── workflows │ ├── unittest.yml │ ├── publishTest.yml │ └── publish.yml ├── pyproject.toml └── readme.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tk_tools/version.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/img/led.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/led.gif -------------------------------------------------------------------------------- /images/minus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/minus.png -------------------------------------------------------------------------------- /docs/img/gauges.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/gauges.png -------------------------------------------------------------------------------- /docs/img/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/graph.png -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | To build the documentation: 2 | 3 | sphinx-build -b html . ./html 4 | 5 | -------------------------------------------------------------------------------- /images/led-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-grey.png -------------------------------------------------------------------------------- /images/led-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-red.png -------------------------------------------------------------------------------- /docs/img/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/calendar.png -------------------------------------------------------------------------------- /docs/img/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/dropdown.png -------------------------------------------------------------------------------- /docs/img/gaugedoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/gaugedoc.png -------------------------------------------------------------------------------- /examples/img/led.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/led.gif -------------------------------------------------------------------------------- /images/led-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-green.png -------------------------------------------------------------------------------- /images/led-red-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-red-on.png -------------------------------------------------------------------------------- /images/led-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-yellow.png -------------------------------------------------------------------------------- /docs/img/button-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/button-grid.png -------------------------------------------------------------------------------- /docs/img/byte-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/byte-label.png -------------------------------------------------------------------------------- /docs/img/entry-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/entry-grid.png -------------------------------------------------------------------------------- /docs/img/key-value.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/key-value.png -------------------------------------------------------------------------------- /docs/img/label-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/label-grid.png -------------------------------------------------------------------------------- /examples/img/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/graph.png -------------------------------------------------------------------------------- /images/led-green-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-green-on.png -------------------------------------------------------------------------------- /images/led-yellow-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/led-yellow-on.png -------------------------------------------------------------------------------- /images/rotary-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/rotary-scale.png -------------------------------------------------------------------------------- /docs/img/rotary-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/rotary-scale.png -------------------------------------------------------------------------------- /examples/img/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/calendar.png -------------------------------------------------------------------------------- /examples/img/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/dropdown.png -------------------------------------------------------------------------------- /examples/img/key-value.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/key-value.png -------------------------------------------------------------------------------- /images/rotary-gauge-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/rotary-gauge-bg.png -------------------------------------------------------------------------------- /.directory: -------------------------------------------------------------------------------- 1 | [Dolphin] 2 | SortRole=modificationtime 3 | Timestamp=2018,6,9,11,51,23 4 | Version=4 5 | ViewMode=1 6 | -------------------------------------------------------------------------------- /docs/img/multi-slot-frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/multi-slot-frame.png -------------------------------------------------------------------------------- /examples/img/button-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/button-grid.png -------------------------------------------------------------------------------- /examples/img/byte-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/byte-label.png -------------------------------------------------------------------------------- /examples/img/entry-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/entry-grid.png -------------------------------------------------------------------------------- /examples/img/label-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/label-grid.png -------------------------------------------------------------------------------- /examples/img/rotary-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/rotary-scale.png -------------------------------------------------------------------------------- /examples/img/smartlistbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/examples/img/smartlistbox.png -------------------------------------------------------------------------------- /images/rotary-gauge-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/rotary-gauge-bar.png -------------------------------------------------------------------------------- /images/rotary-gauge-volt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/images/rotary-gauge-volt.png -------------------------------------------------------------------------------- /docs/img/seven-segment-display.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slightlynybbled/tk_tools/HEAD/docs/img/seven-segment-display.png -------------------------------------------------------------------------------- /docs/tooltips.rst: -------------------------------------------------------------------------------- 1 | Tool Tips 2 | ============== 3 | 4 | ``ToolTip`` 5 | ----------- 6 | 7 | .. autoclass:: tooltips.ToolTip 8 | :members: 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea/ 3 | /build 4 | /dist 5 | /tk_tools.egg-info 6 | .vscode/ 7 | __pycache__/ 8 | venv/ 9 | -------------------------------------------------------------------------------- /tests/test_SevenSegment.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import SevenSegmentDigits 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def ssd(root): 12 | ssd_widget = SevenSegmentDigits(root, digits=3) 13 | ssd_widget.grid() 14 | 15 | yield ssd_widget 16 | 17 | 18 | def test_creation(root): 19 | SevenSegmentDigits(root) 20 | 21 | 22 | def test_set_value_str(ssd): 23 | ssd.set_value('98.7') 24 | -------------------------------------------------------------------------------- /examples/smart_checkbutton.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | if __name__ == '__main__': 6 | root = tk.Tk() 7 | 8 | tk.Label(root, text="The variable value: ").grid(row=0, column=0) 9 | value_label = tk.Label(root, text="") 10 | value_label.grid(row=0, column=1) 11 | 12 | def callback(value): 13 | value_label.config(text=str(value)) 14 | 15 | scb = tk_tools.SmartCheckbutton(root, callback=callback) 16 | scb.grid() 17 | 18 | root.mainloop() 19 | -------------------------------------------------------------------------------- /examples/multislotframe.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | root = tk.Tk() 5 | 6 | msf = tk_tools.MultiSlotFrame(root) 7 | msf.grid(row=0, column=0, sticky='news') 8 | 9 | count = 0 10 | 11 | 12 | def add_element(): 13 | global count 14 | msf.add(count) 15 | count += 1 16 | 17 | 18 | def show_elements(): 19 | print(msf.get()) 20 | 21 | 22 | tk.Button(root, text='add element', command=add_element)\ 23 | .grid(row=1, column=0, sticky='news') 24 | 25 | tk.Button(root, text='retrieve elements', command=show_elements)\ 26 | .grid(row=2, column=0, sticky='news') 27 | 28 | root.mainloop() 29 | -------------------------------------------------------------------------------- /examples/tooltip.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | root = tk.Tk() 5 | 6 | tk.Label(root, text='Hover over the labels and controls in order to raise a tool tip').grid(row=0, column=0) 7 | 8 | entry = tk.Entry(root) 9 | entry.grid(row=1, column=0, sticky='news') 10 | tk_tools.ToolTip(entry, 'enter some data here') 11 | 12 | 13 | def btn_press(): 14 | print(entry.get()) 15 | entry.delete(0, 'end') 16 | 17 | 18 | button = tk.Button(root, text='the button', command=btn_press) 19 | button.grid(row=2, column=0, sticky='news') 20 | tk_tools.ToolTip(button, 'press the button!') 21 | 22 | root.mainloop() 23 | -------------------------------------------------------------------------------- /examples/user_calendar.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import tkinter 4 | import tk_tools 5 | import locale 6 | 7 | # use this as a flag to change the language of the 8 | # calendar using the locale, as shown below 9 | show_in_german = False 10 | 11 | 12 | def callback(): 13 | print(calendar.selection) 14 | 15 | 16 | if __name__ == '__main__': 17 | 18 | root = tkinter.Tk() 19 | root.title('TK Tools Calendar') 20 | 21 | if show_in_german: 22 | locale.setlocale(locale.LC_ALL, 'deu_deu') 23 | 24 | calendar = tk_tools.Calendar(root, year=2021, month=2, day=5) 25 | calendar.pack() 26 | 27 | calendar.add_callback(callback) 28 | 29 | root.mainloop() 30 | -------------------------------------------------------------------------------- /tests/test_MultiSlotFrame.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import MultiSlotFrame 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def msf(root): 12 | msf_widget = MultiSlotFrame(root) 13 | msf_widget.grid() 14 | 15 | msf_widget.add('test 1') 16 | msf_widget.add('test 2') 17 | msf_widget.add('test 3') 18 | 19 | yield msf_widget 20 | 21 | 22 | def test_creation(root): 23 | MultiSlotFrame(root) 24 | 25 | 26 | def test_clear(msf): 27 | msf.clear() 28 | 29 | 30 | def test_retrieve_values(msf): 31 | t1, t2, t3 = msf.get() 32 | 33 | assert t1 == 'test 1' 34 | assert t2 == 'test 2' 35 | assert t3 == 'test 3' 36 | -------------------------------------------------------------------------------- /docs/canvas_widgets.rst: -------------------------------------------------------------------------------- 1 | Canvas Widgets 2 | ============== 3 | 4 | These widgets provide visual feedback to the user using the canvas. 5 | 6 | ``RotaryScale`` 7 | --------------- 8 | 9 | .. image:: img/rotary-scale.png 10 | 11 | .. autoclass:: canvas.RotaryScale 12 | :members: 13 | 14 | ``Gauge`` 15 | --------- 16 | 17 | .. image:: img/gauges.png 18 | 19 | .. image:: img/gaugedoc.png 20 | 21 | .. autoclass:: canvas.Gauge 22 | :members: 23 | 24 | ``Graph`` 25 | --------- 26 | 27 | .. image:: img/graph.png 28 | 29 | .. autoclass:: canvas.Graph 30 | :members: 31 | 32 | ``LED`` 33 | --------- 34 | 35 | .. image:: img/led.gif 36 | :height: 50px 37 | :width: 50px 38 | 39 | .. autoclass:: canvas.Led 40 | :members: 41 | -------------------------------------------------------------------------------- /examples/smart_optionmenu.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | # this callback doesn't necessarily have to take the 'value', but it is considered good practice 6 | def callback(value): 7 | print(value) 8 | 9 | 10 | if __name__ == '__main__': 11 | root = tk.Tk() 12 | 13 | tk.Label(root, text="The variable value: ").grid(row=1, column=0, sticky='ew') 14 | value_label = tk.Label(root, text="") 15 | value_label.grid(row=1, column=1, sticky='ew') 16 | 17 | def callback(value): 18 | value_label.config(text=str(value)) 19 | 20 | tk.Label(root, text="Select a value: ").grid(row=0, column=0, sticky='ew') 21 | drop_down = tk_tools.SmartOptionMenu(root, ['one', 'two', 'three'], callback=callback) 22 | drop_down.grid(row=0, column=1, sticky='ew') 23 | 24 | root.mainloop() 25 | -------------------------------------------------------------------------------- /examples/binary_label.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | if __name__ == '__main__': 6 | 7 | root = tk.Tk() 8 | 9 | value = 32766 10 | blabel1 = tk_tools.BinaryLabel(root, value, "d1:", 16, font="Consolas 12") 11 | blabel1.grid(row=0, column=0) 12 | 13 | e = tk.Entry(root, width=10) 14 | e.insert("end", str(value)) 15 | e.grid(row=1, column=0) 16 | 17 | btn_set = tk.Button(root, text="Set", 18 | command=lambda: blabel1.set(int(e.get()))) 19 | btn_set.grid(row=1, column=1) 20 | 21 | btn_tmsb = tk.Button(root, text="Toggle MSB", command=blabel1.toggle_msb) 22 | btn_tmsb.grid(row=1, column=2) 23 | 24 | btn_tlsb = tk.Button(root, text="Toggle LSB", command=blabel1.toggle_lsb) 25 | btn_tlsb.grid(row=1, column=3) 26 | 27 | root.mainloop() 28 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ['3.11', '3.12', '3.12'] 9 | 10 | timeout-minutes: 10 11 | 12 | steps: 13 | - name: Install xvfb 14 | run: sudo apt-get install -y xvfb 15 | 16 | - name: Check out repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v3 26 | 27 | - name: Create venv 28 | run: uv venv 29 | 30 | - name: Install tk_tools 31 | run: uv pip install -e . 32 | 33 | - name: Execute Tests 34 | run: xvfb-run -a uv run pytest -v tests/ --cov=tk_tools 35 | -------------------------------------------------------------------------------- /examples/smart_listbox.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | if __name__ == '__main__': 6 | root = tk.Tk() 7 | 8 | tk.Label(root, text="The variable value: ").grid(row=0, column=0) 9 | value_label = tk.Label(root, text="") 10 | value_label.grid(row=0, column=1) 11 | 12 | def callback(values): 13 | string = ''.join(values) 14 | value_label.config(text=string) 15 | 16 | tk.Label(root, text='selectmode="browse"').grid(row=1, column=0) 17 | tk_tools.SmartListBox(root, on_select_callback=callback, selectmode='browse', 18 | options=['1', '2', '3']).grid(row=2, column=0) 19 | 20 | tk.Label(root, text='selectmode="multiple"').grid(row=1, column=0) 21 | tk_tools.SmartListBox(root, on_select_callback=callback, selectmode='multiple', 22 | options=['a', 'b', 'c']).grid(row=4, column=0) 23 | root.mainloop() 24 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Pip 5 | --- 6 | 7 | To install, simply ``pip install tk_tools``. All images and other source material are included as packages within python, so you shouldn't have to do any funky workarounds even when using this package in pyinstaller or other static execution environments. Some environments may require some basic modification to this, such as the use of `pip3` instead of `pip`. 8 | 9 | Setup.py 10 | -------- 11 | 12 | Clone the git repository, navigate to the cloned directory, and ``python3 setup.py install``. 13 | 14 | Dependencies 15 | ------------ 16 | 17 | The tk_tools package is written with Python 3.5+ in mind! It uses type hints so that your IDE - such as PyCharm - can easily identify potential issues with your code as you write it. If you want this to support a different python version, create an issue and I'm sure that we can work something out easily enough. 18 | -------------------------------------------------------------------------------- /examples/smart_spinbox.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | if __name__ == '__main__': 6 | root = tk.Tk() 7 | 8 | tk.Label(root, text="The variable value: ").grid(row=0, column=0) 9 | value_label = tk.Label(root, text="") 10 | value_label.grid(row=0, column=1) 11 | 12 | def callback(value): 13 | value_label.config(text=str(value)) 14 | 15 | # specify a callback, then specify the normal spinbox options (such as "from_", "to", and "increment" 16 | tk_tools.SmartSpinBox(root, callback=callback, entry_type='int', 17 | from_=0, to=3).grid(row=1, column=0) 18 | tk_tools.SmartSpinBox(root, callback=callback, entry_type='float', 19 | from_=-2.5, to=3.0, increment=0.1).grid(row=2, column=0) 20 | tk_tools.SmartSpinBox(root, callback=callback, entry_type='str', 21 | values=('a', 'b', 'c')).grid(row=3, column=0) 22 | root.mainloop() 23 | -------------------------------------------------------------------------------- /examples/label_grid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | from random import randint 5 | 6 | 7 | def add_row(): 8 | row = [randint(0, 10) for _ in range(3)] 9 | label_grid.add_row(row) 10 | 11 | 12 | if __name__ == '__main__': 13 | 14 | root = tk.Tk() 15 | 16 | add_row_btn = tk.Button(text='Add Row', command=add_row) 17 | add_row_btn.grid(row=0, column=0, columnspan=2, sticky='ew') 18 | 19 | remove_row_btn = tk.Button(text='Remove Row') 20 | remove_row_btn.grid(row=1, column=0, sticky='ew') 21 | 22 | row_to_remove_entry = tk.Entry(root) 23 | row_to_remove_entry.grid(row=1, column=1, sticky='ew') 24 | row_to_remove_entry.insert(0, '0') 25 | 26 | remove_row_btn.config(command=lambda: label_grid.remove_row(int(row_to_remove_entry.get()))) 27 | 28 | label_grid = tk_tools.LabelGrid(root, 3, ['Column0', 'Column1', 'Column2']) 29 | label_grid.grid(row=2, column=0, columnspan=2, sticky='ew') 30 | 31 | root.mainloop() 32 | -------------------------------------------------------------------------------- /examples/key_value_test.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | if __name__ == '__main__': 5 | root = tk.Tk() 6 | 7 | key_value = tk_tools.KeyValueEntry( 8 | root, ['key0', 'key1', 'key2'], 9 | title='help', 10 | unit_labels=['one', 'two', 'three'], 11 | defaults=['', 'two', 'three'], 12 | enables=[True, False, True], 13 | on_change_callback=lambda: print('works') 14 | ) 15 | 16 | key_value.pack() 17 | 18 | key_value.add_row('key3') 19 | key_value.add_row('key4', enable=False) 20 | key_value.add_row('key5', unit_label='five') 21 | 22 | 23 | def load_value(): 24 | key_value.load( 25 | { 26 | 'key0': '1', 27 | 'key1': '2', 28 | 'key2': '3', 29 | 'key3': '4', 30 | 'key4': '5', 31 | 'key5': '6' 32 | } 33 | ) 34 | 35 | root.after(1000, load_value) 36 | root.mainloop() 37 | -------------------------------------------------------------------------------- /examples/seven_segment.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | from decimal import Decimal 4 | 5 | 6 | root = tk.Tk() 7 | 8 | max_speed = 20.0 9 | 10 | ss_float = tk_tools.SevenSegmentDigits(root, digits=5) 11 | ss_float.grid(row=0, column=1, sticky='news') 12 | 13 | ss_int = tk_tools.SevenSegmentDigits(root, digits=3, background='black', digit_color='red') 14 | ss_int.grid(row=1, column=1, sticky='news') 15 | 16 | count = 0 17 | up = True 18 | 19 | 20 | def update_gauge(): 21 | global count, up 22 | 23 | if up: 24 | count += 0.1 25 | if count > max_speed: 26 | up = False 27 | else: 28 | count -= 0.1 29 | 30 | if count < -1.0: 31 | up = True 32 | 33 | decimal_count_float = str(Decimal(count).quantize(Decimal('0.10'))) 34 | decimal_count_int = str(Decimal(count).quantize(Decimal('1'))) 35 | 36 | ss_float.set_value(decimal_count_float) 37 | ss_int.set_value(decimal_count_int) 38 | 39 | root.after(100, update_gauge) 40 | 41 | 42 | root.after(100, update_gauge) 43 | 44 | root.mainloop() 45 | -------------------------------------------------------------------------------- /tests/test_SmartCheckbutton.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import SmartCheckbutton 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def scb(root): 12 | scb_widget = SmartCheckbutton(root) 13 | scb_widget.grid() 14 | 15 | yield scb_widget 16 | 17 | 18 | def test_creation(root): 19 | SmartCheckbutton(root) 20 | 21 | 22 | def test_callback(root): 23 | item_value = 0 24 | 25 | def callback(): 26 | nonlocal item_value 27 | item_value += 1 28 | 29 | scb = SmartCheckbutton(root, callback=callback) 30 | scb.grid() 31 | 32 | scb.set(True) 33 | scb.set(False) 34 | scb.set(True) 35 | 36 | assert item_value == 3 37 | 38 | 39 | def test_callback_with_parameter(root): 40 | item_value = 0 41 | 42 | def callback(value): 43 | nonlocal item_value 44 | item_value += 2 if value else 0 45 | 46 | scb = SmartCheckbutton(root, callback=callback) 47 | scb.grid() 48 | 49 | scb.set(True) 50 | scb.set(False) 51 | scb.set(True) 52 | 53 | assert item_value == 4 54 | 55 | -------------------------------------------------------------------------------- /tests/test_LabelGrid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import LabelGrid 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def label_grid_3col(root): 12 | lg = LabelGrid(root, 3, headers=['a', 'b', 'c']) 13 | yield lg 14 | 15 | lg._redraw() 16 | 17 | 18 | def test_creation_with_header(root): 19 | LabelGrid(root, 3, headers=['a', 'b', 'c']) 20 | 21 | 22 | def test_header_len_doesnt_match_cols(root): 23 | with pytest.raises(ValueError): 24 | LabelGrid(root, 2, headers=['a', 'b', 'c']) 25 | 26 | 27 | def test_add_row(label_grid_3col): 28 | data = ['1', '2', '3'] 29 | label_grid_3col.add_row(data) 30 | 31 | 32 | def test_add_row_len_doesnt_match_cols(label_grid_3col): 33 | data = ['1', '2', '3', '4'] 34 | 35 | with pytest.raises(ValueError): 36 | label_grid_3col.add_row(data) 37 | 38 | 39 | def test_remove_row(label_grid_3col): 40 | label_grid_3col.add_row(['1', '2', '3']) 41 | label_grid_3col.add_row(['4', '5', '6']) 42 | label_grid_3col.add_row(['7', '8', '9']) 43 | 44 | label_grid_3col.remove_row(1) 45 | 46 | -------------------------------------------------------------------------------- /tk_tools/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from tk_tools.canvas import Gauge, Graph, Led, RotaryScale 4 | 5 | from tk_tools.groups import ( 6 | ButtonGrid, 7 | Calendar, 8 | EntryGrid, 9 | KeyValueEntry, 10 | LabelGrid, 11 | MultiSlotFrame, 12 | SevenSegment, 13 | SevenSegmentDigits, 14 | ) 15 | 16 | from tk_tools.widgets import ( 17 | BinaryLabel, 18 | ByteLabel, 19 | ScrollableFrame, 20 | SmartCheckbutton, 21 | SmartListBox, 22 | SmartOptionMenu, 23 | SmartSpinBox, 24 | ) 25 | 26 | from tk_tools.tooltips import ToolTip 27 | 28 | __version__ = importlib.metadata.version("tk_tools") 29 | 30 | 31 | __all__ = [ 32 | "BinaryLabel", 33 | "ButtonGrid", 34 | "Calendar", 35 | "ByteLabel", 36 | "EntryGrid", 37 | "Gauge", 38 | "Graph", 39 | "KeyValueEntry", 40 | "LabelGrid", 41 | "Led", 42 | "MultiSlotFrame", 43 | "RotaryScale", 44 | "ScrollableFrame", 45 | "SmartCheckbutton", 46 | "SmartListBox", 47 | "SmartOptionMenu", 48 | "SmartSpinBox", 49 | "SevenSegment", 50 | "SevenSegmentDigits", 51 | "ToolTip", 52 | "__version__", 53 | ] 54 | -------------------------------------------------------------------------------- /docs/smart_widgets.rst: -------------------------------------------------------------------------------- 1 | Smart Widgets 2 | ============= 3 | 4 | Smart widgets consist of existing widgets with improved API. In most cases, these widgets will simply incorporate the appropriate type of ``xVar`` for the widget type. For instance, imaging providing for an ``OptionMenu`` without having to use a ``StringVar``. These widgets generally appear the same as their ordinary counterparts that are already present within the library. 5 | 6 | ``SmartOptionMenu`` 7 | ------------------- 8 | 9 | .. autoclass:: widgets.SmartOptionMenu 10 | :members: 11 | 12 | ``SmartSpinBox`` 13 | ---------------- 14 | 15 | .. autoclass:: widgets.SmartSpinBox 16 | :members: 17 | 18 | ``SmartCheckbutton`` 19 | -------------------- 20 | 21 | .. autoclass:: widgets.SmartCheckbutton 22 | :members: 23 | 24 | ``SmartListBox`` 25 | -------------------- 26 | 27 | .. autoclass:: widgets.SmartListBox 28 | :members: 29 | 30 | ``BinaryLabel`` 31 | -------------------- 32 | 33 | .. image:: img/byte-label.png 34 | 35 | .. autoclass:: widgets.BinaryLabel 36 | :members: 37 | 38 | ``ByteLabel`` 39 | ------------- 40 | 41 | .. image:: img/byte-label.png 42 | 43 | .. autoclass:: widgets.ByteLabel 44 | :members: 45 | -------------------------------------------------------------------------------- /examples/button_grid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | import random 4 | import string 5 | 6 | 7 | def random_letters(): 8 | sal = string.ascii_letters 9 | return (random.choice(sal) 10 | + random.choice(sal) 11 | + random.choice(sal)) 12 | 13 | 14 | def add_row_random(): 15 | r1 = random_letters() 16 | r2 = random_letters() 17 | r3 = random_letters() 18 | 19 | button_grid.add_row( 20 | [ 21 | (r1, lambda: print(r1)), 22 | (r2, lambda: print(r2)), 23 | (r3, lambda: print(r3)) 24 | ] 25 | ) 26 | 27 | 28 | def remove_row(): 29 | button_grid.remove_row() 30 | 31 | 32 | if __name__ == '__main__': 33 | 34 | root = tk.Tk() 35 | 36 | button_grid = tk_tools.ButtonGrid(root, 3, ['Column0', 'Column1', 'Column2']) 37 | button_grid.grid(row=0, column=0) 38 | 39 | add_row_btn2 = tk.Button(text='Add button row (random text)', 40 | command=add_row_random) 41 | add_row_btn2.grid(row=1, column=0, sticky='EW') 42 | 43 | rm_row_btn = tk.Button(text='Remove button row', command=remove_row) 44 | rm_row_btn.grid(row=2, column=0, sticky='EW') 45 | 46 | root.mainloop() 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tk-tools" 3 | version = "0.17.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | 8 | classifiers=[ 9 | 'Development Status :: 4 - Beta', 10 | 'Programming Language :: Python :: 3', 11 | 'Programming Language :: Python :: 3.11', 12 | 'Programming Language :: Python :: 3.12', 13 | 'Programming Language :: Python :: 3.13', 14 | 'Natural Language :: English' 15 | ] 16 | 17 | dependencies = [ 18 | "engineering-notation>=0.10.0", 19 | "stringify>=0.1.1", 20 | ] 21 | 22 | [dependency-groups] 23 | dev = [ 24 | "coverage>=7.6.10", 25 | "pytest>=8.3.4", 26 | "pytest-cov>=6.0.0", 27 | "pyvirtualdisplay>=3.0", 28 | "ruff>=0.9.2", 29 | "sphinx>=8.1.3", 30 | ] 31 | 32 | [build-system] 33 | requires = ["setuptools > 75.0.0"] 34 | build-backend = "setuptools.build_meta" 35 | 36 | [tool.setuptools.packages.find] 37 | where = ["."] # list of folders that contain the packages (["."] by default) 38 | include = ["tk_tools", ] # package names should match these glob patterns (["*"] by default) 39 | exclude = [] # exclude packages matching these glob patterns (empty by default) 40 | namespaces = false # to disable scanning PEP 420 namespaces (true by default) 41 | -------------------------------------------------------------------------------- /docs/widget_groups.rst: -------------------------------------------------------------------------------- 1 | Widget Groups 2 | ============= 3 | 4 | Widget Groups consist of groups of other widgets. 5 | 6 | ``LabelGrid`` 7 | ------------- 8 | 9 | .. image:: img/label-grid.png 10 | 11 | .. autoclass:: groups.LabelGrid 12 | :members: 13 | 14 | ``EntryGrid`` 15 | ------------- 16 | 17 | .. image:: img/entry-grid.png 18 | 19 | .. autoclass:: groups.EntryGrid 20 | :members: 21 | 22 | ``ButtonGrid`` 23 | -------------- 24 | 25 | .. image:: img/button-grid.png 26 | 27 | .. autoclass:: groups.ButtonGrid 28 | :members: 29 | 30 | ``KeyValueEntry`` 31 | ----------------- 32 | 33 | The screenshot consists of three individual examples of ``KeyValueEntry`` widgets. 34 | 35 | .. image:: img/key-value.png 36 | 37 | .. autoclass:: groups.KeyValueEntry 38 | :members: 39 | 40 | ``Calendar`` 41 | ----------------- 42 | 43 | .. image:: img/calendar.png 44 | 45 | .. autoclass:: groups.Calendar 46 | :members: 47 | 48 | ``MultiSlotFrame`` 49 | ------------------ 50 | 51 | .. image:: img/multi-slot-frame.png 52 | 53 | .. autoclass:: groups.MultiSlotFrame 54 | :members: 55 | 56 | ``SevenSegment`` 57 | ---------------- 58 | 59 | .. image:: img/seven-segment-display.png 60 | 61 | .. autoclass:: groups.SevenSegment 62 | 63 | .. autoclass:: groups.SevenSegmentDigits 64 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import * 6 | 7 | 8 | @pytest.fixture 9 | def root(): 10 | root_frame = tk.Tk() 11 | yield root_frame 12 | 13 | try: 14 | root_frame.destroy() 15 | except: 16 | pass 17 | 18 | 19 | def test_version(): 20 | assert isinstance(__version__, str) 21 | 22 | 23 | def test_init(root): 24 | """ 25 | Ensures that all elements of the GUI may be instantiated 26 | without errors. 27 | """ 28 | RotaryScale(root).grid() 29 | Gauge(root).grid() 30 | Graph(root, 0, 10, 0, 10, 0.1, 0.1).grid() 31 | Led(root).grid() 32 | EntryGrid(root, 3).grid() 33 | LabelGrid(root, 3).grid() 34 | ButtonGrid(root, 3).grid() 35 | KeyValueEntry(root, keys=['1', '2']).grid() 36 | SmartOptionMenu(root, ['1', '2']).grid() 37 | SmartSpinBox(root).grid() 38 | SmartCheckbutton(root).grid() 39 | Calendar(root).grid() 40 | MultiSlotFrame(root).grid() 41 | SevenSegmentDigits(root).grid() 42 | BinaryLabel(root).grid() 43 | 44 | bl = BinaryLabel(root) 45 | bl.grid() 46 | ToolTip(bl, 'some text') 47 | 48 | # if the test suite makes it to here, then all widgets 49 | # have been successfully instantiated 50 | assert True 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /examples/graph.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | def add_series(): 6 | line_1 = [(x/5 - 1.0, x/10.0) for x in range(10)] 7 | graph.plot_line(line_1, point_visibility=True, color='blue') 8 | 9 | 10 | def add_point(): 11 | point = (0.5, 0.75) 12 | graph.plot_point(*point, color='red') 13 | 14 | 15 | def clear(): 16 | graph.draw_axes() 17 | 18 | 19 | if __name__ == '__main__': 20 | 21 | root = tk.Tk() 22 | 23 | # create the graph 24 | graph = tk_tools.Graph( 25 | parent=root, 26 | x_min=-1.0, 27 | x_max=1.0, 28 | y_min=0.0, 29 | y_max=2.0, 30 | x_tick=0.2, 31 | y_tick=0.2, 32 | width=500, 33 | height=400 34 | ) 35 | 36 | graph.grid(row=0, column=0) 37 | 38 | # create an initial line 39 | line_0 = [(x / 10, x / 10) for x in range(10)] 40 | graph.plot_line(line_0) 41 | 42 | add_series_btn = tk.Button(root, text='add series', command=add_series) 43 | add_series_btn.grid(row=1, column=0, sticky='EW') 44 | 45 | add_point_btn = tk.Button(root, text='add point', command=add_point) 46 | add_point_btn.grid(row=2, column=0, sticky='EW') 47 | 48 | clear_btn = tk.Button(root, text='clear', command=clear) 49 | clear_btn.grid(row=3, column=0, sticky='EW') 50 | 51 | root.mainloop() 52 | -------------------------------------------------------------------------------- /examples/button_grid_rowlabels.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | import random 4 | import string 5 | 6 | 7 | def random_letters(): 8 | sal = string.ascii_letters 9 | return (random.choice(sal) 10 | + random.choice(sal) 11 | + random.choice(sal)) 12 | 13 | 14 | def add_row_random(): 15 | r1 = random_letters() 16 | r2 = random_letters() 17 | r3 = random_letters() 18 | 19 | button_grid.add_row( 20 | [ 21 | (r1, lambda: print(r1)), 22 | (r2, lambda: print(r2)), 23 | (r3, lambda: print(r3)) 24 | ], 25 | row_label=random_letters() 26 | ) 27 | 28 | 29 | def remove_row(): 30 | button_grid.remove_row() 31 | 32 | 33 | if __name__ == '__main__': 34 | root = tk.Tk() 35 | 36 | button_grid = tk_tools.ButtonGrid( 37 | parent=root, 38 | num_of_columns=3, 39 | headers=['Column 0', 'Column 1', 'Column 2'] 40 | ) 41 | button_grid.grid(row=0, column=0) 42 | 43 | add_row_btn2 = tk.Button(text='Add button row (random text)', 44 | command=add_row_random) 45 | add_row_btn2.grid(row=1, column=0, sticky='EW') 46 | 47 | rm_row_btn = tk.Button(text='Remove button row', command=remove_row) 48 | rm_row_btn.grid(row=2, column=0, sticky='EW') 49 | 50 | root.mainloop() 51 | -------------------------------------------------------------------------------- /examples/key_value.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | def get_values(): 6 | print('kve0: {}'.format(kve0.get())) 7 | print('kve1: {}'.format(kve1.get())) 8 | print('kve2: {}'.format(kve2.get())) 9 | 10 | 11 | if __name__ == '__main__': 12 | 13 | root = tk.Tk() 14 | 15 | # create the key-value with a title 16 | kve0 = tk_tools.KeyValueEntry( 17 | root, 18 | title='Key/Value 0', 19 | keys=['Buckets', 'Dollars', 'Hens'], 20 | unit_labels=['buckets', 'dollars', 'hens'], 21 | ) 22 | kve0.grid(row=0) 23 | 24 | # create another key-value set without a title and with no units 25 | kve1 = tk_tools.KeyValueEntry( 26 | root, 27 | keys=['Baskets', 'Cows'] 28 | ) 29 | kve1.grid(row=1) 30 | 31 | # create a key-value with some entries disabled, then load values into each 32 | kve2 = tk_tools.KeyValueEntry( 33 | root, 34 | title='Static Key Value', 35 | keys=['Buckets', 'Dollars', 'Hens'], 36 | unit_labels=['buckets', 'dollars', 'hens'], 37 | enables=[False, False, True] 38 | ) 39 | kve2.grid(row=2) 40 | kve2.load({'Buckets': 'x', 'Dollars': 'y', 'Hens': 'z'}) 41 | 42 | get_values_btn = tk.Button(root, text='Retrieve Values', command=get_values) 43 | get_values_btn.grid(row=3) 44 | 45 | root.mainloop() 46 | -------------------------------------------------------------------------------- /.github/workflows/publishTest.yml: -------------------------------------------------------------------------------- 1 | name: Publish 📦 to TestPyPI 2 | on: 3 | push: 4 | tags: 5 | - v[0-9]+.[0-9]+.[0-9]+-alpha[0-9]* 6 | - v[0-9]+.[0-9]+.[0-9]+-beta[0-9]* 7 | - v[0-9]+.[0-9]+.[0-9]+-rc[0-9]* 8 | 9 | jobs: 10 | build-n-publish: 11 | name: Build and publish Python distributions to TestPyPI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Install xvfb 16 | run: sudo apt-get install -y xvfb 17 | 18 | - name: Checkout repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Python 3.x 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: 3.x 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v3 28 | 29 | - name: Create venv 30 | run: uv venv 31 | 32 | - name: Install tk_tools 33 | run: uv pip install -e . 34 | 35 | - name: Execute Tests 36 | run: xvfb-run -a uv run pytest -v tests/ --cov=tk_tools 37 | 38 | - name: Build wheel file in `dist/` 39 | run: uv build 40 | 41 | - name: Build docs 42 | run: | 43 | cd docs/ 44 | uv run sphinx-build -b html . ./html 45 | 46 | - name: Publish distribution 📦 to Test PyPI with UV 47 | run: uv publish --token ${{ secrets.TEST_PYPI_API_TOKEN }} --publish-url https://test.pypi.org/legacy/ 48 | -------------------------------------------------------------------------------- /examples/leds.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | 5 | def on_click_callback(on): 6 | if on: 7 | print('led is on') 8 | else: 9 | print('led is off') 10 | 11 | 12 | if __name__ == '__main__': 13 | root = tk.Tk() 14 | root.configure(bg='black') 15 | 16 | led0 = tk_tools.Led(root, size=50, on_click_callback=on_click_callback, 17 | bg='black') 18 | led0.pack() 19 | 20 | tk.Button(root, text='red', command=led0.to_red).pack(fill=tk.X) 21 | 22 | tk.Button(root, 23 | text='red on', 24 | command=lambda: led0.to_red(True)).pack(fill=tk.X) 25 | 26 | tk.Button(root, text='green', command=led0.to_green).pack(fill=tk.X) 27 | 28 | tk.Button(root, 29 | text='green on', 30 | command=lambda: led0.to_green(True)).pack(fill=tk.X) 31 | 32 | tk.Button(root, text='yellow', command=led0.to_yellow).pack(fill=tk.X) 33 | 34 | tk.Button(root, 35 | text='yellow on', 36 | command=lambda: led0.to_yellow(True)).pack(fill=tk.X) 37 | 38 | tk.Button(root, text='grey', command=led0.to_grey).pack(fill=tk.X) 39 | 40 | tk.Label(root, text='Clickable LED').pack(fill=tk.X) 41 | 42 | led1 = tk_tools.Led(root, size=50, on_click_callback=on_click_callback, toggle_on_click=True) 43 | led1.to_green() 44 | led1.pack() 45 | 46 | root.mainloop() 47 | -------------------------------------------------------------------------------- /examples/entry_grid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | import random 4 | 5 | 6 | def add_row(): 7 | entry_grid.add_row() 8 | 9 | 10 | def add_with_data(): 11 | data = [random.randint(0, 10) for _ in range(3)] 12 | entry_grid.add_row(data=data) 13 | 14 | 15 | def read(): 16 | print(entry_grid.read(as_dicts=False)) 17 | 18 | 19 | if __name__ == '__main__': 20 | 21 | root = tk.Tk() 22 | 23 | add_row_btn = tk.Button(root, text='Add Row', command=add_row) 24 | add_row_btn.grid(row=0, column=0, columnspan=2, sticky='ew') 25 | 26 | add_row_data_btn = tk.Button(root, text='Add Row (with data)', command=add_with_data) 27 | add_row_data_btn.grid(row=1, column=0, columnspan=2, sticky='ew') 28 | 29 | remove_row_btn = tk.Button(root, text='Remove Row') 30 | remove_row_btn.grid(row=2, column=0, sticky='ew') 31 | 32 | row_to_remove_entry = tk.Entry(root) 33 | row_to_remove_entry.grid(row=2, column=1, sticky='ew') 34 | row_to_remove_entry.insert(0, '0') 35 | 36 | remove_row_btn.config(command=lambda: entry_grid.remove_row(int(row_to_remove_entry.get()))) 37 | 38 | read_btn = tk.Button(root, text='Read', command=read) 39 | read_btn.grid(row=3, column=0, columnspan=2, sticky='ew') 40 | 41 | entry_grid = tk_tools.EntryGrid(root, 3, ['L0', 'L1', 'L2']) 42 | entry_grid.grid(row=4, column=0, columnspan=2, sticky='ew') 43 | 44 | root.mainloop() 45 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 📦 to PyPI 2 | on: 3 | push: 4 | tags: 5 | - v[0-9]+.[0-9]+.[0-9]+ 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python distributions to PyPI 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Install xvfb 14 | run: sudo apt-get install -y xvfb 15 | 16 | - name: Checkout repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 3.x 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.x 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v3 26 | 27 | - name: Create venv 28 | run: uv venv 29 | 30 | - name: Install tk_tools 31 | run: uv pip install -e . 32 | 33 | - name: Execute Tests 34 | run: xvfb-run -a uv run pytest -v tests/ --cov=tk_tools 35 | 36 | - name: Build wheel file in `dist/` 37 | run: uv build 38 | 39 | - name: Build docs 40 | run: | 41 | cd docs/ 42 | uv run sphinx-build -b html . ./html 43 | 44 | # - name: Deploy documentation 45 | # if: ${{ github.event_name == 'push' }} 46 | # uses: JamesIves/github-pages-deploy-action@4.1.4 47 | # with: 48 | # branch: gh-pages 49 | # folder: gh-pages 50 | 51 | - name: Publish distribution 📦 to PyPI with UV 52 | run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} 53 | -------------------------------------------------------------------------------- /tests/test_ButtonGrid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import ButtonGrid 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def btn_grid_3col(root): 12 | eg = ButtonGrid(root, 3, headers=['a', 'b', 'c']) 13 | yield eg 14 | 15 | eg._redraw() 16 | 17 | 18 | def test_creation_with_header(root): 19 | ButtonGrid(root, 3, headers=['a', 'b', 'c']) 20 | 21 | 22 | def test_header_len_doesnt_match_cols(root): 23 | with pytest.raises(ValueError): 24 | ButtonGrid(root, 2, headers=['a', 'b', 'c']) 25 | 26 | 27 | def test_add_row(btn_grid_3col): 28 | data = [ 29 | ('1', lambda: print('1')), 30 | ('2', lambda: print('2')), 31 | ('3', lambda: print('3')), 32 | ] 33 | btn_grid_3col.add_row(data) 34 | 35 | 36 | def test_add_row_wrong_format(btn_grid_3col): 37 | data = ['1', '2', '3'] 38 | with pytest.raises(ValueError): 39 | btn_grid_3col.add_row(data) 40 | 41 | 42 | def test_add_row_len_doesnt_match_cols(btn_grid_3col): 43 | data = ['1', '2', '3', '4'] 44 | 45 | with pytest.raises(ValueError): 46 | btn_grid_3col.add_row(data) 47 | 48 | 49 | def test_remove_row(btn_grid_3col): 50 | data = [ 51 | ('1', lambda: print('1')), 52 | ('2', lambda: print('2')), 53 | ('3', lambda: print('3')), 54 | ] 55 | btn_grid_3col.add_row(data) 56 | btn_grid_3col.add_row(data) 57 | btn_grid_3col.add_row(data) 58 | 59 | # remove row 1 60 | btn_grid_3col.remove_row(1) 61 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. tk_tools documentation master file, created by 2 | sphinx-quickstart on Thu Jan 25 06:53:00 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to tk_tools's documentation! 7 | ==================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | Installation 13 | Widget Groups 14 | Canvas Widgets 15 | Smart Widgets 16 | Tool Tips 17 | 18 | Introduction 19 | ------------ 20 | 21 | The ``tk_tools`` package exists in a space like other packages. In many cases, the ``tkinter`` interface leaves some API to be desired while, in other cases, it leaves out some room for fairly standard visualizations. This is a collection of widgets and tools that have been developed over the course of creating GUI elements as a means to simplify and enhance the process and results. 22 | 23 | There are three categories of widgets: 24 | 25 | - groups of widgets that are useful as a group 26 | - visual aids using the canvas 27 | - useful improvements on existing widgets 28 | 29 | Tkinter Setup 30 | ------------- 31 | 32 | Each of the code examples assumes a structure similar to the below in order to setup the root environment.:: 33 | 34 | import tkinter as tk 35 | import tk_tools 36 | 37 | root = tk.Tk() 38 | 39 | # ----------------------------------- 40 | # ----- your GUI widget(s) here ----- 41 | # ----------------------------------- 42 | 43 | root.mainloop() 44 | 45 | Indices and tables 46 | ================== 47 | 48 | * :ref:`genindex` 49 | -------------------------------------------------------------------------------- /tests/test_KeyValueEntry.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import KeyValueEntry 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def kve(root): 12 | kve_widget = KeyValueEntry(root, keys=['a', 'b', 'c']) 13 | kve_widget.grid() 14 | 15 | yield kve_widget 16 | 17 | 18 | def test_creation(root): 19 | KeyValueEntry(root, title='blah', keys=['a', 'b', 'c']) 20 | 21 | 22 | def test_faulty_init(root): 23 | with pytest.raises(ValueError): 24 | KeyValueEntry(root, keys=['a', 'b', 'c'], defaults=['1', '2']) 25 | 26 | with pytest.raises(ValueError): 27 | KeyValueEntry(root, keys=['a', 'b', 'c'], enables=[True, False]) 28 | 29 | with pytest.raises(ValueError): 30 | KeyValueEntry(root, keys=['a', 'b', 'c'], unit_labels=['meters', 'buckets']) 31 | 32 | 33 | def test_reset(root): 34 | kve = KeyValueEntry(root, keys=['a', 'b', 'c']) 35 | kve.grid() 36 | 37 | kve.load( 38 | { 39 | 'a': '1', 'b': '2', 'c': '3' 40 | } 41 | ) 42 | 43 | data = kve.get() 44 | assert data['a'] == '1' 45 | assert data['b'] == '2' 46 | assert data['c'] == '3' 47 | 48 | kve.reset() 49 | 50 | # todo: figure out how to verify that the entries were in fact cleared 51 | 52 | 53 | def test_with_defaults(root): 54 | kve = KeyValueEntry(root, keys=['a', 'b', 'c'], defaults=['1', '2', '3']) 55 | kve.grid() 56 | 57 | kve.add_row('d') 58 | kve.add_row('e', default='5') 59 | 60 | 61 | def test_enables(root): 62 | kve = KeyValueEntry(root, keys=['a', 'b', 'c'], enables=[True, False, False]) 63 | kve.grid() 64 | 65 | kve.add_row('d') 66 | kve.change_enables([False, False, False, False]) 67 | -------------------------------------------------------------------------------- /examples/rotary_scale.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | from tk_tools.images import rotary_gauge_volt, rotary_scale 5 | 6 | max_value = 100.0 7 | min_value = 0.0 8 | 9 | 10 | def increment(): 11 | global value 12 | 13 | value += increment_value 14 | if value > max_value: 15 | value = max_value 16 | 17 | p1.set_value(value) 18 | p2.set_value(value) 19 | p3.set_value(value) 20 | 21 | 22 | def decrement(): 23 | global value 24 | value -= increment_value 25 | 26 | if value < min_value: 27 | value = min_value 28 | 29 | p1.set_value(value) 30 | p2.set_value(value) 31 | p3.set_value(value) 32 | 33 | 34 | if __name__ == '__main__': 35 | 36 | root = tk.Tk() 37 | 38 | p1 = tk_tools.RotaryScale(root, max_value=max_value, size=100, unit='km/h') 39 | p1.grid(row=0, column=0) 40 | 41 | p2 = tk_tools.RotaryScale(root, 42 | max_value=max_value, 43 | size=100, 44 | needle_thickness=3, 45 | needle_color='black', 46 | img_data=rotary_gauge_volt) 47 | 48 | p2.grid(row=0, column=1) 49 | 50 | increment_value = 1.0 51 | value = 0.0 52 | 53 | inc_btn = tk.Button(root, 54 | text='increment_value by {}'.format(increment_value), 55 | command=increment) 56 | 57 | inc_btn.grid(row=1, column=0, columnspan=2, sticky='news') 58 | 59 | dec_btn = tk.Button(root, 60 | text='decrement by {}'.format(increment_value), 61 | command=decrement) 62 | 63 | dec_btn.grid(row=2, column=0, columnspan=2, sticky='news') 64 | 65 | root.mainloop() 66 | -------------------------------------------------------------------------------- /tk_tools/tooltips.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | 4 | class ToolTip(object): 5 | """ 6 | Add a tooltip to any widget.:: 7 | 8 | entry = tk.Entry(root) 9 | entry.grid() 10 | 11 | # createst a tooltip 12 | tk_tools.ToolTip(entry, 'enter a value between 1 and 10') 13 | 14 | :param widget: the widget on which to hover 15 | :param text: the text to display 16 | :param time: the time to display the text, in milliseconds 17 | """ 18 | 19 | def __init__(self, widget, text: str = "widget info", time: int = 4000): 20 | self._widget = widget 21 | self._text = text 22 | self._time = time 23 | 24 | self._widget.bind("", lambda _: self._widget.after(500, self._enter())) 25 | self._widget.bind("", self._close) 26 | 27 | self._tw = None 28 | 29 | def _enter(self, event=None): 30 | x, y, cx, cy = self._widget.bbox("insert") 31 | x += self._widget.winfo_rootx() + 25 32 | y += self._widget.winfo_rooty() + 20 33 | 34 | # creates a toplevel window 35 | self._tw = tk.Toplevel(self._widget) 36 | 37 | # Leaves only the label and removes the app window 38 | self._tw.wm_overrideredirect(True) 39 | self._tw.wm_geometry("+%d+%d" % (x, y)) 40 | label = tk.Label( 41 | self._tw, 42 | text=self._text, 43 | justify="left", 44 | background="#FFFFDD", 45 | relief="solid", 46 | borderwidth=1, 47 | font=("times", "8", "normal"), 48 | ) 49 | 50 | label.pack(ipadx=1) 51 | 52 | if self._time: 53 | self._tw.after(self._time, self._tw.destroy) 54 | 55 | def _close(self, event=None): 56 | if self._tw: 57 | self._tw.destroy() 58 | -------------------------------------------------------------------------------- /tests/test_SmartOptionMenu.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import SmartOptionMenu 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def som(root): 12 | som_widget = SmartOptionMenu(root, ['1', '2', '3']) 13 | som_widget.grid() 14 | 15 | yield som_widget 16 | 17 | 18 | def test_creation(root): 19 | SmartOptionMenu(root, ['1', '2', '3']) 20 | 21 | 22 | def test_creation_with_initial_value(root): 23 | som = SmartOptionMenu(root, ['1', '2', '3'], initial_value='2') 24 | 25 | assert som.get() == '2' 26 | 27 | 28 | def test_callback_no_param(root): 29 | item_value = 0 30 | 31 | def callback(): 32 | nonlocal item_value 33 | item_value += 1 34 | 35 | som = SmartOptionMenu(root, ['1', '2', '3'], callback=callback) 36 | som.grid() 37 | 38 | som.set('1') 39 | som.set('3') 40 | som.set('2') 41 | 42 | assert item_value == 3 43 | 44 | 45 | def test_callback_with_param(root): 46 | item_value = 0 47 | 48 | def callback(value): 49 | nonlocal item_value 50 | item_value += int(value) 51 | 52 | som = SmartOptionMenu(root, ['1', '2', '3'], callback=callback) 53 | som.grid() 54 | 55 | som.set('2') 56 | som.set('2') 57 | som.set('2') 58 | 59 | assert item_value == 6 60 | 61 | 62 | def test_add_callback_no_param(som): 63 | item_value = 0 64 | 65 | def callback(): 66 | nonlocal item_value 67 | item_value += 1 68 | 69 | som.add_callback(callback) 70 | 71 | som.set('1') 72 | som.set('3') 73 | som.set('2') 74 | 75 | assert item_value == 3 76 | 77 | 78 | def test_add_callback_with_param(som): 79 | item_value = 0 80 | 81 | def callback(value): 82 | nonlocal item_value 83 | item_value += int(value) 84 | 85 | som.add_callback(callback) 86 | 87 | som.set('2') 88 | som.set('2') 89 | som.set('2') 90 | 91 | assert item_value == 6 92 | -------------------------------------------------------------------------------- /tests/test_EntryGrid.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import EntryGrid 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def entry_grid_3col(root): 12 | eg = EntryGrid(root, 3, headers=['a', 'b', 'c']) 13 | yield eg 14 | 15 | eg._redraw() 16 | 17 | 18 | @pytest.fixture 19 | def entry_grid_with_data(entry_grid_3col): 20 | eg = entry_grid_3col 21 | 22 | entry_grid_3col.add_row(['1', '2', '3']) 23 | entry_grid_3col.add_row(['4', '5', '6']) 24 | entry_grid_3col.add_row(['7', '8', '9']) 25 | 26 | yield eg 27 | 28 | eg._redraw() 29 | 30 | 31 | def test_creation(root): 32 | EntryGrid(root, 3) 33 | 34 | 35 | def test_creation_with_header(root): 36 | EntryGrid(root, 3, headers=['a', 'b', 'c']) 37 | 38 | 39 | def test_header_len_doesnt_match_cols(root): 40 | with pytest.raises(ValueError): 41 | EntryGrid(root, 2, headers=['a', 'b', 'c']) 42 | 43 | 44 | def test_add_row(entry_grid_3col): 45 | data = ['1', '2', '3'] 46 | entry_grid_3col.add_row(data) 47 | 48 | 49 | def test_add_empty_row(entry_grid_3col): 50 | entry_grid_3col.add_row() 51 | 52 | 53 | def test_add_row_len_doesnt_match_cols(entry_grid_3col): 54 | data = ['1', '2', '3', '4'] 55 | 56 | with pytest.raises(ValueError): 57 | entry_grid_3col.add_row(data) 58 | 59 | 60 | def test_read_as_dicts(entry_grid_with_data): 61 | data = entry_grid_with_data.read() 62 | 63 | assert data[0]['a'] == '1' 64 | assert data[0]['b'] == '2' 65 | assert data[0]['c'] == '3' 66 | assert data[1]['a'] == '4' 67 | assert data[1]['b'] == '5' 68 | assert data[1]['c'] == '6' 69 | assert data[2]['a'] == '7' 70 | assert data[2]['b'] == '8' 71 | assert data[2]['c'] == '9' 72 | 73 | 74 | def test_read_as_lists(entry_grid_with_data): 75 | data = entry_grid_with_data.read(as_dicts=False) 76 | 77 | assert data[0] == ['1', '2', '3'] 78 | assert data[1] == ['4', '5', '6'] 79 | assert data[2] == ['7', '8', '9'] 80 | 81 | 82 | def test_remove_row(entry_grid_with_data): 83 | eg = entry_grid_with_data 84 | 85 | # remove row 1 86 | eg.remove_row(1) 87 | data = entry_grid_with_data.read() 88 | 89 | # now, instead of containing 4, 5, 6, row 1 should contain 7, 8, 9 90 | assert data[0]['a'] == '1' 91 | assert data[0]['b'] == '2' 92 | assert data[0]['c'] == '3' 93 | assert data[1]['a'] == '7' 94 | assert data[1]['b'] == '8' 95 | assert data[1]['c'] == '9' 96 | 97 | -------------------------------------------------------------------------------- /tests/test_BinaryLabel.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | import pytest 4 | 5 | from tk_tools import BinaryLabel 6 | 7 | from tests.test_basic import root 8 | 9 | 10 | @pytest.fixture 11 | def bl(root): 12 | bl_widget = BinaryLabel(root) 13 | bl_widget.grid() 14 | 15 | yield bl_widget 16 | 17 | 18 | def test_creation(root): 19 | bl = BinaryLabel(root) 20 | bl.grid() 21 | 22 | assert bl.get() == 0 23 | 24 | 25 | def test_creation_with_value(root): 26 | bl = BinaryLabel(root, value=101) 27 | bl.grid() 28 | 29 | assert bl.get() == 101 30 | 31 | 32 | def test_change_value(bl): 33 | bl.set(101) 34 | assert bl.get() == 101 35 | 36 | bl.set(10) 37 | assert bl.get() == 10 38 | 39 | 40 | def test_change_value_too_large(bl): 41 | with pytest.raises(ValueError): 42 | bl.set(0x100) 43 | 44 | 45 | def test_get_bit(bl): 46 | bl.set(0x55) 47 | 48 | assert bl.get_bit(0) == 1 49 | assert bl.get_bit(1) == 0 50 | assert bl.get_bit(2) == 1 51 | assert bl.get_bit(3) == 0 52 | assert bl.get_bit(4) == 1 53 | assert bl.get_bit(5) == 0 54 | assert bl.get_bit(6) == 1 55 | assert bl.get_bit(7) == 0 56 | 57 | with pytest.raises(ValueError): 58 | bl.get_bit(8) 59 | 60 | 61 | def test_toggle_bit(bl): 62 | bl.set(0x55) 63 | assert bl.get_bit(0) == 1 64 | 65 | bl.toggle_bit(0) 66 | assert bl.get_bit(0) == 0 67 | 68 | with pytest.raises(ValueError): 69 | bl.toggle_bit(8) 70 | 71 | 72 | def test_set_and_clear_bit(bl): 73 | bl.set(0x55) 74 | assert bl.get_bit(0) == 1 75 | 76 | bl.clear_bit(0) 77 | assert bl.get_bit(0) == 0 78 | 79 | bl.set_bit(0) 80 | assert bl.get_bit(0) == 1 81 | 82 | with pytest.raises(ValueError): 83 | bl.clear_bit(8) 84 | 85 | with pytest.raises(ValueError): 86 | bl.set_bit(8) 87 | 88 | 89 | def test_get_msb_and_lsb(bl): 90 | assert bl.get_msb() == 0 91 | assert bl.get_lsb() == 0 92 | 93 | bl.set(0xff) 94 | assert bl.get_msb() == 1 95 | assert bl.get_lsb() == 1 96 | 97 | 98 | def test_set_msb(bl): 99 | assert bl.get() == 0 100 | 101 | bl.set_msb() 102 | assert bl.get() == 0x80 103 | 104 | 105 | def test_clear_msb(bl): 106 | bl.set(0xff) 107 | assert bl.get() == 0xff 108 | 109 | bl.clear_msb() 110 | assert bl.get() == 0x7f 111 | 112 | 113 | def test_set_lsb(bl): 114 | assert bl.get() == 0 115 | 116 | bl.set_lsb() 117 | assert bl.get() == 0x01 118 | 119 | 120 | def test_clear_lsb(bl): 121 | bl.set(0xff) 122 | assert bl.get() == 0xff 123 | 124 | bl.clear_lsb() 125 | assert bl.get() == 0xfe 126 | 127 | 128 | def test_toggle_msb_lsb(bl): 129 | bl.toggle_msb() 130 | bl.toggle_lsb() 131 | assert bl.get() == 0x81 132 | 133 | bl.toggle_msb() 134 | bl.toggle_lsb() 135 | assert bl.get() == 0x00 136 | 137 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/slightlynybbled/tk_tools.svg?branch=master)](https://travis-ci.org/slightlynybbled/tk_tools) 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/tk-tools/badge/?version=latest)](http://tk-tools.readthedocs.io/en/latest/?badge=latest) 4 | 5 | # Purpose 6 | 7 | This repository holds useful high-level widgets written in pure python. 8 | This library used type hints and requires Python 3.11+; it could, however, be back-ported to earlier Python versions without difficulty. 9 | 10 | For more details, check out the [documentation](https://tk-tools.readthedocs.io). 11 | 12 | Here are some examples screenshots of the widgets you can create: 13 | 14 | ## Button-Grid: 15 | 16 | ![Button-Grid](https://tk-tools.readthedocs.io/en/latest/_images/button-grid.png) 17 | 18 | ## Binary-Label: 19 | 20 | ![Byte-Label](https://tk-tools.readthedocs.io/en/latest/_images/byte-label.png) 21 | 22 | ## Calendar: 23 | 24 | ![Calendar](https://tk-tools.readthedocs.io/en/latest/_images/calendar.png) 25 | 26 | ## Entry-Grid: 27 | 28 | ![Entry-Grid](https://tk-tools.readthedocs.io/en/latest/_images/entry-grid.png) 29 | 30 | ## Multi-Slot Frame 31 | 32 | ![Multi-Slot Frame](https://tk-tools.readthedocs.io/en/latest/_images/multi-slot-frame.png) 33 | 34 | ## Graph: 35 | 36 | ![Graph](https://tk-tools.readthedocs.io/en/latest/_images/graph.png) 37 | 38 | ## Key-Value: 39 | 40 | ![Key-Value](https://tk-tools.readthedocs.io/en/latest/_images/key-value.png) 41 | 42 | ## Label-Grid: 43 | 44 | ![Label-Grid](https://tk-tools.readthedocs.io/en/latest/_images/label-grid.png) 45 | 46 | ## LED: (size can be scaled) 47 | 48 | ![LED](https://tk-tools.readthedocs.io/en/latest/_images/led.gif) 49 | 50 | ## SevenSegment and SevenSegmentDisplay 51 | 52 | ![Seven Segment Display](https://tk-tools.readthedocs.io/en/latest/_images/seven-segment-display.png) 53 | 54 | ## Gauge 55 | 56 | ![Gauge](https://tk-tools.readthedocs.io/en/latest/_images/gauges.png) 57 | 58 | ![Gauge Documentation](https://tk-tools.readthedocs.io/en/latest/_images/gaugedoc.png) 59 | 60 | ## Rotary-Scale: (Tachometer) 61 | 62 | ![Rotary-Scale](https://tk-tools.readthedocs.io/en/latest/_images/rotary-scale.png) 63 | 64 | # Testing 65 | 66 | ## Dev Installation 67 | 68 | $>uv pip install -e . 69 | 70 | # Contributions 71 | 72 | Contributions for new widgets, documentation, tests, and resolving issues are welcomed. 73 | 74 | Contribution guidelines: 75 | 76 | 1. Fork the repository to your account. 77 | 2. Clone your account repository to your local development environment. 78 | 3. Create/checkout a new branch appropriately named by feature, bug, issue number, whatever. 79 | 4. Make your changes on your branch. The ideal changes would: 80 | 81 | - have working examples in the examples directory 82 | - have documentation in the docs directory 83 | 84 | 5. Push your changes to your github account. 85 | 6. Create a pull request from within github. 86 | 87 | All code is to be passing `flake8` before it is merged into master! 88 | -------------------------------------------------------------------------------- /examples/gauge.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tk_tools 3 | 4 | root = tk.Tk() 5 | 6 | max_speed = 20000 7 | 8 | # Typical one-sided gauge that you might be expecting for a 9 | # speedometer or tachometer. Starts at zero and goes to 10 | # max_value when full-scale. 11 | speed_gauge = tk_tools.Gauge(root, 12 | max_value=max_speed, 13 | label='speed', 14 | unit=' m/h', 15 | bg='grey') 16 | speed_gauge.grid(row=0, column=0, sticky='news') 17 | 18 | tach_gauge = tk_tools.Gauge(root, 19 | max_value=8000, 20 | label='tach', 21 | unit=' RPM', 22 | divisions=10) 23 | tach_gauge.grid(row=1, column=0, sticky='news') 24 | 25 | strange_gauge = tk_tools.Gauge(root, 26 | max_value=30000, 27 | label='strange', unit=' blah', 28 | divisions=10, red=90, yellow=60) 29 | strange_gauge.grid(row=2, column=0, sticky='news') 30 | 31 | # The battery voltage gauge has a lower voltage limit and an 32 | # upper voltage limit. These are automatically created when 33 | # one imposes values on red_low and yellow_low along with 34 | # using the min_value. 35 | batV_gauge = tk_tools.Gauge(root, height=120, width=250, 36 | max_value=16, min_value=8, 37 | label='Bat voltage', 38 | unit='V', 39 | divisions=8, 40 | yellow=60, 41 | red=75, 42 | red_low=30, 43 | yellow_low=40) 44 | batV_gauge.grid(row=0, column=1, sticky='news') 45 | 46 | # Similar to the previous gauge with bi-directional, but shows an 47 | # imbalanced configuration along with support for negative numbers. 48 | batI_gauge = tk_tools.Gauge(root, height=120, width=250, 49 | max_value=6, 50 | min_value=-8, 51 | label='Bat current', 52 | unit='A', 53 | divisions=14, yellow=80, red=90, 54 | red_low=20, yellow_low=30, bg='lavender') 55 | batI_gauge.grid(row=1, column=1, sticky='news') 56 | 57 | # initialization of some variables. 58 | count = 0 59 | up = True 60 | 61 | 62 | def update_gauge(): 63 | global count, up 64 | 65 | increment = 30 66 | 67 | if up: 68 | count += increment 69 | if count > max_speed: 70 | up = False 71 | else: 72 | count -= increment 73 | 74 | if count <= 0.0: 75 | up = True 76 | 77 | # update the gauges according to their value 78 | speed_gauge.set_value(count) 79 | tach_gauge.set_value(count) 80 | strange_gauge.set_value(count) 81 | batV_gauge.set_value(count / 1000) 82 | batI_gauge.set_value((count - 10000) / 1000) 83 | 84 | root.after(50, update_gauge) 85 | 86 | 87 | if __name__ == '__main__': 88 | root.after(100, update_gauge) 89 | 90 | root.mainloop() 91 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tk_tools documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Jan 25 06:53:00 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('../tk_tools')) 23 | 24 | print('cwd: ', os.getcwd()) 25 | print('sys path', sys.path) 26 | 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = ['sphinx.ext.autodoc', 38 | 'sphinx.ext.todo', 39 | 'sphinx.ext.coverage', 40 | 'sphinx.ext.mathjax'] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = 'tk_tools' 56 | copyright = '2018, Jason R. Jones' 57 | author = 'Jason R. Jones' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = '' 65 | # The full version, including alpha/beta/rc tags. 66 | release = '' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | # This patterns also effect to html_static_path and html_extra_path 78 | exclude_patterns = [] 79 | 80 | # The name of the Pygments (syntax highlighting) style to use. 81 | pygments_style = 'sphinx' 82 | 83 | # If true, `todo` and `todoList` produce output, else they produce nothing. 84 | todo_include_todos = True 85 | 86 | 87 | # -- Options for HTML output ---------------------------------------------- 88 | 89 | # The theme to use for HTML and HTML Help pages. See the documentation for 90 | # a list of builtin themes. 91 | # 92 | html_theme = 'alabaster' 93 | 94 | # Theme options are theme-specific and customize the look and feel of a theme 95 | # further. For a list of options available for each theme, see the 96 | # documentation. 97 | # 98 | # html_theme_options = {} 99 | 100 | # Add any paths that contain custom static files (such as style sheets) here, 101 | # relative to this directory. They are copied after the builtin static files, 102 | # so a file named "default.css" will overwrite the builtin "default.css". 103 | html_static_path = ['_static'] 104 | 105 | # Custom sidebar templates, must be a dictionary that maps document names 106 | # to template names. 107 | # 108 | # This is required for the alabaster theme 109 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 110 | html_sidebars = { 111 | '**': [ 112 | 'relations.html', # needs 'show_related': True theme option to display 113 | 'globaltoc.html', 114 | 'searchbox.html', 115 | ] 116 | } 117 | 118 | 119 | # -- Options for HTMLHelp output ------------------------------------------ 120 | 121 | # Output file base name for HTML help builder. 122 | htmlhelp_basename = 'tk_toolsdoc' 123 | 124 | 125 | # -- Options for LaTeX output --------------------------------------------- 126 | 127 | latex_elements = { 128 | # The paper size ('letterpaper' or 'a4paper'). 129 | # 130 | # 'papersize': 'letterpaper', 131 | 132 | # The font size ('10pt', '11pt' or '12pt'). 133 | # 134 | # 'pointsize': '10pt', 135 | 136 | # Additional stuff for the LaTeX preamble. 137 | # 138 | # 'preamble': '', 139 | 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, 'tk_tools.tex', 'tk\\_tools Documentation', 150 | 'Jason R. Jones', 'manual'), 151 | ] 152 | 153 | 154 | # -- Options for manual page output --------------------------------------- 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [ 159 | (master_doc, 'tk_tools', 'tk_tools Documentation', 160 | [author], 1) 161 | ] 162 | 163 | 164 | # -- Options for Texinfo output ------------------------------------------- 165 | 166 | # Grouping the document tree into Texinfo files. List of tuples 167 | # (source start file, target name, title, author, 168 | # dir menu entry, description, category) 169 | texinfo_documents = [ 170 | (master_doc, 'tk_tools', 'tk_tools Documentation', 171 | author, 'tk_tools', 'One line description of project.', 172 | 'Miscellaneous'), 173 | ] 174 | -------------------------------------------------------------------------------- /tk_tools/widgets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import tkinter as tk 3 | import tkinter.ttk as ttk 4 | from typing import List, Union 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class SmartWidget(ttk.Frame): 11 | """ 12 | Superclass which contains basic elements of the 'smart' widgets. 13 | """ 14 | 15 | def __init__(self, parent): 16 | self._parent = parent 17 | super().__init__(self._parent) 18 | 19 | self._var = None 20 | 21 | def add_callback(self, callback: callable): 22 | """ 23 | Add a callback on change 24 | 25 | :param callback: callable function 26 | :return: None 27 | """ 28 | 29 | def internal_callback(*args): 30 | try: 31 | callback() 32 | except TypeError: 33 | callback(self.get()) 34 | 35 | self._var.trace("w", internal_callback) 36 | 37 | def get(self): 38 | """ 39 | Retrieve the value of the dropdown 40 | 41 | :return: the value of the current variable 42 | """ 43 | return self._var.get() 44 | 45 | def set(self, value): 46 | """ 47 | Set the value of the dropdown 48 | 49 | :param value: a string representing the 50 | :return: None 51 | """ 52 | self._var.set(value) 53 | 54 | 55 | class SmartOptionMenu(SmartWidget): 56 | """ 57 | Classic drop down entry with built-in tracing variable.:: 58 | 59 | # create the dropdown and grid 60 | som = SmartOptionMenu(root, ['one', 'two', 'three']) 61 | som.grid() 62 | 63 | # define a callback function that retrieves 64 | # the currently selected option 65 | def callback(): 66 | print(som.get()) 67 | 68 | # add the callback function to the dropdown 69 | som.add_callback(callback) 70 | 71 | :param data: the tk parent frame 72 | :param options: a list containing the drop down options 73 | :param initial_value: the initial value of the dropdown 74 | :param callback: a function 75 | """ 76 | 77 | def __init__( 78 | self, 79 | parent, 80 | options: list, 81 | initial_value: str = None, 82 | callback: callable = None, 83 | ): 84 | super().__init__(parent) 85 | 86 | self._var = tk.StringVar() 87 | self._var.set(initial_value if initial_value else options[0]) 88 | 89 | self.option_menu = tk.OptionMenu(self, self._var, *options) 90 | self.option_menu.grid(row=0, column=0) 91 | 92 | if callback is not None: 93 | 94 | def internal_callback(*args): 95 | try: 96 | callback() 97 | except TypeError: 98 | callback(self.get()) 99 | 100 | self._var.trace("w", internal_callback) 101 | 102 | 103 | class SmartSpinBox(SmartWidget): 104 | """ 105 | Easy-to-use spinbox. Takes most options that work with a normal SpinBox. 106 | Attempts to call your callback function - if assigned - whenever there 107 | is a change to the spinbox.:: 108 | 109 | # create a callback function 110 | def callback(value): 111 | print('the new value is: ', value) 112 | 113 | # create the smart spinbox and grid 114 | ssb = SmartSpinBox(root, from_=0, to=5, callback=callback) 115 | ssb.grid() 116 | 117 | :param parent: the tk parent frame 118 | :param entry_type: 'str', 'int', 'float' 119 | :param callback: python callable 120 | :param options: any options that are valid for tkinter.SpinBox 121 | """ 122 | 123 | def __init__( 124 | self, parent, entry_type: str = "float", callback: callable = None, **options 125 | ): 126 | """ 127 | Constructor for SmartSpinBox 128 | """ 129 | self._parent = parent 130 | super().__init__(self._parent) 131 | 132 | sb_options = options.copy() 133 | 134 | if entry_type == "str": 135 | self._var = tk.StringVar() 136 | elif entry_type == "int": 137 | self._var = tk.IntVar() 138 | elif entry_type == "float": 139 | self._var = tk.DoubleVar() 140 | else: 141 | raise ValueError('Entry type must be "str", "int", or "float"') 142 | 143 | sb_options["textvariable"] = self._var 144 | self._spin_box = tk.Spinbox(self, **sb_options) 145 | self._spin_box.grid() 146 | 147 | if callback is not None: 148 | 149 | def internal_callback(*args): 150 | try: 151 | callback() 152 | except TypeError: 153 | callback(self.get()) 154 | 155 | self._var.trace("w", internal_callback) 156 | 157 | 158 | class SmartCheckbutton(SmartWidget): 159 | """ 160 | Easy-to-use check button. Takes most options that work with 161 | a normal CheckButton. Attempts to call your callback 162 | function - if assigned - whenever there is a change to 163 | the check button.:: 164 | 165 | # create the smart spinbox and grid 166 | scb = SmartCheckbutton(root) 167 | scb.grid() 168 | 169 | # define a callback function that retrieves 170 | # the currently selected option 171 | def callback(): 172 | print(scb.get()) 173 | 174 | # add the callback function to the checkbutton 175 | scb.add_callback(callback) 176 | 177 | :param parent: the tk parent frame 178 | :param callback: python callable 179 | :param options: any options that are valid for tkinter.Checkbutton 180 | """ 181 | 182 | def __init__(self, parent, callback: callable = None, **options): 183 | self._parent = parent 184 | super().__init__(self._parent) 185 | 186 | self._var = tk.BooleanVar() 187 | self._cb = tk.Checkbutton(self, variable=self._var, **options) 188 | self._cb.grid() 189 | 190 | if callback is not None: 191 | 192 | def internal_callback(*args): 193 | try: 194 | callback() 195 | except TypeError: 196 | callback(self.get()) 197 | 198 | self._var.trace("w", internal_callback) 199 | 200 | 201 | class SmartListBox(SmartWidget): 202 | """ 203 | Easy-to-use List Box. Takes most options that work with 204 | a normal CheckButton. Attempts to call your callback 205 | function - if assigned - whenever there is a change to 206 | the list box selections.:: 207 | 208 | # create the smart spinbox and grid 209 | scb = SmartListBox(root, options=['one', 'two', 'three']) 210 | scb.grid() 211 | 212 | # define a callback function that retrieves 213 | # the currently selected option 214 | def callback(): 215 | print(scb.get_selected()) 216 | 217 | # add the callback function to the checkbutton 218 | scb.add_callback(callback) 219 | 220 | :param parent: the tk parent frame 221 | :param options: any options that are valid for tkinter.Checkbutton 222 | :param on_select_callback: python callable 223 | :param selectmode: the selector mode (supports "browse" and "multiple") 224 | """ 225 | 226 | def __init__( 227 | self, 228 | parent, 229 | options: List[str], 230 | width: int = 12, 231 | height: int = 5, 232 | on_select_callback: callable = None, 233 | selectmode: str = "browse", 234 | ): 235 | super().__init__(parent=parent) 236 | 237 | self._on_select_callback = on_select_callback 238 | self._values = {} 239 | 240 | r = 0 241 | self._lb = tk.Listbox( 242 | self, width=width, height=height, selectmode=selectmode, exportselection=0 243 | ) 244 | self._lb.grid(row=r, column=0, sticky="ew") 245 | [self._lb.insert("end", option) for option in options] 246 | self._lb.bind("<>", lambda _: self._on_select()) 247 | 248 | r += 1 249 | clear_label = tk.Label(self, text="clear", fg="blue") 250 | clear_label.grid(row=r, column=0, sticky="ew") 251 | clear_label.bind("", lambda _: self._clear_selected()) 252 | 253 | def _on_select(self): 254 | self.after(200, self.__on_select) 255 | 256 | def _clear_selected(self): 257 | for i in self._lb.curselection(): 258 | self._lb.selection_clear(i, "end") 259 | 260 | while len(self._values): 261 | self._values.popitem() 262 | 263 | if self._on_select_callback is not None: 264 | values = list(self._values.keys()) 265 | try: 266 | self._on_select_callback(values) 267 | except TypeError: 268 | self._on_select_callback() 269 | 270 | def __on_select(self): 271 | value = self._lb.get("active") 272 | 273 | if self._lb.cget("selectmode") == "multiple": 274 | if value in self._values.keys(): 275 | self._values.pop(value) 276 | else: 277 | self._values[value] = True 278 | else: 279 | while len(self._values): 280 | self._values.popitem() 281 | self._values[value] = True 282 | 283 | if self._on_select_callback is not None: 284 | values = list(self._values.keys()) 285 | try: 286 | self._on_select_callback(values) 287 | except TypeError: 288 | self._on_select_callback() 289 | 290 | def add_callback(self, callback: callable): 291 | """ 292 | Associates a callback function when the user makes a selection. 293 | 294 | :param callback: a callable function 295 | """ 296 | self._on_select_callback = callback 297 | 298 | def get_selected(self): 299 | return list(self._values.keys()) 300 | 301 | def select(self, value): 302 | options = self._lb.get(0, "end") 303 | if value not in options: 304 | raise ValueError("Not a valid selection") 305 | 306 | option = options.index(value) 307 | 308 | self._lb.activate(option) 309 | self._values[value] = True 310 | 311 | 312 | class BinaryLabel(ttk.Label): 313 | """ 314 | Displays a value binary. Provides methods for 315 | easy manipulation of bit values.:: 316 | 317 | # create the label and grid 318 | bl = BinaryLabel(root, 255) 319 | bl.grid() 320 | 321 | # toggle highest bit 322 | bl.toggle_msb() 323 | 324 | :param parent: the tk parent frame 325 | :param value: the initial value, default is 0 326 | :param options: prefix string for identifiers 327 | """ 328 | 329 | def __init__( 330 | self, parent, value: int = 0, prefix: str = "", bit_width: int = 8, **options 331 | ): 332 | self._parent = parent 333 | super().__init__(self._parent, **options) 334 | 335 | self._value = value 336 | self._prefix = prefix 337 | self._bit_width = bit_width 338 | self._text_update() 339 | 340 | def get(self): 341 | """ 342 | Return the current value 343 | 344 | :return: the current integer value 345 | """ 346 | return self._value 347 | 348 | def set(self, value: int): 349 | """ 350 | Set the current value 351 | 352 | :param value: 353 | :return: None 354 | """ 355 | max_value = int("".join(["1" for _ in range(self._bit_width)]), 2) 356 | 357 | if value > max_value: 358 | raise ValueError( 359 | "the value {} is larger than " 360 | "the maximum value {}".format(value, max_value) 361 | ) 362 | 363 | self._value = value 364 | self._text_update() 365 | 366 | def _text_update(self): 367 | self["text"] = ( 368 | self._prefix 369 | + str(bin(self._value))[2:].zfill(self._bit_width)[-self._bit_width :] 370 | ) 371 | 372 | def get_bit(self, position: int): 373 | """ 374 | Returns the bit value at position 375 | 376 | :param position: integer between 0 and , inclusive 377 | :return: the value at position as a integer 378 | """ 379 | 380 | if position > (self._bit_width - 1): 381 | raise ValueError("position greater than the bit width") 382 | 383 | if self._value & (1 << position): 384 | return 1 385 | else: 386 | return 0 387 | 388 | def toggle_bit(self, position: int): 389 | """ 390 | Toggles the value at position 391 | 392 | :param position: integer between 0 and 7, inclusive 393 | :return: None 394 | """ 395 | if position > (self._bit_width - 1): 396 | raise ValueError("position greater than the bit width") 397 | 398 | self._value ^= 1 << position 399 | self._text_update() 400 | 401 | def set_bit(self, position: int): 402 | """ 403 | Sets the value at position 404 | 405 | :param position: integer between 0 and 7, inclusive 406 | :return: None 407 | """ 408 | if position > (self._bit_width - 1): 409 | raise ValueError("position greater than the bit width") 410 | 411 | self._value |= 1 << position 412 | self._text_update() 413 | 414 | def clear_bit(self, position: int): 415 | """ 416 | Clears the value at position 417 | 418 | :param position: integer between 0 and 7, inclusive 419 | :return: None 420 | """ 421 | if position > (self._bit_width - 1): 422 | raise ValueError("position greater than the bit width") 423 | 424 | self._value &= ~(1 << position) 425 | self._text_update() 426 | 427 | def get_msb(self): 428 | """ 429 | Returns the most significant bit as an integer 430 | :return: the MSB 431 | """ 432 | return self.get_bit(self._bit_width - 1) 433 | 434 | def toggle_msb(self): 435 | """ 436 | Changes the most significant bit 437 | :return: None 438 | """ 439 | self.toggle_bit(self._bit_width - 1) 440 | 441 | def get_lsb(self): 442 | """ 443 | Returns the least significant bit as an integer 444 | :return: the LSB 445 | """ 446 | return self.get_bit(0) 447 | 448 | def set_msb(self): 449 | """ 450 | Sets the most significant bit 451 | :return: None 452 | """ 453 | self.set_bit(self._bit_width - 1) 454 | 455 | def clear_msb(self): 456 | """ 457 | Clears the most significant bit 458 | :return: None 459 | """ 460 | self.clear_bit(self._bit_width - 1) 461 | 462 | def toggle_lsb(self): 463 | """ 464 | Toggles the least significant bit 465 | :return: 466 | """ 467 | self.toggle_bit(0) 468 | 469 | def set_lsb(self): 470 | """ 471 | Sets the least significant bit 472 | :return: None 473 | """ 474 | self.set_bit(0) 475 | 476 | def clear_lsb(self): 477 | """ 478 | Clears the least significant bit 479 | :return: None 480 | """ 481 | self.clear_bit(0) 482 | 483 | 484 | class ByteLabel(BinaryLabel): 485 | """ 486 | Has been replaced with more general BinaryLabel. 487 | Still here for backwards compatibility. 488 | """ 489 | 490 | pass 491 | 492 | 493 | class ScrollableFrame(ttk.Frame): 494 | """ 495 | Add scrollable frame which will automatically adjust to the contents. Note that widgets must be packed or \ 496 | gridded on the ``scrollable_frame`` widget contained within the object. 497 | 498 | Example: 499 | 500 | root = tk.Tk() 501 | 502 | sf = ScrollableFrame(root) 503 | sf.pack() 504 | 505 | # add a long list of widgets 506 | def add_widget(i): 507 | tk.Label(sf.scrollable_frame, # <--- note that widgets are being added to the scrollable_frame! 508 | text=f'label {i}').grid(sticky='w') 509 | 510 | for i in range(40): 511 | sf.after(i*100, lambda i=i: add_widget(i)) 512 | 513 | root.mainloop() 514 | 515 | :param master: the master widget 516 | :param height: an integer specifying the height in pixels 517 | :param args: the arguments to pass along to the frame 518 | :param kwargs: the arguments/options to pass along to the frame 519 | """ 520 | 521 | def __init__( 522 | self, 523 | master: Union[tk.Frame, ttk.Frame, tk.Toplevel, tk.Tk], 524 | height: int = 400, 525 | *args, 526 | **kwargs, 527 | ): 528 | super().__init__(master, *args, **kwargs) 529 | self._parent = master 530 | 531 | self._canvas = tk.Canvas(self, height=height) 532 | scrollbar = ttk.Scrollbar(self, orient="vertical", command=self._canvas.yview) 533 | self.scrollable_frame = ttk.Frame(self._canvas) 534 | 535 | self.scrollable_frame.bind( 536 | "", 537 | lambda e: self._canvas.configure( 538 | scrollregion=self._canvas.bbox("all"), width=e.width 539 | ), 540 | ) 541 | 542 | self._canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") 543 | 544 | self._canvas.configure(yscrollcommand=scrollbar.set) 545 | 546 | self._canvas.pack(side="left", fill="both", expand=True) 547 | scrollbar.pack(side="right", fill="y") 548 | 549 | self._canvas.bind_all("", self._on_mousewheel) 550 | 551 | def _on_mousewheel(self, event): 552 | self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") 553 | 554 | 555 | if __name__ == "__main__": 556 | root = tk.Tk() 557 | 558 | sf = ScrollableFrame(root) 559 | sf.pack() 560 | 561 | def add_widget(i): 562 | tk.Label(sf.scrollable_frame, text=f"label {i}").grid(sticky="w") 563 | 564 | for i in range(40): 565 | sf.after(i * 100, lambda i=i: add_widget(i)) 566 | 567 | root.mainloop() 568 | -------------------------------------------------------------------------------- /tk_tools/canvas.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import cmath 3 | import sys 4 | import logging 5 | from decimal import Decimal 6 | 7 | # these imports make autodoc easier to run 8 | try: 9 | from engineering_notation import EngNumber 10 | except ImportError: 11 | pass 12 | 13 | try: 14 | from tk_tools.images import ( 15 | rotary_scale, 16 | led_green, 17 | led_green_on, 18 | led_yellow, 19 | led_yellow_on, 20 | led_red, 21 | led_red_on, 22 | led_grey, 23 | ) 24 | except ImportError: 25 | pass 26 | 27 | logger = logging.getLogger(__name__) 28 | logger.setLevel(logging.DEBUG) 29 | 30 | 31 | if getattr(sys, "frozen", False): 32 | frozen = True 33 | else: 34 | frozen = False 35 | 36 | logger.info("frozen: {}".format(frozen)) 37 | 38 | 39 | class Dial(tk.Frame): 40 | """ 41 | Base class for all dials and dial-like widgets 42 | """ 43 | 44 | def __init__(self, parent, size: int = 100, **options): 45 | self._parent = parent 46 | super().__init__(self._parent, padx=3, pady=3, borderwidth=2, **options) 47 | 48 | self.size = size 49 | 50 | def to_absolute(self, x, y): 51 | """ 52 | Converts coordinates provided with reference to the center \ 53 | of the canvas (0, 0) to absolute coordinates which are used \ 54 | by the canvas object in which (0, 0) is located in the top \ 55 | left of the object. 56 | 57 | :param x: x value in pixels 58 | :param y: x value in pixels 59 | :return: None 60 | """ 61 | return x + self.size / 2, y + self.size / 2 62 | 63 | 64 | class Compass(Dial): 65 | """ 66 | Displays a compass typically seen on a map 67 | """ 68 | 69 | def __init__(self, parent, size: int = 100, **options): 70 | super().__init__(parent, size=size, **options) 71 | raise NotImplementedError() 72 | 73 | # todo: import an image, place the image on the canvas, then place 74 | # an arrow on top of the image 75 | 76 | 77 | class RotaryScale(Dial): 78 | """ 79 | Shows a rotary scale, much like a speedometer.:: 80 | 81 | rs = tk_tools.RotaryScale(root, max_value=100.0, size=100, unit='km/h') 82 | rs.grid(row=0, column=0) 83 | 84 | rs.set_value(10) 85 | 86 | :param parent: tkinter parent frame 87 | :param max_value: the value corresponding to the maximum value on the scale 88 | :param size: the size in pixels 89 | :param options: the frame options 90 | """ 91 | 92 | def __init__( 93 | self, 94 | parent, 95 | max_value: (float, int) = 100.0, 96 | size: (float, int) = 100, 97 | unit: str = None, 98 | img_data: str = None, 99 | needle_color="blue", 100 | needle_thickness=0, 101 | **options 102 | ): 103 | super().__init__(parent, size=size, **options) 104 | 105 | self.max_value = float(max_value) 106 | self.size = size 107 | self.unit = "" if not unit else unit 108 | self.needle_color = needle_color 109 | self.needle_thickness = needle_thickness 110 | 111 | self.canvas = tk.Canvas(self, width=self.size, height=self.size) 112 | self.canvas.grid(row=0) 113 | self.readout = tk.Label(self, text="-{}".format(self.unit)) 114 | self.readout.grid(row=1) 115 | 116 | if img_data: 117 | self.image = tk.PhotoImage(data=img_data) 118 | else: 119 | self.image = tk.PhotoImage(data=rotary_scale) 120 | 121 | self.image = self.image.subsample(int(200 / self.size), int(200 / self.size)) 122 | 123 | initial_value = 0.0 124 | self.set_value(initial_value) 125 | 126 | def set_value(self, number: (float, int)): 127 | """ 128 | Sets the value of the graphic 129 | 130 | :param number: the number (must be between 0 and \ 131 | 'max_range' or the scale will peg the limits 132 | :return: None 133 | """ 134 | self.canvas.delete("all") 135 | self.canvas.create_image(0, 0, image=self.image, anchor="nw") 136 | 137 | number = number if number <= self.max_value else self.max_value 138 | number = 0.0 if number < 0.0 else number 139 | 140 | radius = 0.9 * self.size / 2.0 141 | angle_in_radians = (2.0 * cmath.pi / 3.0) + number / self.max_value * ( 142 | 5.0 * cmath.pi / 3.0 143 | ) 144 | 145 | center = cmath.rect(0, 0) 146 | outer = cmath.rect(radius, angle_in_radians) 147 | if self.needle_thickness == 0: 148 | line_width = int(5 * self.size / 200) 149 | line_width = 1 if line_width < 1 else line_width 150 | else: 151 | line_width = self.needle_thickness 152 | 153 | self.canvas.create_line( 154 | *self.to_absolute(center.real, center.imag), 155 | *self.to_absolute(outer.real, outer.imag), 156 | width=line_width, 157 | fill=self.needle_color 158 | ) 159 | 160 | self.readout["text"] = "{}{}".format(number, self.unit) 161 | 162 | def _draw_background(self, divisions: int = 10): 163 | """ 164 | Draws the background of the dial 165 | 166 | :param divisions: the number of divisions 167 | between 'ticks' shown on the dial 168 | :return: None 169 | """ 170 | self.canvas.create_arc( 171 | 2, 172 | 2, 173 | self.size - 2, 174 | self.size - 2, 175 | style=tk.PIESLICE, 176 | start=-60, 177 | extent=30, 178 | fill="red", 179 | ) 180 | self.canvas.create_arc( 181 | 2, 182 | 2, 183 | self.size - 2, 184 | self.size - 2, 185 | style=tk.PIESLICE, 186 | start=-30, 187 | extent=60, 188 | fill="yellow", 189 | ) 190 | self.canvas.create_arc( 191 | 2, 192 | 2, 193 | self.size - 2, 194 | self.size - 2, 195 | style=tk.PIESLICE, 196 | start=30, 197 | extent=210, 198 | fill="green", 199 | ) 200 | 201 | # find the distance between the center and the inner tick radius 202 | inner_tick_radius = int(self.size * 0.4) 203 | outer_tick_radius = int(self.size * 0.5) 204 | 205 | for tick in range(divisions): 206 | angle_in_radians = (2.0 * cmath.pi / 3.0) + tick / divisions * ( 207 | 5.0 * cmath.pi / 3.0 208 | ) 209 | inner_point = cmath.rect(inner_tick_radius, angle_in_radians) 210 | outer_point = cmath.rect(outer_tick_radius, angle_in_radians) 211 | 212 | self.canvas.create_line( 213 | *self.to_absolute(inner_point.real, inner_point.imag), 214 | *self.to_absolute(outer_point.real, outer_point.imag), 215 | width=1 216 | ) 217 | 218 | 219 | class Gauge(tk.Frame): 220 | """ 221 | Shows a gauge, much like the RotaryGauge.:: 222 | 223 | gauge = tk_tools.Gauge(root, max_value=100.0, 224 | label='speed', unit='km/h') 225 | gauge.grid() 226 | gauge.set_value(10) 227 | 228 | :param parent: tkinter parent frame 229 | :param width: canvas width 230 | :param height: canvas height 231 | :param min_value: the minimum value 232 | :param max_value: the maximum value 233 | :param label: the label on the scale 234 | :param unit: the unit to show on the scale 235 | :param divisions: the number of divisions on the scale 236 | :param yellow: the beginning of the yellow (warning) zone in percent 237 | :param red: the beginning of the red (danger) zone in percent 238 | :param yellow_low: in percent warning for low values 239 | :param red_low: in percent if very low values are a danger 240 | :param bg: background 241 | """ 242 | 243 | def __init__( 244 | self, 245 | parent, 246 | width: int = 200, 247 | height: int = 100, 248 | min_value=0.0, 249 | max_value=100.0, 250 | label="", 251 | unit="", 252 | divisions=8, 253 | yellow=50, 254 | red=80, 255 | yellow_low=0, 256 | red_low=0, 257 | bg="lightgrey", 258 | ): 259 | self._parent = parent 260 | self._width = width 261 | self._height = height 262 | self._label = label 263 | self._unit = unit 264 | self._divisions = divisions 265 | self._min_value = EngNumber(min_value) 266 | self._max_value = EngNumber(max_value) 267 | self._average_value = EngNumber((max_value + min_value) / 2) 268 | self._yellow = yellow * 0.01 269 | self._red = red * 0.01 270 | self._yellow_low = yellow_low * 0.01 271 | self._red_low = red_low * 0.01 272 | 273 | super().__init__(self._parent) 274 | 275 | self._canvas = tk.Canvas(self, width=self._width, height=self._height, bg=bg) 276 | self._canvas.grid(row=0, column=0, sticky="news") 277 | self._min_value = EngNumber(min_value) 278 | self._max_value = EngNumber(max_value) 279 | self._value = self._min_value 280 | self._redraw() 281 | 282 | def _redraw(self): 283 | self._canvas.delete("all") 284 | max_angle = 120.0 285 | value_as_percent = (self._value - self._min_value) / ( 286 | self._max_value - self._min_value 287 | ) 288 | value = float(max_angle * value_as_percent) 289 | # no int() => accuracy 290 | # create the tick marks and colors across the top 291 | for i in range(self._divisions): 292 | extent = max_angle / self._divisions 293 | start = 150.0 - i * extent 294 | rate = (i + 1) / (self._divisions + 1) 295 | if rate < self._red_low: 296 | bg_color = "red" 297 | elif rate <= self._yellow_low: 298 | bg_color = "yellow" 299 | elif rate <= self._yellow: 300 | bg_color = "green" 301 | elif rate <= self._red: 302 | bg_color = "yellow" 303 | else: 304 | bg_color = "red" 305 | 306 | self._canvas.create_arc( 307 | 0, 308 | int(self._height * 0.15), 309 | self._width, 310 | int(self._height * 1.8), 311 | start=start, 312 | extent=-extent, 313 | width=2, 314 | fill=bg_color, 315 | style="pie", 316 | ) 317 | bg_color = "white" 318 | red = "#c21807" 319 | ratio = 0.06 320 | self._canvas.create_arc( 321 | self._width * ratio, 322 | int(self._height * 0.25), 323 | self._width * (1.0 - ratio), 324 | int(self._height * 1.8 * (1.0 - ratio * 1.1)), 325 | start=150, 326 | extent=-120, 327 | width=2, 328 | fill=bg_color, 329 | style="pie", 330 | ) 331 | # readout & title 332 | self.readout(self._value, "black") # BG black if OK 333 | 334 | # display lowest value 335 | value_text = "{}".format(self._min_value) 336 | self._canvas.create_text( 337 | self._width * 0.1, 338 | self._height * 0.7, 339 | font=("Courier New", 10), 340 | text=value_text, 341 | ) 342 | # display greatest value 343 | value_text = "{}".format(self._max_value) 344 | self._canvas.create_text( 345 | self._width * 0.9, 346 | self._height * 0.7, 347 | font=("Courier New", 10), 348 | text=value_text, 349 | ) 350 | # display center value 351 | value_text = "{}".format(self._average_value) 352 | self._canvas.create_text( 353 | self._width * 0.5, 354 | self._height * 0.1, 355 | font=("Courier New", 10), 356 | text=value_text, 357 | ) 358 | # create first half (red needle) 359 | self._canvas.create_arc( 360 | 0, 361 | int(self._height * 0.15), 362 | self._width, 363 | int(self._height * 1.8), 364 | start=150, 365 | extent=-value, 366 | width=3, 367 | outline=red, 368 | ) 369 | 370 | # create inset red 371 | self._canvas.create_arc( 372 | self._width * 0.35, 373 | int(self._height * 0.75), 374 | self._width * 0.65, 375 | int(self._height * 1.2), 376 | start=150, 377 | extent=-120, 378 | width=1, 379 | outline="grey", 380 | fill=red, 381 | style="pie", 382 | ) 383 | 384 | # create the overlapping border 385 | self._canvas.create_arc( 386 | 0, 387 | int(self._height * 0.15), 388 | self._width, 389 | int(self._height * 1.8), 390 | start=150, 391 | extent=-120, 392 | width=4, 393 | outline="#343434", 394 | ) 395 | 396 | def readout(self, value, bg): # value, BG color 397 | # draw the black behind the readout 398 | r_width = 95 399 | r_height = 20 400 | r_offset = 8 401 | self._canvas.create_rectangle( 402 | self._width / 2.0 - r_width / 2.0, 403 | self._height / 2.0 - r_height / 2.0 + r_offset, 404 | self._width / 2.0 + r_width / 2.0, 405 | self._height / 2.0 + r_height / 2.0 + r_offset, 406 | fill=bg, 407 | outline="grey", 408 | ) 409 | # the digital readout 410 | self._canvas.create_text( 411 | self._width * 0.5, 412 | self._height * 0.5 - r_offset, 413 | font=("Courier New", 10), 414 | text=self._label, 415 | ) 416 | 417 | value_text = "{}{}".format(self._value, self._unit) 418 | self._canvas.create_text( 419 | self._width * 0.5, 420 | self._height * 0.5 + r_offset, 421 | font=("Courier New", 10), 422 | text=value_text, 423 | fill="white", 424 | ) 425 | 426 | def set_value(self, value): 427 | self._value = EngNumber(value) 428 | if self._min_value * 1.02 < value < self._max_value * 0.98: 429 | self._redraw() # refresh all 430 | else: # OFF limits refresh only readout 431 | self.readout(self._value, "red") # on RED BackGround 432 | 433 | 434 | class Graph(tk.Frame): 435 | """ 436 | Tkinter native graph (pretty basic, but doesn't require heavy install).:: 437 | 438 | graph = tk_tools.Graph( 439 | parent=root, 440 | x_min=-1.0, 441 | x_max=1.0, 442 | y_min=0.0, 443 | y_max=2.0, 444 | x_tick=0.2, 445 | y_tick=0.2, 446 | width=500, 447 | height=400 448 | ) 449 | 450 | graph.grid(row=0, column=0) 451 | 452 | # create an initial line 453 | line_0 = [(x/10, x/10) for x in range(10)] 454 | graph.plot_line(line_0) 455 | 456 | :param parent: the parent frame 457 | :param x_min: the x minimum 458 | :param x_max: the x maximum 459 | :param y_min: the y minimum 460 | :param y_max: the y maximum 461 | :param x_tick: the 'tick' on the x-axis 462 | :param y_tick: the 'tick' on the y-axis 463 | :param options: additional valid tkinter.canvas options 464 | """ 465 | 466 | def __init__( 467 | self, 468 | parent, 469 | x_min: float, 470 | x_max: float, 471 | y_min: float, 472 | y_max: float, 473 | x_tick: float, 474 | y_tick: float, 475 | **options 476 | ): 477 | self._parent = parent 478 | super().__init__(self._parent, **options) 479 | 480 | self.canvas = tk.Canvas(self) 481 | self.canvas.grid(row=0, column=0) 482 | 483 | self.w = float(self.canvas.config("width")[4]) 484 | self.h = float(self.canvas.config("height")[4]) 485 | self.x_min = x_min 486 | self.x_max = x_max 487 | self.x_tick = x_tick 488 | self.y_min = y_min 489 | self.y_max = y_max 490 | self.y_tick = y_tick 491 | self.px_x = (self.w - 100) / ((x_max - x_min) / x_tick) 492 | self.px_y = (self.h - 100) / ((y_max - y_min) / y_tick) 493 | 494 | self.draw_axes() 495 | 496 | def draw_axes(self): 497 | """ 498 | Removes all existing series and re-draws the axes. 499 | 500 | :return: None 501 | """ 502 | self.canvas.delete("all") 503 | rect = 50, 50, self.w - 50, self.h - 50 504 | 505 | self.canvas.create_rectangle(rect, outline="black") 506 | 507 | for x in self.frange(0, self.x_max - self.x_min + 1, self.x_tick): 508 | value = Decimal(self.x_min + x) 509 | if self.x_min <= value <= self.x_max: 510 | x_step = (self.px_x * x) / self.x_tick 511 | coord = 50 + x_step, self.h - 50, 50 + x_step, self.h - 45 512 | self.canvas.create_line(coord, fill="black") 513 | coord = 50 + x_step, self.h - 40 514 | 515 | label = round(Decimal(self.x_min + x), 1) 516 | self.canvas.create_text(coord, fill="black", text=label) 517 | 518 | for y in self.frange(0, self.y_max - self.y_min + 1, self.y_tick): 519 | value = Decimal(self.y_max - y) 520 | 521 | if self.y_min <= value <= self.y_max: 522 | y_step = (self.px_y * y) / self.y_tick 523 | coord = 45, 50 + y_step, 50, 50 + y_step 524 | self.canvas.create_line(coord, fill="black") 525 | coord = 35, 50 + y_step 526 | 527 | label = round(value, 1) 528 | self.canvas.create_text(coord, fill="black", text=label) 529 | 530 | def plot_point(self, x, y, visible=True, color="black", size=5): 531 | """ 532 | Places a single point on the grid 533 | 534 | :param x: the x coordinate 535 | :param y: the y coordinate 536 | :param visible: True if the individual point should be visible 537 | :param color: the color of the point 538 | :param size: the point size in pixels 539 | :return: The absolute coordinates as a tuple 540 | """ 541 | xp = (self.px_x * (x - self.x_min)) / self.x_tick 542 | yp = (self.px_y * (self.y_max - y)) / self.y_tick 543 | coord = 50 + xp, 50 + yp 544 | 545 | if visible: 546 | # divide down to an appropriate size 547 | size = int(size / 2) if int(size / 2) > 1 else 1 548 | x, y = coord 549 | 550 | self.canvas.create_oval(x - size, y - size, x + size, y + size, fill=color) 551 | 552 | return coord 553 | 554 | def plot_line(self, points: list, color="black", point_visibility=False): 555 | """ 556 | Plot a line of points 557 | 558 | :param points: a list of tuples, each tuple containing an (x, y) point 559 | :param color: the color of the line 560 | :param point_visibility: True if the points \ 561 | should be individually visible 562 | :return: None 563 | """ 564 | last_point = () 565 | for point in points: 566 | this_point = self.plot_point( 567 | point[0], point[1], color=color, visible=point_visibility 568 | ) 569 | 570 | if last_point: 571 | self.canvas.create_line(last_point + this_point, fill=color) 572 | last_point = this_point 573 | # print last_point 574 | 575 | @staticmethod 576 | def frange(start, stop, step, digits_to_round=3): 577 | """ 578 | Works like range for doubles 579 | 580 | :param start: starting value 581 | :param stop: ending value 582 | :param step: the increment_value 583 | :param digits_to_round: the digits to which to round \ 584 | (makes floating-point numbers much easier to work with) 585 | :return: generator 586 | """ 587 | while start < stop: 588 | yield round(start, digits_to_round) 589 | start += step 590 | 591 | 592 | class Led(tk.Frame): 593 | """ 594 | Create an LED-like interface for the user.:: 595 | 596 | led = tk_tools.Led(root, size=50) 597 | led.pack() 598 | 599 | led.to_red() 600 | led.to_green(on=True) 601 | 602 | The user also has the option of adding an `on_click_callback` 603 | function. When the button is clicked, the button will change 604 | state and the on-click callback will be executed. The 605 | callback must accept a single boolean parameter, `on`, which 606 | indicates if the LED was just turned on or off. 607 | 608 | :param parent: the parent frame 609 | :param size: the size in pixels 610 | :param on_click_callback: a callback which accepts a boolean parameter 'on' 611 | :param options: the frame options 612 | """ 613 | 614 | def __init__( 615 | self, 616 | parent, 617 | size: int = 100, 618 | on_click_callback: callable = None, 619 | toggle_on_click: bool = False, 620 | **options 621 | ): 622 | self._parent = parent 623 | super().__init__(self._parent, padx=3, pady=3, borderwidth=0, **options) 624 | 625 | self._size = size 626 | 627 | canvas_opts = {} 628 | if "bg" in options.keys(): 629 | canvas_opts["bg"] = options.get("bg") 630 | canvas_opts["highlightthickness"] = 0 631 | canvas_opts["width"] = self._size 632 | canvas_opts["height"] = self._size 633 | self._canvas = tk.Canvas(self, **canvas_opts) 634 | self._canvas.grid(row=0) 635 | self._image = None 636 | self._on = False 637 | self._user_click_callback = on_click_callback 638 | self._toggle_on_click = toggle_on_click 639 | 640 | self.to_grey() 641 | 642 | def on_click(e): 643 | if self._user_click_callback is not None: 644 | self._user_click_callback(self._on) 645 | 646 | self._canvas.bind("", on_click) 647 | 648 | def _load_new(self, img_data: str): 649 | """ 650 | Load a new image. 651 | 652 | :param img_data: the image data as a base64 string 653 | :return: None 654 | """ 655 | self._image = tk.PhotoImage(data=img_data) 656 | self._image = self._image.subsample( 657 | int(200 / self._size), int(200 / self._size) 658 | ) 659 | self._canvas.delete("all") 660 | self._canvas.create_image(0, 0, image=self._image, anchor="nw") 661 | 662 | if self._user_click_callback is not None: 663 | self._user_click_callback(self._on) 664 | 665 | def to_grey(self, on: bool = False): 666 | """ 667 | Change the LED to grey. 668 | 669 | :param on: Unused, here for API consistency with the other states 670 | :return: None 671 | """ 672 | self._on = False 673 | self._load_new(led_grey) 674 | 675 | def to_green(self, on: bool = False): 676 | """ 677 | Change the LED to green (on or off). 678 | 679 | :param on: True or False 680 | :return: None 681 | """ 682 | self._on = on 683 | if on: 684 | self._load_new(led_green_on) 685 | 686 | if self._toggle_on_click: 687 | self._canvas.bind("", lambda x: self.to_green(False)) 688 | else: 689 | self._load_new(led_green) 690 | 691 | if self._toggle_on_click: 692 | self._canvas.bind("", lambda x: self.to_green(True)) 693 | 694 | def to_red(self, on: bool = False): 695 | """ 696 | Change the LED to red (on or off) 697 | :param on: True or False 698 | :return: None 699 | """ 700 | self._on = on 701 | if on: 702 | self._load_new(led_red_on) 703 | 704 | if self._toggle_on_click: 705 | self._canvas.bind("", lambda x: self.to_red(False)) 706 | else: 707 | self._load_new(led_red) 708 | 709 | if self._toggle_on_click: 710 | self._canvas.bind("", lambda x: self.to_red(True)) 711 | 712 | def to_yellow(self, on: bool = False): 713 | """ 714 | Change the LED to yellow (on or off) 715 | :param on: True or False 716 | :return: None 717 | """ 718 | self._on = on 719 | if on: 720 | self._load_new(led_yellow_on) 721 | 722 | if self._toggle_on_click: 723 | self._canvas.bind("", lambda x: self.to_yellow(False)) 724 | else: 725 | self._load_new(led_yellow) 726 | 727 | if self._toggle_on_click: 728 | self._canvas.bind("", lambda x: self.to_yellow(True)) 729 | -------------------------------------------------------------------------------- /tk_tools/groups.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | import tkinter.ttk as ttk 3 | from tkinter.font import Font 4 | 5 | import datetime 6 | import calendar 7 | from collections import OrderedDict 8 | 9 | try: 10 | from tk_tools.images import minus 11 | except ImportError: 12 | minus = "" 13 | 14 | 15 | class _Grid(ttk.Frame): 16 | padding = 3 17 | 18 | """ 19 | Creates a grid of widgets (intended to be subclassed). 20 | 21 | :param parent: the tk parent element of this frame 22 | :param num_of_columns: the number of columns contained of the grid 23 | :param headers: a list containing the names of the column headers 24 | """ 25 | 26 | def __init__(self, parent, num_of_columns: int, headers: list = None, **options): 27 | self._parent = parent 28 | super().__init__(self._parent, padding=3, borderwidth=2, **options) 29 | self.grid() 30 | 31 | self.headers = list() 32 | self._rows = list() 33 | self.num_of_columns = num_of_columns 34 | self._has_row_labels = False 35 | 36 | # do some validation 37 | if headers: 38 | if len(headers) != num_of_columns: 39 | raise ValueError 40 | 41 | for i, element in enumerate(headers): 42 | label = ttk.Label( 43 | self, text=str(element), relief=tk.GROOVE, padding=self.padding 44 | ) 45 | label.grid(row=0, column=i, sticky="E,W") 46 | self.headers.append(label) 47 | 48 | def add_row(self, data: list): 49 | """ 50 | Adds a row of data based on the entered data 51 | 52 | :param data: row of data as a list 53 | :return: None 54 | """ 55 | raise NotImplementedError 56 | 57 | def _redraw(self): 58 | """ 59 | Forgets the current layout and redraws with the most recent information 60 | 61 | :return: None 62 | """ 63 | for widget in self.headers: 64 | widget.grid_forget() 65 | 66 | for row in self._rows: 67 | for widget in row: 68 | widget.grid_forget() 69 | 70 | for i, widget in enumerate(self.headers): 71 | widget.grid(row=0, column=i, sticky="E,W") 72 | 73 | r = 0 if not self.headers else 1 74 | for i, row in enumerate(self._rows): 75 | for j, widget in enumerate(row): 76 | widget.grid(row=i + r, column=j) 77 | 78 | def remove_row(self, row_number: int = -1): 79 | """ 80 | Removes a specified row of data 81 | 82 | :param row_number: the row to remove (defaults to the last row) 83 | :return: None 84 | """ 85 | if len(self._rows) == 0: 86 | return 87 | 88 | row = self._rows.pop(row_number) 89 | for widget in row: 90 | widget.destroy() 91 | 92 | def clear(self): 93 | """ 94 | Removes all elements of the grid 95 | 96 | :return: None 97 | """ 98 | for i in range(len(self._rows)): 99 | self.remove_row(0) 100 | 101 | 102 | class LabelGrid(_Grid): 103 | """ 104 | A table-like display widget. 105 | 106 | :param parent: the tk parent element of this frame 107 | :param num_of_columns: the number of columns contained of the grid 108 | :param headers: a list containing the names of the column headers 109 | """ 110 | 111 | def __init__(self, parent, num_of_columns: int, headers: list = None, **options): 112 | self._parent = parent 113 | super().__init__(self._parent, num_of_columns, headers, **options) 114 | 115 | def add_row(self, data: list): 116 | """ 117 | Add a row of data to the current widget 118 | 119 | :param data: a row of data 120 | :return: None 121 | """ 122 | # validation 123 | if self.headers: 124 | if len(self.headers) != len(data): 125 | raise ValueError 126 | 127 | if len(data) != self.num_of_columns: 128 | raise ValueError 129 | 130 | offset = 0 if not self.headers else 1 131 | row = list() 132 | for i, element in enumerate(data): 133 | label = ttk.Label( 134 | self, text=str(element), relief=tk.GROOVE, padding=self.padding 135 | ) 136 | label.grid(row=len(self._rows) + offset, column=i, sticky="E,W") 137 | row.append(label) 138 | 139 | self._rows.append(row) 140 | 141 | 142 | class EntryGrid(_Grid): 143 | """ 144 | Add a spreadsheet-like grid of entry widgets. 145 | 146 | :param parent: the tk parent element of this frame 147 | :param num_of_columns: the number of columns contained of the grid 148 | :param headers: a list containing the names of the column headers 149 | """ 150 | 151 | def __init__(self, parent, num_of_columns: int, headers: list = None, **options): 152 | super().__init__(parent, num_of_columns, headers, **options) 153 | 154 | def add_row(self, data: list = None): 155 | """ 156 | Add a row of data to the current widget, add a \ 157 | binding to the last element of the last row, and set \ 158 | the focus at the beginning of the next row. 159 | 160 | :param data: a row of data 161 | :return: None 162 | """ 163 | # validation 164 | if self.headers and data: 165 | if len(self.headers) != len(data): 166 | raise ValueError 167 | 168 | offset = 0 if not self.headers else 1 169 | row = list() 170 | 171 | if data: 172 | for i, element in enumerate(data): 173 | contents = "" if element is None else str(element) 174 | entry = ttk.Entry(self) 175 | entry.insert(0, contents) 176 | entry.grid(row=len(self._rows) + offset, column=i, sticky="E,W") 177 | row.append(entry) 178 | else: 179 | for i in range(self.num_of_columns): 180 | entry = ttk.Entry(self) 181 | entry.grid(row=len(self._rows) + offset, column=i, sticky="E,W") 182 | row.append(entry) 183 | 184 | self._rows.append(row) 185 | 186 | # clear all bindings 187 | for row in self._rows: 188 | for widget in row: 189 | widget.unbind("") 190 | 191 | def add(e): 192 | self.add_row() 193 | 194 | last_entry = self._rows[-1][-1] 195 | last_entry.bind("", add) 196 | 197 | e = self._rows[-1][0] 198 | e.focus_set() 199 | 200 | self._redraw() 201 | 202 | def _read_as_dict(self): 203 | """ 204 | Read the data contained in all entries as a list of 205 | dictionaries with the headers as the dictionary keys 206 | 207 | :return: list of dicts containing all tabular data 208 | """ 209 | data = list() 210 | for row in self._rows: 211 | row_data = OrderedDict() 212 | for i, header in enumerate(self.headers): 213 | row_data[header.cget("text")] = row[i].get() 214 | 215 | data.append(row_data) 216 | 217 | return data 218 | 219 | def _read_as_table(self): 220 | """ 221 | Read the data contained in all entries as a list of 222 | lists containing all of the data 223 | 224 | :return: list of dicts containing all tabular data 225 | """ 226 | rows = list() 227 | 228 | for row in self._rows: 229 | rows.append([row[i].get() for i in range(self.num_of_columns)]) 230 | 231 | return rows 232 | 233 | def read(self, as_dicts=True): 234 | """ 235 | Read the data from the entry fields 236 | 237 | :param as_dicts: True if list of dicts required, else False 238 | :return: entries as a dict or table 239 | """ 240 | if as_dicts: 241 | return self._read_as_dict() 242 | else: 243 | return self._read_as_table() 244 | 245 | 246 | class ButtonGrid(_Grid): 247 | """ 248 | A grid of buttons. 249 | 250 | :param parent: the tk parent element of this frame 251 | :param num_of_columns: the number of columns contained of the grid 252 | :param headers: a list containing the names of the column headers 253 | """ 254 | 255 | def __init__(self, parent, num_of_columns: int, headers: list = None, **options): 256 | super().__init__(parent, num_of_columns, headers, **options) 257 | 258 | def add_row(self, data: list, row_label: str = None): 259 | """ 260 | Add a row of buttons each with their own callbacks to the 261 | current widget. Each element in `data` will consist of a 262 | label and a command. 263 | :param data: a list of tuples of the form ('label', ) 264 | :return: None 265 | """ 266 | 267 | # validation 268 | if self.headers and data: 269 | if len(self.headers) != len(data): 270 | raise ValueError 271 | 272 | for widget in self.headers: 273 | widget.grid_forget() 274 | 275 | for row in self._rows: 276 | for widget in row: 277 | widget.grid_forget() 278 | 279 | row = list() 280 | 281 | if row_label is not None: 282 | lbl = tk.Label(self, text=row_label) 283 | row.append(lbl) 284 | 285 | for i, e in enumerate(data): 286 | if not isinstance(e, tuple): 287 | raise ValueError( 288 | "all elements must be a tuple " 'consisting of ("label", )' 289 | ) 290 | 291 | label, command = e 292 | button = tk.Button( 293 | self, 294 | text=str(label), 295 | relief=tk.RAISED, 296 | command=command, 297 | padx=self.padding, 298 | pady=self.padding, 299 | ) 300 | 301 | row.append(button) 302 | 303 | self._rows.append(row) 304 | 305 | # check if row has row labels 306 | has_row_labels = False 307 | for row in self._rows: 308 | if isinstance(row[0], tk.Label): 309 | has_row_labels = True 310 | break 311 | 312 | r = 0 if not self.headers else 1 313 | 314 | for i, widget in enumerate(self.headers): 315 | if has_row_labels: 316 | widget.grid(row=0, column=i + 1, sticky="ew") 317 | else: 318 | widget.grid(row=0, column=i, sticky="ew") 319 | 320 | for i, row in enumerate(self._rows): 321 | for j, widget in enumerate(row): 322 | widget.grid(row=i + r, column=j, sticky="ew") 323 | 324 | 325 | class KeyValueEntry(ttk.Frame): 326 | """ 327 | Creates a key-value input/output frame. 328 | 329 | :param parent: the parent frame 330 | :param keys: the keys represented 331 | :param defaults: default values for each key 332 | :param unit_labels: unit labels for each key (to the right of the value) 333 | :param enables: True/False for each key 334 | :param title: The title of the block 335 | :param on_change_callback: a function callback when any element is changed 336 | :param options: frame tk options 337 | """ 338 | 339 | def __init__( 340 | self, 341 | parent, 342 | keys: list, 343 | defaults: list = None, 344 | unit_labels: list = None, 345 | enables: list = None, 346 | title: str = None, 347 | on_change_callback: callable = None, 348 | **options 349 | ): 350 | self._parent = parent 351 | super().__init__(self._parent, borderwidth=2, padding=5, **options) 352 | 353 | # some checks before proceeding 354 | if defaults: 355 | if len(keys) != len(defaults): 356 | raise ValueError("unit_labels length does not " "match keys length") 357 | if unit_labels: 358 | if len(keys) != len(unit_labels): 359 | raise ValueError("unit_labels length does not " "match keys length") 360 | if enables: 361 | if len(keys) != len(enables): 362 | raise ValueError("enables length does not " "match keys length") 363 | 364 | self.keys = [] 365 | self.values = [] 366 | self.defaults = [] 367 | self.unit_labels = [] 368 | self.enables = [] 369 | self.callback = on_change_callback 370 | 371 | if title is not None: 372 | self.title = ttk.Label(self, text=title) 373 | self.title.grid(row=0, column=0, columnspan=3) 374 | else: 375 | self.title = None 376 | 377 | for i in range(len(keys)): 378 | self.add_row( 379 | key=keys[i], 380 | default=defaults[i] if defaults else None, 381 | unit_label=unit_labels[i] if unit_labels else None, 382 | enable=enables[i] if enables else None, 383 | ) 384 | 385 | def add_row( 386 | self, key: str, default: str = None, unit_label: str = None, enable: bool = None 387 | ): 388 | """ 389 | Add a single row and re-draw as necessary 390 | 391 | :param key: the name and dict accessor 392 | :param default: the default value 393 | :param unit_label: the label that should be \ 394 | applied at the right of the entry 395 | :param enable: the 'enabled' state (defaults to True) 396 | :return: 397 | """ 398 | self.keys.append(ttk.Label(self, text=key)) 399 | 400 | self.defaults.append(default) 401 | self.unit_labels.append(ttk.Label(self, text=unit_label if unit_label else "")) 402 | self.enables.append(enable) 403 | self.values.append(ttk.Entry(self)) 404 | 405 | row_offset = 1 if self.title is not None else 0 406 | 407 | for i in range(len(self.keys)): 408 | self.keys[i].grid_forget() 409 | 410 | self.keys[i].grid(row=row_offset, column=0, sticky="e") 411 | self.values[i].grid(row=row_offset, column=1) 412 | 413 | if self.unit_labels[i]: 414 | self.unit_labels[i].grid(row=row_offset, column=3, sticky="w") 415 | 416 | if self.defaults[i]: 417 | self.values[i].config(state=tk.NORMAL) 418 | self.values[i].delete(0, tk.END) 419 | self.values[i].insert(0, self.defaults[i]) 420 | 421 | if self.enables[i] in [True, None]: 422 | self.values[i].config(state=tk.NORMAL) 423 | elif self.enables[i] is False: 424 | self.values[i].config(state=tk.DISABLED) 425 | 426 | row_offset += 1 427 | 428 | # strip and bindings, add callbacks to all entries 429 | self.values[i].unbind("") 430 | self.values[i].unbind("") 431 | 432 | if self.callback is not None: 433 | 434 | def callback(event): 435 | self.callback() 436 | 437 | self.values[i].bind("", callback) 438 | self.values[i].bind("", callback) 439 | 440 | def reset(self): 441 | """ 442 | Clears all entries. 443 | 444 | :return: None 445 | """ 446 | for i in range(len(self.values)): 447 | self.values[i].delete(0, tk.END) 448 | 449 | if self.defaults[i] is not None: 450 | self.values[i].insert(0, self.defaults[i]) 451 | 452 | def change_enables(self, enables_list: list): 453 | """ 454 | Enable/disable inputs. 455 | 456 | :param enables_list: list containing enables for each key 457 | :return: None 458 | """ 459 | for i, entry in enumerate(self.values): 460 | if enables_list[i]: 461 | entry.config(state=tk.NORMAL) 462 | else: 463 | entry.config(state=tk.DISABLED) 464 | 465 | def load(self, data: dict): 466 | """ 467 | Load values into the key/values via dict. 468 | 469 | :param data: dict containing the key/values that should be inserted 470 | :return: None 471 | """ 472 | for i, label in enumerate(self.keys): 473 | key = label.cget("text") 474 | if key in data.keys(): 475 | entry_was_enabled = str(self.values[i].cget("state")) == "normal" 476 | if not entry_was_enabled: 477 | self.values[i].config(state="normal") 478 | 479 | self.values[i].delete(0, tk.END) 480 | self.values[i].insert(0, str(data[key])) 481 | 482 | if not entry_was_enabled: 483 | self.values[i].config(state="disabled") 484 | 485 | def get(self): 486 | """ 487 | Retrieve the GUI elements for program use. 488 | 489 | :return: a dictionary containing all \ 490 | of the data from the key/value entries 491 | """ 492 | data = dict() 493 | for label, entry in zip(self.keys, self.values): 494 | data[label.cget("text")] = entry.get() 495 | 496 | return data 497 | 498 | 499 | def _get_calendar(locale, fwday): 500 | # instantiate proper calendar class 501 | if locale is None: 502 | return calendar.TextCalendar(fwday) 503 | else: 504 | return calendar.LocaleTextCalendar(fwday, locale) 505 | 506 | 507 | class Calendar(ttk.Frame): 508 | """ 509 | Graphical date selection widget, with callbacks. To change 510 | the language, use the ``locale`` library with the appropriate 511 | settings for the target language. For instance, to display 512 | the ``Calendar`` widget in German, you might use:: 513 | 514 | locale.setlocale(locale.LC_ALL, 'deu_deu') 515 | 516 | :param parent: the parent frame 517 | :param callback: the callable to be executed on selection 518 | :param year: the year as an integer, i.e. `2020` 519 | :param month: the month as an integer; not zero-indexed; i.e. 520 | "1" will translate to "January" 521 | :param day: the day as an integer; not zero-indexed 522 | :param kwargs: tkinter.frame keyword arguments 523 | """ 524 | 525 | timedelta = datetime.timedelta 526 | datetime = datetime.datetime 527 | 528 | def __init__( 529 | self, 530 | parent, 531 | callback: callable = None, 532 | year: int = None, 533 | month: int = None, 534 | day: int = None, 535 | **kwargs 536 | ): 537 | # remove custom options from kw before initializing ttk.Frame 538 | fwday = calendar.SUNDAY 539 | now = self.datetime.now() 540 | year = year if year else now.year 541 | month = month if month else now.month 542 | day = day if day else now.day 543 | locale = kwargs.pop("locale", None) 544 | sel_bg = kwargs.pop("selectbackground", "#ecffc4") 545 | sel_fg = kwargs.pop("selectforeground", "#05640e") 546 | 547 | self._date = self.datetime(year, month, day) 548 | self._selection = None # no date selected 549 | self.callback = callback 550 | 551 | super().__init__(parent, **kwargs) 552 | 553 | self._cal = _get_calendar(locale, fwday) 554 | 555 | self.__setup_styles() # creates custom styles 556 | self.__place_widgets() # pack/grid used widgets 557 | self.__config_calendar() # adjust calendar columns and setup tags 558 | # configure a _canvas, and proper bindings, for selecting dates 559 | self.__setup_selection(sel_bg, sel_fg) 560 | 561 | # store items ids, used for insertion later 562 | self._items = [self._calendar.insert("", "end", values="") for _ in range(6)] 563 | 564 | # insert dates in the currently empty calendar 565 | self._build_calendar() 566 | 567 | def __setitem__(self, item, value): 568 | if item in ("year", "month"): 569 | raise AttributeError("attribute '%s' is not writeable" % item) 570 | elif item == "selectbackground": 571 | self._canvas["background"] = value 572 | elif item == "selectforeground": 573 | self._canvas.itemconfigure(self._canvas.text, item=value) 574 | else: 575 | ttk.Frame.__setitem__(self, item, value) 576 | 577 | def __getitem__(self, item): 578 | if item in ("year", "month"): 579 | return getattr(self._date, item) 580 | elif item == "selectbackground": 581 | return self._canvas["background"] 582 | elif item == "selectforeground": 583 | return self._canvas.itemcget(self._canvas.text, "fill") 584 | else: 585 | r = ttk.tclobjs_to_py({item: ttk.Frame.__getitem__(self, item)}) 586 | return r[item] 587 | 588 | def __setup_styles(self): 589 | # custom ttk styles 590 | style = ttk.Style(self.master) 591 | 592 | def arrow_layout(dir): 593 | return [("Button.focus", {"children": [("Button.%sarrow" % dir, None)]})] 594 | 595 | style.layout("L.TButton", arrow_layout("left")) 596 | style.layout("R.TButton", arrow_layout("right")) 597 | 598 | def __place_widgets(self): 599 | # header frame and its widgets 600 | hframe = ttk.Frame(self) 601 | lbtn = ttk.Button(hframe, style="L.TButton", command=self._prev_month) 602 | rbtn = ttk.Button(hframe, style="R.TButton", command=self._next_month) 603 | self._header = ttk.Label(hframe, width=15, anchor="center") 604 | # the calendar 605 | self._calendar = ttk.Treeview(self, show="", selectmode="none", height=7) 606 | 607 | # pack the widgets 608 | hframe.pack(in_=self, side="top", pady=4, anchor="center") 609 | lbtn.grid(in_=hframe) 610 | self._header.grid(in_=hframe, column=1, row=0, padx=12) 611 | rbtn.grid(in_=hframe, column=2, row=0) 612 | self._calendar.pack(in_=self, expand=1, fill="both", side="bottom") 613 | 614 | def __config_calendar(self): 615 | cols = self._cal.formatweekheader(3).split() 616 | 617 | self._calendar["columns"] = cols 618 | self._calendar.tag_configure("header", background="grey90") 619 | self._calendar.insert("", "end", values=cols, tag="header") 620 | 621 | # adjust its columns width 622 | font = Font() 623 | maxwidth = max(font.measure(col) for col in cols) 624 | for col in cols: 625 | self._calendar.column(col, width=maxwidth, minwidth=maxwidth, anchor="e") 626 | 627 | def __setup_selection(self, sel_bg, sel_fg): 628 | self._font = Font() 629 | self._canvas = canvas = tk.Canvas( 630 | self._calendar, background=sel_bg, borderwidth=0, highlightthickness=0 631 | ) 632 | canvas.text = canvas.create_text(0, 0, fill=sel_fg, anchor="w") 633 | 634 | canvas.bind("", lambda evt: canvas.place_forget()) 635 | self._calendar.bind("", lambda evt: canvas.place_forget()) 636 | self._calendar.bind("", self._pressed) 637 | 638 | def __minsize(self, evt): 639 | width, height = self._calendar.master.geometry().split("x") 640 | height = height[: height.index("+")] 641 | self._calendar.master.minsize(width, height) 642 | 643 | def _build_calendar(self): 644 | year, month = self._date.year, self._date.month 645 | 646 | # update header text (Month, YEAR) 647 | header = self._cal.formatmonthname(year, month, 0) 648 | self._header["text"] = header.title() 649 | 650 | # update calendar shown dates 651 | cal = self._cal.monthdayscalendar(year, month) 652 | for indx, item in enumerate(self._items): 653 | week = cal[indx] if indx < len(cal) else [] 654 | fmt_week = [("%02d" % day) if day else "" for day in week] 655 | self._calendar.item(item, values=fmt_week) 656 | 657 | def _show_selection(self, text, bbox): 658 | """ 659 | Configure canvas for a new selection. 660 | """ 661 | x, y, width, height = bbox 662 | 663 | textw = self._font.measure(text) 664 | 665 | canvas = self._canvas 666 | canvas.configure(width=width, height=height) 667 | canvas.coords(canvas.text, width - textw, height / 2 - 1) 668 | canvas.itemconfigure(canvas.text, text=text) 669 | canvas.place(in_=self._calendar, x=x, y=y) 670 | 671 | # Callbacks 672 | 673 | def _pressed(self, evt): 674 | """ 675 | Clicked somewhere in the calendar. 676 | """ 677 | x, y, widget = evt.x, evt.y, evt.widget 678 | item = widget.identify_row(y) 679 | column = widget.identify_column(x) 680 | 681 | if not column or item not in self._items: 682 | # clicked in the weekdays row or just outside the columns 683 | return 684 | 685 | item_values = widget.item(item)["values"] 686 | if not len(item_values): # row is empty for this month 687 | return 688 | 689 | text = item_values[int(column[1]) - 1] 690 | if not text: # date is empty 691 | return 692 | 693 | bbox = widget.bbox(item, column) 694 | if not bbox: # calendar not visible yet 695 | return 696 | 697 | # update and then show selection 698 | text = "%02d" % text 699 | self._selection = (text, item, column) 700 | self._show_selection(text, bbox) 701 | 702 | if self.callback is not None: 703 | self.callback() 704 | 705 | def add_callback(self, callback: callable): 706 | """ 707 | Adds a callback to call when the user clicks on a date 708 | 709 | :param callback: a callable function 710 | :return: None 711 | """ 712 | self.callback = callback 713 | 714 | def _prev_month(self): 715 | """ 716 | Updated calendar to show the previous month. 717 | """ 718 | self._canvas.place_forget() 719 | 720 | self._date = self._date - self.timedelta(days=1) 721 | self._date = self.datetime(self._date.year, self._date.month, 1) 722 | self._build_calendar() # reconstruct calendar 723 | 724 | def _next_month(self): 725 | """ 726 | Update calendar to show the next month. 727 | """ 728 | self._canvas.place_forget() 729 | 730 | year, month = self._date.year, self._date.month 731 | self._date = self._date + self.timedelta( 732 | days=calendar.monthrange(year, month)[1] + 1 733 | ) 734 | self._date = self.datetime(self._date.year, self._date.month, 1) 735 | self._build_calendar() # reconstruct calendar 736 | 737 | @property 738 | def selection(self): 739 | """ 740 | Return a datetime representing the current selected date. 741 | """ 742 | if not self._selection: 743 | return None 744 | 745 | year, month = self._date.year, self._date.month 746 | return self.datetime(year, month, int(self._selection[0])) 747 | 748 | 749 | class _SlotFrame(ttk.Frame): 750 | """A single slot""" 751 | 752 | def __init__(self, parent, remove_callback=None, entries=1): 753 | self.parent = parent 754 | super().__init__(self.parent) 755 | 756 | self.columnconfigure(0, weight=1) 757 | self._entries = [] 758 | 759 | if entries < 1: 760 | raise ValueError("entries must be >= 1") 761 | 762 | for i in range(entries): 763 | entry = ttk.Entry(self) 764 | entry.grid(row=0, column=i, sticky="ew") 765 | self._entries.append(entry) 766 | 767 | self._image = tk.PhotoImage(data=minus).subsample(2, 2) 768 | self._remove_btn = ttk.Button(self, image=self._image, command=self.remove) 769 | self._remove_btn.grid(row=0, column=entries, sticky="ew") 770 | 771 | self.deleted = False 772 | self._remove_callback = remove_callback 773 | 774 | def add(self, string: (str, list)): 775 | """ 776 | Clear the contents of the entry field and 777 | insert the contents of string. 778 | 779 | :param string: an str containing the text to display 780 | :return: 781 | """ 782 | if len(self._entries) == 1: 783 | self._entries[0].delete(0, "end") 784 | self._entries[0].insert(0, string) 785 | else: 786 | if len(string) != len(self._entries): 787 | raise ValueError( 788 | 'the "string" list must be ' "equal to the number of entries" 789 | ) 790 | 791 | for i, e in enumerate(self._entries): 792 | self._entries[i].delete(0, "end") 793 | self._entries[i].insert(0, string[i]) 794 | 795 | def remove(self): 796 | """ 797 | Deletes itself. 798 | :return: None 799 | """ 800 | for e in self._entries: 801 | e.grid_forget() 802 | e.destroy() 803 | 804 | self._remove_btn.grid_forget() 805 | self._remove_btn.destroy() 806 | 807 | self.deleted = True 808 | 809 | if self._remove_callback: 810 | self._remove_callback() 811 | 812 | def get(self): 813 | """ 814 | Returns the value for the slot. 815 | :return: the entry value 816 | """ 817 | values = [e.get() for e in self._entries] 818 | if len(self._entries) == 1: 819 | return values[0] 820 | else: 821 | return values 822 | 823 | 824 | class MultiSlotFrame(ttk.Frame): 825 | """ 826 | Can hold several removable elements, 827 | such as a list of files, directories, 828 | or a checklist.:: 829 | 830 | # create and grid the frame 831 | msf = tk_tools.MultiSlotFrame(root) 832 | msf.grid() 833 | 834 | # add some items 835 | msf.add('item 1') 836 | msf.add('item 2') 837 | 838 | # get any user-entered or modified values 839 | print(msf.get()) 840 | 841 | :param parent: the tk parent frame 842 | :param columns: the number of user columns (defaults to 1) 843 | """ 844 | 845 | def __init__(self, parent, columns: int = 1): 846 | self._parent = parent 847 | super().__init__(self._parent) 848 | 849 | self.columnconfigure(0, weight=1) 850 | self._slot_columns = columns 851 | 852 | self._slots = [] 853 | 854 | self._blank_label = None 855 | self._redraw() 856 | 857 | self._blank_label = ttk.Label(self, text="") 858 | self._blank_label.grid(row=0, column=0) 859 | 860 | def _redraw(self): 861 | """ 862 | Clears the current layout and re-draws all elements in self._slots 863 | :return: 864 | """ 865 | if self._blank_label: 866 | self._blank_label.grid_forget() 867 | self._blank_label.destroy() 868 | self._blank_label = None 869 | 870 | for slot in self._slots: 871 | slot.grid_forget() 872 | 873 | self._slots = [slot for slot in self._slots if not slot.deleted] 874 | 875 | max_per_col = 8 876 | for i, slot in enumerate(self._slots): 877 | slot.grid(row=i % max_per_col, column=int(i / max_per_col), sticky="ew") 878 | 879 | def add(self, string: (str, list)): 880 | """ 881 | Add a new slot to the multi-frame containing the string. 882 | :param string: a string to insert 883 | :return: None 884 | """ 885 | slot = _SlotFrame( 886 | self, remove_callback=self._redraw, entries=self._slot_columns 887 | ) 888 | slot.add(string) 889 | 890 | self._slots.append(slot) 891 | 892 | self._redraw() 893 | 894 | def clear(self): 895 | """ 896 | Clear out the multi-frame 897 | :return: 898 | """ 899 | for slot in self._slots: 900 | slot.grid_forget() 901 | slot.destroy() 902 | 903 | self._slots = [] 904 | 905 | def get(self): 906 | """ 907 | Retrieve and return the values in the multi-frame 908 | :return: A list of values containing the contents of the GUI 909 | """ 910 | return [slot.get() for slot in self._slots] 911 | 912 | 913 | class SevenSegment(tk.Frame): 914 | """ 915 | Creates a single seven-segment display which may be 916 | used to emulate a numeric display of old:: 917 | 918 | # create and grid the frame 919 | ss = tk_tools.SevenSegment(root) 920 | ss.grid() 921 | 922 | # set the value 923 | ss.set_value(2) 924 | 925 | # set the value with a period 926 | ss.set_value(6.0) 927 | 928 | :param parent: the tk parent frame 929 | :param height: the widget height (defaults to 50) 930 | :param digit_color: the digit color (ex: 'black', '#ff0000') 931 | :param background: the background color (ex: 'black', '#ff0000') 932 | """ 933 | 934 | def __init__( 935 | self, parent, height: int = 50, digit_color="black", background="white" 936 | ): 937 | self._parent = parent 938 | self._color = digit_color 939 | self._bg_color = background 940 | 941 | super().__init__( 942 | self._parent, 943 | height=height, 944 | width=int(height / 2), 945 | background=self._bg_color, 946 | ) 947 | 948 | self.columnconfigure(0, weight=1) 949 | self.columnconfigure(1, weight=1) 950 | self.columnconfigure(2, weight=8) 951 | self.columnconfigure(3, weight=1) 952 | self.columnconfigure(4, weight=1) 953 | 954 | self.rowconfigure(0, weight=1) 955 | self.rowconfigure(1, weight=1) 956 | self.rowconfigure(2, weight=8) 957 | self.rowconfigure(3, weight=1) 958 | self.rowconfigure(4, weight=8) 959 | self.rowconfigure(5, weight=1) 960 | 961 | self._segments = dict() 962 | 963 | self._segments["a"] = tk.Frame(self, bg=self._bg_color) 964 | self._segments["a"].grid(row=1, column=2, sticky="news") 965 | 966 | self._segments["b"] = tk.Frame(self, bg=self._bg_color) 967 | self._segments["b"].grid(row=2, column=3, sticky="news") 968 | 969 | self._segments["c"] = tk.Frame(self, bg=self._bg_color) 970 | self._segments["c"].grid(row=4, column=3, sticky="news") 971 | 972 | self._segments["d"] = tk.Frame(self, bg=self._bg_color) 973 | self._segments["d"].grid(row=5, column=2, sticky="news") 974 | 975 | self._segments["e"] = tk.Frame(self, bg=self._bg_color) 976 | self._segments["e"].grid(row=4, column=1, sticky="news") 977 | 978 | self._segments["f"] = tk.Frame(self, bg=self._bg_color) 979 | self._segments["f"].grid(row=2, column=1, sticky="news") 980 | 981 | self._segments["g"] = tk.Frame(self, bg=self._bg_color) 982 | self._segments["g"].grid(row=3, column=2, sticky="news") 983 | 984 | self._segments["period"] = tk.Frame(self, bg=self._bg_color) 985 | self._segments["period"].grid(row=5, column=4, sticky="news") 986 | 987 | self.grid_propagate(0) 988 | 989 | def clear(self): 990 | """ 991 | Clear the segment. 992 | :return: None 993 | """ 994 | for _, frame in self._segments.items(): 995 | frame.configure(background=self._bg_color) 996 | 997 | def set_value(self, value: str): 998 | """ 999 | Sets the value of the 7-segment display 1000 | :param value: the desired value 1001 | :return: None 1002 | """ 1003 | 1004 | self.clear() 1005 | 1006 | if "." in value: 1007 | self._segments["period"].configure(background=self._color) 1008 | 1009 | if value in ["0", "0."]: 1010 | self._segments["a"].configure(background=self._color) 1011 | self._segments["b"].configure(background=self._color) 1012 | self._segments["c"].configure(background=self._color) 1013 | self._segments["d"].configure(background=self._color) 1014 | self._segments["e"].configure(background=self._color) 1015 | self._segments["f"].configure(background=self._color) 1016 | elif value in ["1", "1."]: 1017 | self._segments["b"].configure(background=self._color) 1018 | self._segments["c"].configure(background=self._color) 1019 | elif value in ["2", "2."]: 1020 | self._segments["a"].configure(background=self._color) 1021 | self._segments["b"].configure(background=self._color) 1022 | self._segments["g"].configure(background=self._color) 1023 | self._segments["e"].configure(background=self._color) 1024 | self._segments["d"].configure(background=self._color) 1025 | elif value in ["3", "3."]: 1026 | self._segments["a"].configure(background=self._color) 1027 | self._segments["b"].configure(background=self._color) 1028 | self._segments["g"].configure(background=self._color) 1029 | self._segments["c"].configure(background=self._color) 1030 | self._segments["d"].configure(background=self._color) 1031 | elif value in ["4", "4."]: 1032 | self._segments["f"].configure(background=self._color) 1033 | self._segments["g"].configure(background=self._color) 1034 | self._segments["b"].configure(background=self._color) 1035 | self._segments["c"].configure(background=self._color) 1036 | elif value in ["5", "5."]: 1037 | self._segments["a"].configure(background=self._color) 1038 | self._segments["f"].configure(background=self._color) 1039 | self._segments["g"].configure(background=self._color) 1040 | self._segments["c"].configure(background=self._color) 1041 | self._segments["d"].configure(background=self._color) 1042 | elif value in ["6", "6."]: 1043 | self._segments["f"].configure(background=self._color) 1044 | self._segments["g"].configure(background=self._color) 1045 | self._segments["c"].configure(background=self._color) 1046 | self._segments["d"].configure(background=self._color) 1047 | self._segments["e"].configure(background=self._color) 1048 | elif value in ["7", "7."]: 1049 | self._segments["a"].configure(background=self._color) 1050 | self._segments["b"].configure(background=self._color) 1051 | self._segments["c"].configure(background=self._color) 1052 | elif value in ["8", "8."]: 1053 | self._segments["a"].configure(background=self._color) 1054 | self._segments["b"].configure(background=self._color) 1055 | self._segments["c"].configure(background=self._color) 1056 | self._segments["d"].configure(background=self._color) 1057 | self._segments["e"].configure(background=self._color) 1058 | self._segments["f"].configure(background=self._color) 1059 | self._segments["g"].configure(background=self._color) 1060 | elif value in ["9", "9."]: 1061 | self._segments["a"].configure(background=self._color) 1062 | self._segments["b"].configure(background=self._color) 1063 | self._segments["c"].configure(background=self._color) 1064 | self._segments["f"].configure(background=self._color) 1065 | self._segments["g"].configure(background=self._color) 1066 | elif value in ["-"]: 1067 | self._segments["g"].configure(background=self._color) 1068 | 1069 | else: 1070 | raise ValueError("unsupported character: {}".format(value)) 1071 | 1072 | 1073 | class SevenSegmentDigits(tk.Frame): 1074 | """ 1075 | Creates a single seven-segment display which may be 1076 | used to emulate a numeric display of old:: 1077 | 1078 | # create and grid the frame 1079 | ss = tk_tools.SevenSegment(root) 1080 | ss.grid() 1081 | 1082 | # set the value 1083 | ss.set_value(2) 1084 | 1085 | # set the value with a period 1086 | ss.set_value(6.0) 1087 | 1088 | :param parent: the tk parent frame 1089 | :param height: the widget height (defaults to 50) 1090 | :param digit_color: the digit color (ex: 'black', '#ff0000') 1091 | :param background: the background color (ex: 'black', '#ff0000') 1092 | """ 1093 | 1094 | def __init__( 1095 | self, 1096 | parent, 1097 | digits: int = 1, 1098 | height: int = 50, 1099 | digit_color="black", 1100 | background="white", 1101 | ): 1102 | self._parent = parent 1103 | self._max_value = digits * 10 - 1 1104 | 1105 | super().__init__(self._parent, bg=background) 1106 | 1107 | self._digits = [ 1108 | SevenSegment( 1109 | self, height=height, digit_color=digit_color, background=background 1110 | ) 1111 | for _ in range(digits) 1112 | ] 1113 | 1114 | for i, digit in enumerate(self._digits): 1115 | digit.grid(row=0, column=i) 1116 | 1117 | def _group(self, value: str): 1118 | """ 1119 | Takes a string and groups it appropriately with any 1120 | period or other appropriate punctuation so that it is 1121 | displayed correctly. 1122 | :param value: a string containing an integer or float 1123 | :return: None 1124 | """ 1125 | reversed_v = value[::-1] 1126 | 1127 | parts = [] 1128 | 1129 | has_period = False 1130 | for c in reversed_v: 1131 | if has_period: 1132 | parts.append(c + ".") 1133 | has_period = False 1134 | elif c == ".": 1135 | has_period = True 1136 | else: 1137 | parts.append(c) 1138 | 1139 | parts = parts[: len(self._digits)] 1140 | 1141 | return parts 1142 | 1143 | def set_value(self, value: str): 1144 | """ 1145 | Sets the displayed digits based on the value string. 1146 | :param value: a string containing an integer or float value 1147 | :return: None 1148 | """ 1149 | [digit.clear() for digit in self._digits] 1150 | 1151 | grouped = self._group(value) # return the parts, reversed 1152 | digits = self._digits[::-1] # reverse the digits 1153 | 1154 | # fill from right to left 1155 | has_period = False 1156 | for i, digit_value in enumerate(grouped): 1157 | try: 1158 | if has_period: 1159 | digits[i].set_value(digit_value + ".") 1160 | has_period = False 1161 | 1162 | elif grouped[i] == ".": 1163 | has_period = True 1164 | 1165 | else: 1166 | digits[i].set_value(digit_value) 1167 | except IndexError: 1168 | raise ValueError( 1169 | 'the value "{}" contains too ' "many digits".format(value) 1170 | ) 1171 | --------------------------------------------------------------------------------