From dfd5b1192b42778b92506dc4ead5421c3f7e0379 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sun, 21 Jun 2026 16:46:15 +0200 Subject: [PATCH] feat: initialize CCMA member administration --- .gitignore | 16 + LICENSE | 21 + MANIFEST.in | 1 + VERSION | 1 + pyproject.toml | 47 ++ requirements.txt | 2 + setup.py | 21 + src/ccma/__init__.py | 5 + src/ccma/__main__.py | 4 + src/ccma/app.py | 134 +++++ src/ccma/assets/CHANGELOG.json | 25 + src/ccma/assets/themes/forest/LICENSE | 21 + src/ccma/assets/themes/forest/forest-dark.tcl | 534 +++++++++++++++++ .../forest-dark/border-accent-hover.png | Bin 0 -> 385 bytes .../forest/forest-dark/border-accent.png | Bin 0 -> 389 bytes .../forest/forest-dark/border-basic.png | Bin 0 -> 333 bytes .../forest/forest-dark/border-hover.png | Bin 0 -> 337 bytes .../forest/forest-dark/border-invalid.png | Bin 0 -> 408 bytes .../assets/themes/forest/forest-dark/card.png | Bin 0 -> 374 bytes .../forest/forest-dark/check-accent.png | Bin 0 -> 434 bytes .../themes/forest/forest-dark/check-basic.png | Bin 0 -> 406 bytes .../themes/forest/forest-dark/check-hover.png | Bin 0 -> 434 bytes .../forest/forest-dark/check-tri-accent.png | Bin 0 -> 317 bytes .../forest/forest-dark/check-tri-basic.png | Bin 0 -> 300 bytes .../forest/forest-dark/check-tri-hover.png | Bin 0 -> 320 bytes .../forest/forest-dark/check-unsel-accent.png | Bin 0 -> 346 bytes .../forest/forest-dark/check-unsel-basic.png | Bin 0 -> 333 bytes .../forest/forest-dark/check-unsel-hover.png | Bin 0 -> 341 bytes .../forest-dark/check-unsel-pressed.png | Bin 0 -> 290 bytes .../forest/forest-dark/combo-button-basic.png | Bin 0 -> 235 bytes .../forest/forest-dark/combo-button-focus.png | Bin 0 -> 245 bytes .../forest/forest-dark/combo-button-hover.png | Bin 0 -> 239 bytes .../assets/themes/forest/forest-dark/down.png | Bin 0 -> 251 bytes .../themes/forest/forest-dark/empty.png | Bin 0 -> 130 bytes .../themes/forest/forest-dark/hor-accent.png | Bin 0 -> 162 bytes .../themes/forest/forest-dark/hor-basic.png | Bin 0 -> 162 bytes .../themes/forest/forest-dark/hor-hover.png | Bin 0 -> 162 bytes .../themes/forest/forest-dark/notebook.png | Bin 0 -> 193 bytes .../themes/forest/forest-dark/off-accent.png | Bin 0 -> 679 bytes .../themes/forest/forest-dark/off-basic.png | Bin 0 -> 640 bytes .../themes/forest/forest-dark/off-hover.png | Bin 0 -> 692 bytes .../themes/forest/forest-dark/on-accent.png | Bin 0 -> 676 bytes .../themes/forest/forest-dark/on-basic.png | Bin 0 -> 633 bytes .../themes/forest/forest-dark/on-hover.png | Bin 0 -> 685 bytes .../forest/forest-dark/radio-accent.png | Bin 0 -> 565 bytes .../themes/forest/forest-dark/radio-basic.png | Bin 0 -> 543 bytes .../themes/forest/forest-dark/radio-hover.png | Bin 0 -> 579 bytes .../forest/forest-dark/radio-tri-accent.png | Bin 0 -> 465 bytes .../forest/forest-dark/radio-tri-basic.png | Bin 0 -> 450 bytes .../forest/forest-dark/radio-tri-hover.png | Bin 0 -> 489 bytes .../forest/forest-dark/radio-unsel-accent.png | Bin 0 -> 605 bytes .../forest/forest-dark/radio-unsel-basic.png | Bin 0 -> 559 bytes .../forest/forest-dark/radio-unsel-hover.png | Bin 0 -> 615 bytes .../forest-dark/radio-unsel-pressed.png | Bin 0 -> 468 bytes .../forest/forest-dark/rect-accent-hover.png | Bin 0 -> 290 bytes .../themes/forest/forest-dark/rect-accent.png | Bin 0 -> 290 bytes .../themes/forest/forest-dark/rect-basic.png | Bin 0 -> 272 bytes .../themes/forest/forest-dark/rect-hover.png | Bin 0 -> 273 bytes .../themes/forest/forest-dark/right.png | Bin 0 -> 217 bytes .../themes/forest/forest-dark/scale-hor.png | Bin 0 -> 166 bytes .../themes/forest/forest-dark/scale-vert.png | Bin 0 -> 161 bytes .../themes/forest/forest-dark/separator.png | Bin 0 -> 128 bytes .../themes/forest/forest-dark/sizegrip.png | Bin 0 -> 459 bytes .../forest-dark/spin-button-down-basic.png | Bin 0 -> 153 bytes .../forest-dark/spin-button-down-focus.png | Bin 0 -> 160 bytes .../forest/forest-dark/spin-button-up.png | Bin 0 -> 222 bytes .../themes/forest/forest-dark/tab-accent.png | Bin 0 -> 188 bytes .../themes/forest/forest-dark/tab-basic.png | Bin 0 -> 188 bytes .../themes/forest/forest-dark/tab-hover.png | Bin 0 -> 188 bytes .../forest/forest-dark/thumb-hor-accent.png | Bin 0 -> 270 bytes .../forest/forest-dark/thumb-hor-basic.png | Bin 0 -> 258 bytes .../forest/forest-dark/thumb-hor-hover.png | Bin 0 -> 273 bytes .../forest/forest-dark/thumb-vert-accent.png | Bin 0 -> 269 bytes .../forest/forest-dark/thumb-vert-basic.png | Bin 0 -> 253 bytes .../forest/forest-dark/thumb-vert-hover.png | Bin 0 -> 269 bytes .../themes/forest/forest-dark/tree-basic.png | Bin 0 -> 149 bytes .../forest/forest-dark/tree-pressed.png | Bin 0 -> 171 bytes .../assets/themes/forest/forest-dark/up.png | Bin 0 -> 250 bytes .../themes/forest/forest-dark/vert-accent.png | Bin 0 -> 158 bytes .../themes/forest/forest-dark/vert-basic.png | Bin 0 -> 158 bytes .../themes/forest/forest-dark/vert-hover.png | Bin 0 -> 158 bytes .../assets/themes/forest/forest-light.tcl | 541 ++++++++++++++++++ .../forest-light/border-accent-hover.png | Bin 0 -> 445 bytes .../forest/forest-light/border-accent.png | Bin 0 -> 463 bytes .../forest/forest-light/border-basic.png | Bin 0 -> 311 bytes .../forest/forest-light/border-hover.png | Bin 0 -> 324 bytes .../forest/forest-light/border-invalid.png | Bin 0 -> 444 bytes .../themes/forest/forest-light/card.png | Bin 0 -> 353 bytes .../forest/forest-light/check-accent.png | Bin 0 -> 526 bytes .../forest/forest-light/check-basic.png | Bin 0 -> 390 bytes .../forest/forest-light/check-hover.png | Bin 0 -> 531 bytes .../forest/forest-light/check-tri-accent.png | Bin 0 -> 358 bytes .../forest/forest-light/check-tri-basic.png | Bin 0 -> 281 bytes .../forest/forest-light/check-tri-hover.png | Bin 0 -> 358 bytes .../forest-light/check-unsel-accent.png | Bin 0 -> 403 bytes .../forest/forest-light/check-unsel-basic.png | Bin 0 -> 311 bytes .../forest/forest-light/check-unsel-hover.png | Bin 0 -> 398 bytes .../forest-light/check-unsel-pressed.png | Bin 0 -> 335 bytes .../forest-light/combo-button-basic.png | Bin 0 -> 247 bytes .../forest-light/combo-button-focus.png | Bin 0 -> 260 bytes .../forest-light/combo-button-hover.png | Bin 0 -> 244 bytes .../themes/forest/forest-light/down-focus.png | Bin 0 -> 200 bytes .../themes/forest/forest-light/down.png | Bin 0 -> 266 bytes .../themes/forest/forest-light/empty.png | Bin 0 -> 130 bytes .../themes/forest/forest-light/hor-accent.png | Bin 0 -> 154 bytes .../themes/forest/forest-light/hor-basic.png | Bin 0 -> 157 bytes .../themes/forest/forest-light/hor-hover.png | Bin 0 -> 154 bytes .../themes/forest/forest-light/notebook.png | Bin 0 -> 190 bytes .../themes/forest/forest-light/off-accent.png | Bin 0 -> 765 bytes .../themes/forest/forest-light/off-basic.png | Bin 0 -> 547 bytes .../themes/forest/forest-light/off-hover.png | Bin 0 -> 771 bytes .../themes/forest/forest-light/on-accent.png | Bin 0 -> 754 bytes .../themes/forest/forest-light/on-basic.png | Bin 0 -> 538 bytes .../themes/forest/forest-light/on-hover.png | Bin 0 -> 764 bytes .../forest/forest-light/radio-accent.png | Bin 0 -> 674 bytes .../forest/forest-light/radio-basic.png | Bin 0 -> 486 bytes .../forest/forest-light/radio-hover.png | Bin 0 -> 679 bytes .../forest/forest-light/radio-tri-accent.png | Bin 0 -> 549 bytes .../forest/forest-light/radio-tri-basic.png | Bin 0 -> 390 bytes .../forest/forest-light/radio-tri-hover.png | Bin 0 -> 550 bytes .../forest-light/radio-unsel-accent.png | Bin 0 -> 676 bytes .../forest/forest-light/radio-unsel-basic.png | Bin 0 -> 504 bytes .../forest/forest-light/radio-unsel-hover.png | Bin 0 -> 674 bytes .../forest-light/radio-unsel-pressed.png | Bin 0 -> 512 bytes .../forest/forest-light/rect-accent-hover.png | Bin 0 -> 335 bytes .../forest/forest-light/rect-accent.png | Bin 0 -> 335 bytes .../themes/forest/forest-light/rect-basic.png | Bin 0 -> 254 bytes .../themes/forest/forest-light/rect-hover.png | Bin 0 -> 272 bytes .../forest/forest-light/right-focus.png | Bin 0 -> 190 bytes .../themes/forest/forest-light/right.png | Bin 0 -> 284 bytes .../themes/forest/forest-light/scale-hor.png | Bin 0 -> 161 bytes .../themes/forest/forest-light/scale-vert.png | Bin 0 -> 162 bytes .../themes/forest/forest-light/separator.png | Bin 0 -> 128 bytes .../themes/forest/forest-light/sizegrip.png | Bin 0 -> 471 bytes .../forest-light/spin-button-down-basic.png | Bin 0 -> 156 bytes .../forest-light/spin-button-down-focus.png | Bin 0 -> 163 bytes .../forest/forest-light/spin-button-up.png | Bin 0 -> 223 bytes .../themes/forest/forest-light/tab-accent.png | Bin 0 -> 184 bytes .../themes/forest/forest-light/tab-basic.png | Bin 0 -> 183 bytes .../themes/forest/forest-light/tab-hover.png | Bin 0 -> 184 bytes .../forest/forest-light/thumb-hor-accent.png | Bin 0 -> 316 bytes .../forest/forest-light/thumb-hor-basic.png | Bin 0 -> 242 bytes .../forest/forest-light/thumb-hor-hover.png | Bin 0 -> 316 bytes .../forest/forest-light/thumb-vert-accent.png | Bin 0 -> 311 bytes .../forest/forest-light/thumb-vert-basic.png | Bin 0 -> 234 bytes .../forest/forest-light/thumb-vert-hover.png | Bin 0 -> 310 bytes .../themes/forest/forest-light/tree-basic.png | Bin 0 -> 149 bytes .../forest/forest-light/tree-pressed.png | Bin 0 -> 168 bytes .../assets/themes/forest/forest-light/up.png | Bin 0 -> 278 bytes .../forest/forest-light/vert-accent.png | Bin 0 -> 158 bytes .../themes/forest/forest-light/vert-basic.png | Bin 0 -> 158 bytes .../themes/forest/forest-light/vert-hover.png | Bin 0 -> 158 bytes src/ccma/config.py | 115 ++++ src/ccma/domain/__init__.py | 3 + src/ccma/domain/dates.py | 162 ++++++ src/ccma/domain/models.py | 169 ++++++ src/ccma/services/__init__.py | 3 + src/ccma/services/housekeeper.py | 237 ++++++++ src/ccma/services/intervals.py | 77 +++ src/ccma/storage/__init__.py | 3 + src/ccma/storage/atomic.py | 25 + src/ccma/storage/repository.py | 431 ++++++++++++++ src/ccma/ui/__init__.py | 1 + src/ccma/ui/changelog_view.py | 123 ++++ src/ccma/ui/dialogs.py | 90 +++ src/ccma/ui/icons.py | 79 +++ src/ccma/ui/main_window.py | 379 ++++++++++++ src/ccma/ui/member_tab.py | 277 +++++++++ src/ccma/ui/monitors.py | 117 ++++ src/ccma/ui/options_dialog.py | 374 ++++++++++++ src/ccma/ui/splash.py | 195 +++++++ src/ccma/ui/theme.py | 77 +++ src/ccma/ui/window_state.py | 29 + src/ccma/ui/work_tabs.py | 248 ++++++++ src/ccma/version.py | 31 + tests/test_changelog.py | 9 + tests/test_config.py | 37 ++ tests/test_dates.py | 63 ++ tests/test_housekeeper.py | 93 +++ tests/test_intervals.py | 27 + tests/test_monitors.py | 13 + tests/test_repository.py | 143 +++++ tests/test_ui_imports.py | 40 ++ tests/test_version.py | 8 + 184 files changed, 5051 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 VERSION create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 src/ccma/__init__.py create mode 100644 src/ccma/__main__.py create mode 100644 src/ccma/app.py create mode 100644 src/ccma/assets/CHANGELOG.json create mode 100644 src/ccma/assets/themes/forest/LICENSE create mode 100644 src/ccma/assets/themes/forest/forest-dark.tcl create mode 100644 src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/border-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/border-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/border-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/border-invalid.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/card.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-unsel-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/down.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/empty.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/hor-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/hor-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/hor-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/notebook.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/off-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/off-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/off-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/on-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/on-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/on-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-tri-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-unsel-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-unsel-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/rect-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/rect-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/rect-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/right.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/scale-hor.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/scale-vert.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/separator.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/sizegrip.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/spin-button-down-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/spin-button-up.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/tab-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/tab-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/tab-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-hor-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-hor-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/tree-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/tree-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/up.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/vert-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/vert-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-dark/vert-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light.tcl create mode 100644 src/ccma/assets/themes/forest/forest-light/border-accent-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/border-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/border-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/border-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/border-invalid.png create mode 100644 src/ccma/assets/themes/forest/forest-light/card.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-tri-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-tri-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-tri-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-unsel-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-unsel-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-light/combo-button-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/combo-button-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-light/combo-button-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/down-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-light/down.png create mode 100644 src/ccma/assets/themes/forest/forest-light/empty.png create mode 100644 src/ccma/assets/themes/forest/forest-light/hor-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/hor-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/hor-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/notebook.png create mode 100644 src/ccma/assets/themes/forest/forest-light/off-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/off-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/off-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/on-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/on-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/on-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-tri-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-tri-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-unsel-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-unsel-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-light/rect-accent-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/rect-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/rect-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/rect-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/right-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-light/right.png create mode 100644 src/ccma/assets/themes/forest/forest-light/scale-hor.png create mode 100644 src/ccma/assets/themes/forest/forest-light/scale-vert.png create mode 100644 src/ccma/assets/themes/forest/forest-light/separator.png create mode 100644 src/ccma/assets/themes/forest/forest-light/sizegrip.png create mode 100644 src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png create mode 100644 src/ccma/assets/themes/forest/forest-light/spin-button-up.png create mode 100644 src/ccma/assets/themes/forest/forest-light/tab-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/tab-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/tab-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-hor-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png create mode 100644 src/ccma/assets/themes/forest/forest-light/tree-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/tree-pressed.png create mode 100644 src/ccma/assets/themes/forest/forest-light/up.png create mode 100644 src/ccma/assets/themes/forest/forest-light/vert-accent.png create mode 100644 src/ccma/assets/themes/forest/forest-light/vert-basic.png create mode 100644 src/ccma/assets/themes/forest/forest-light/vert-hover.png create mode 100644 src/ccma/config.py create mode 100644 src/ccma/domain/__init__.py create mode 100644 src/ccma/domain/dates.py create mode 100644 src/ccma/domain/models.py create mode 100644 src/ccma/services/__init__.py create mode 100644 src/ccma/services/housekeeper.py create mode 100644 src/ccma/services/intervals.py create mode 100644 src/ccma/storage/__init__.py create mode 100644 src/ccma/storage/atomic.py create mode 100644 src/ccma/storage/repository.py create mode 100644 src/ccma/ui/__init__.py create mode 100644 src/ccma/ui/changelog_view.py create mode 100644 src/ccma/ui/dialogs.py create mode 100644 src/ccma/ui/icons.py create mode 100644 src/ccma/ui/main_window.py create mode 100644 src/ccma/ui/member_tab.py create mode 100644 src/ccma/ui/monitors.py create mode 100644 src/ccma/ui/options_dialog.py create mode 100644 src/ccma/ui/splash.py create mode 100644 src/ccma/ui/theme.py create mode 100644 src/ccma/ui/window_state.py create mode 100644 src/ccma/ui/work_tabs.py create mode 100644 src/ccma/version.py create mode 100644 tests/test_changelog.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dates.py create mode 100644 tests/test_housekeeper.py create mode 100644 tests/test_intervals.py create mode 100644 tests/test_monitors.py create mode 100644 tests/test_repository.py create mode 100644 tests/test_ui_imports.py create mode 100644 tests/test_version.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1791b5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +.coverage +htmlcov/ +.idea/ +.vscode/ + +# Local member stores must never be committed. +member-store/ +*.ccma-lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18095cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chaos Computer Club Mannheim e.V. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ceeea23 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..07156bd --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1-dev0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c40a774 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "ccma" +dynamic = ["version"] +description = "Chaotic Creature Member Administration for Chaos Computer Club Mannheim e.V." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [{name = "Chaos Computer Club Mannheim e.V."}] +dependencies = [ + "screeninfo>=0.8.1,<1", + "ttkbootstrap-icons", + "ttkbootstrap-icons-mat", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.6", +] + +[project.scripts] +ccma = "ccma.app:main" + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +ccma = ["VERSION", "assets/CHANGELOG.json", "assets/themes/forest/**/*", "assets/themes/forest/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 110 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..658a218 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Install CCMA and its runtime dependencies from pyproject.toml. +-e . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d61de67 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +from pathlib import Path +from shutil import copyfile + +from setuptools import setup +from setuptools.command.build_py import build_py + +ROOT = Path(__file__).resolve().parent + + +class BuildPyWithVersion(build_py): + def run(self) -> None: + super().run() + target = Path(self.build_lib) / "ccma" / "VERSION" + target.parent.mkdir(parents=True, exist_ok=True) + copyfile(ROOT / "VERSION", target) + + +setup( + version=(ROOT / "VERSION").read_text(encoding="utf-8").strip(), + cmdclass={"build_py": BuildPyWithVersion}, +) diff --git a/src/ccma/__init__.py b/src/ccma/__init__.py new file mode 100644 index 0000000..ad7cbdb --- /dev/null +++ b/src/ccma/__init__.py @@ -0,0 +1,5 @@ +from ccma.version import get_version + +__version__ = get_version() + +__all__ = ["__version__"] diff --git a/src/ccma/__main__.py b/src/ccma/__main__.py new file mode 100644 index 0000000..e040fd6 --- /dev/null +++ b/src/ccma/__main__.py @@ -0,0 +1,4 @@ +from ccma.app import main + +if __name__ == "__main__": + main() diff --git a/src/ccma/app.py b/src/ccma/app.py new file mode 100644 index 0000000..789d338 --- /dev/null +++ b/src/ccma/app.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox + +from ccma import __version__ +from ccma.config import load_config +from ccma.domain.dates import setup_system_locale +from ccma.storage.repository import MemberRepository +from ccma.ui.main_window import MainWindow +from ccma.ui.monitors import ( + centered_geometry, + ensure_visible_geometry, + monitor_for_geometry, + preferred_monitor, +) +from ccma.ui.splash import SplashScreen, StartupResult +from ccma.ui.theme import load_theme +from ccma.ui.window_state import is_maximized, maximize + + +class CCMAApp(tk.Tk): + def __init__(self): + super().__init__() + setup_system_locale() + self.config_obj = load_config() + self.title(f"CCMA · v{__version__}") + self.minsize(1050, 650) + load_theme(self, self.config_obj.theme_mode) + self.startup_monitor = preferred_monitor(self, self.config_obj.monitor_bounds) + initial_geometry = self.config_obj.window_geometry or centered_geometry( + 1500, 860, self.startup_monitor + ) + self.geometry(ensure_visible_geometry(initial_geometry, self.startup_monitor)) + self._last_normal_geometry = self.geometry() + self._geometry_capture_job: str | None = None + self.bind("", self._remember_window_geometry) + self.protocol("WM_DELETE_WINDOW", self.close) + self.withdraw() + self.after_idle(self._start) + + def _start(self) -> None: + store_path = self._resolve_store() + if not store_path: + self.destroy() + return + repository = MemberRepository(store_path) + try: + SplashScreen( + self, + repository, + self._startup_complete, + self._startup_failed, + run_housekeeper=self.config_obj.run_housekeeper_on_startup, + monitor=self.startup_monitor, + housekeeper_settings=self.config_obj.housekeeper_settings(), + ) + except Exception as exc: + self._startup_failed(exc) + + def _resolve_store(self) -> Path | None: + configured = self.config_obj.store_path.strip() + if configured: + return Path(configured).expanduser() + selected = filedialog.askdirectory( + parent=self, + title="Zentrales CCMA-Mitgliederverzeichnis auswählen oder anlegen", + initialdir=str(Path.home()), + mustexist=False, + ) + if not selected: + return None + self.config_obj.store_path = selected + self.config_obj.save() + return Path(selected) + + def _startup_complete(self, result: StartupResult) -> None: + self.deiconify() + main = MainWindow( + self, + result.repository, + self.config_obj, + result.findings, + result.validation_errors, + ) + main.pack(fill="both", expand=True) + if self.config_obj.window_state in {"maximized", "zoomed"}: + self.after_idle(lambda: maximize(self)) + + def _startup_failed(self, error: Exception) -> None: + self.deiconify() + messagebox.showerror("CCMA konnte nicht gestartet werden", str(error), parent=self) + self.destroy() + + def _remember_window_geometry(self, _event: tk.Event | None = None) -> None: + if self._geometry_capture_job: + try: + self.after_cancel(self._geometry_capture_job) + except tk.TclError: + pass + self._geometry_capture_job = self.after(150, self._capture_normal_geometry) + + def _capture_normal_geometry(self) -> None: + self._geometry_capture_job = None + try: + if self.state() == "normal" and not is_maximized(self) and self.winfo_viewable(): + self._last_normal_geometry = self.winfo_geometry() + except tk.TclError: + return + + def close(self) -> None: + try: + maximized = is_maximized(self) + current_geometry = self.winfo_geometry() + if not maximized: + self._last_normal_geometry = current_geometry + self.config_obj.window_geometry = self._last_normal_geometry + self.config_obj.window_state = "maximized" if maximized else "normal" + monitor = monitor_for_geometry(self, current_geometry) + self.config_obj.monitor_bounds = monitor.as_tuple() + self.config_obj.save() + except (OSError, tk.TclError): + pass + self.destroy() + + +def main() -> None: + app = CCMAApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json new file mode 100644 index 0000000..4d52fac --- /dev/null +++ b/src/ccma/assets/CHANGELOG.json @@ -0,0 +1,25 @@ +[ + { + "version": "0.0.1-dev0", + "date": "2026-06-21", + "changes": [ + "Projektgrundlage für CCMA – Chaotic Creature Member Administration geschaffen.", + "Dateibasierten Mitglieder-Store mit transparenten JSON-Dateien, atomarem Speichern und separaten Dokumentordnern eingeführt.", + "Append-only Eventlog pro Mitglied für Systemereignisse, Zahlungen, Mahnungen und Vorstandskommentare ergänzt.", + "Universelle Mitgliedersuche, parallele Mitglied-Tabs, Dashboard und ersten prüfenden Hausmeisterlauf umgesetzt.", + "Forest Light/Dark, Material-Design-Icons und einen eigenen Splash-Screen integriert.", + "Optionen für Mitglieder-Store, zukünftige GnuCash-Datei, Darstellung und Startautomatisierung ergänzt.", + "Integrierten Changelog im Optionen-Dialog mit scrollbaren Release-Karten ergänzt.", + "Ribbon-Suche und Aktionsbuttons für ein klar getrenntes, responsives Layout überarbeitet.", + "Konfigurierbare automatische oder manuelle Mitgliedsnummern mit Pattern, Vorschau und Kollisionsschutz eingeführt.", + "Strikte Datumsvalidierung und Live-Altersanzeige für Mitgliedsdaten ergänzt.", + "Datumseingabe und -anzeige an das Systemformat angepasst; gespeichert wird weiterhin portabel im ISO-Format.", + "Eine ribbonweite Mitgliederliste mit direktem Zugriff auf alle Akten ergänzt.", + "Texthintergründe der Dashboard-Karten an die Kartenflächen angeglichen.", + "Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.", + "Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.", + "Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.", + "Anwendungsname und technische Paketbezeichnung auf CCMA vereinheitlicht." + ] + } +] diff --git a/src/ccma/assets/themes/forest/LICENSE b/src/ccma/assets/themes/forest/LICENSE new file mode 100644 index 0000000..0212030 --- /dev/null +++ b/src/ccma/assets/themes/forest/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 rdbende + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/ccma/assets/themes/forest/forest-dark.tcl b/src/ccma/assets/themes/forest/forest-dark.tcl new file mode 100644 index 0000000..c8aaac2 --- /dev/null +++ b/src/ccma/assets/themes/forest/forest-dark.tcl @@ -0,0 +1,534 @@ +# Copyright (c) 2021 rdbende + +# The Forest theme is a beautiful and modern ttk theme inspired by Excel. + +package require Tk 8.6 + +namespace eval ttk::theme::forest-dark { + + variable version 1.0 + package provide ttk::theme::forest-dark $version + variable colors + array set colors { + -fg "#eeeeee" + -bg "#313131" + -disabledfg "#595959" + -disabledbg "#ffffff" + -selectfg "#ffffff" + -selectbg "#217346" + } + + proc LoadImages {imgdir} { + variable I + foreach file [glob -directory $imgdir *.png] { + set img [file tail [file rootname $file]] + set I($img) [image create photo -file $file -format png] + } + } + + LoadImages [file join [file dirname [info script]] forest-dark] + + # Settings + ttk::style theme create forest-dark -parent default -settings { + ttk::style configure . \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -troughcolor $colors(-bg) \ + -focuscolor $colors(-selectbg) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -insertwidth 1 \ + -insertcolor $colors(-fg) \ + -fieldbackground $colors(-selectbg) \ + -font {TkDefaultFont 10} \ + -borderwidth 1 \ + -relief flat + + ttk::style map . -foreground [list disabled $colors(-disabledfg)] + + tk_setPalette background [ttk::style lookup . -background] \ + foreground [ttk::style lookup . -foreground] \ + highlightColor [ttk::style lookup . -focuscolor] \ + selectBackground [ttk::style lookup . -selectbackground] \ + selectForeground [ttk::style lookup . -selectforeground] \ + activeBackground [ttk::style lookup . -selectbackground] \ + activeForeground [ttk::style lookup . -selectforeground] + + option add *font [ttk::style lookup . -font] + + + # Layouts + ttk::style layout TButton { + Button.button -children { + Button.padding -children { + Button.label -side left -expand true + } + } + } + + ttk::style layout Toolbutton { + Toolbutton.button -children { + Toolbutton.padding -children { + Toolbutton.label -side left -expand true + } + } + } + + ttk::style layout TMenubutton { + Menubutton.button -children { + Menubutton.padding -children { + Menubutton.indicator -side right + Menubutton.label -side right -expand true + } + } + } + + ttk::style layout TOptionMenu { + OptionMenu.button -children { + OptionMenu.padding -children { + OptionMenu.indicator -side right + OptionMenu.label -side right -expand true + } + } + } + + ttk::style layout Accent.TButton { + AccentButton.button -children { + AccentButton.padding -children { + AccentButton.label -side left -expand true + } + } + } + + ttk::style layout TCheckbutton { + Checkbutton.button -children { + Checkbutton.padding -children { + Checkbutton.indicator -side left + Checkbutton.label -side right -expand true + } + } + } + + ttk::style layout Switch { + Switch.button -children { + Switch.padding -children { + Switch.indicator -side left + Switch.label -side right -expand true + } + } + } + + ttk::style layout ToggleButton { + ToggleButton.button -children { + ToggleButton.padding -children { + ToggleButton.label -side left -expand true + } + } + } + + ttk::style layout TRadiobutton { + Radiobutton.button -children { + Radiobutton.padding -children { + Radiobutton.indicator -side left + Radiobutton.label -side right -expand true + } + } + } + + ttk::style layout Vertical.TScrollbar { + Vertical.Scrollbar.trough -sticky ns -children { + Vertical.Scrollbar.thumb -expand true + } + } + + ttk::style layout Horizontal.TScrollbar { + Horizontal.Scrollbar.trough -sticky ew -children { + Horizontal.Scrollbar.thumb -expand true + } + } + + ttk::style layout TCombobox { + Combobox.field -sticky nswe -children { + Combobox.padding -expand true -sticky nswe -children { + Combobox.textarea -sticky nswe + } + } + Combobox.button -side right -sticky ns -children { + Combobox.arrow -sticky nsew + } + } + + ttk::style layout TSpinbox { + Spinbox.field -sticky nsew -children { + Spinbox.padding -expand true -sticky nswe -children { + Spinbox.textarea -sticky nsew + } + + } + null -side right -sticky nsew -children { + Spinbox.uparrow -side right -sticky nsew -children { + Spinbox.symuparrow + } + Spinbox.downarrow -side left -sticky nsew -children { + Spinbox.symdownarrow + } + } + } + + ttk::style layout Horizontal.TSeparator { + Horizontal.separator -sticky nswe + } + + ttk::style layout Vertical.TSeparator { + Vertical.separator -sticky nswe + } + + ttk::style layout Card { + Card.field { + Card.padding -expand 1 + } + } + + ttk::style layout TLabelframe { + Labelframe.border { + Labelframe.padding -expand 1 -children { + Labelframe.label -side left + } + } + } + + ttk::style layout TNotebook { + Notebook.border -children { + TNotebook.Tab -expand 1 -side top + Notebook.client -sticky nsew + } + } + + ttk::style layout TNotebook.Tab { + Notebook.tab -children { + Notebook.padding -side top -children { + Notebook.label + } + } + } + + ttk::style layout Treeview.Item { + Treeitem.padding -sticky nswe -children { + Treeitem.indicator -side left -sticky {} + Treeitem.image -side left -sticky {} + Treeitem.text -side left -sticky {} + } + } + + + # Elements + + # Button + ttk::style configure TButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Button.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-basic) \ + disabled $I(rect-basic) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Toolbutton + ttk::style configure Toolbutton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Toolbutton.button image \ + [list $I(empty) \ + {selected disabled} $I(empty) \ + disabled $I(empty) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-basic) \ + ] -border 4 -sticky nsew + + # Menubutton + ttk::style configure TMenubutton -padding {8 4 4 4} + + ttk::style element create Menubutton.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create Menubutton.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # OptionMenu + ttk::style configure TOptionMenu -padding {8 4 4 4} + + ttk::style element create OptionMenu.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create OptionMenu.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # AccentButton + ttk::style configure Accent.TButton -padding {8 4 8 4} -width -10 -anchor center -foreground #eeeeee + + ttk::style element create AccentButton.button image \ + [list $I(rect-accent) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-accent-hover) \ + selected $I(rect-accent) \ + pressed $I(rect-accent) \ + active $I(rect-accent-hover) \ + ] -border 4 -sticky nsew + + # Checkbutton + ttk::style configure TCheckbutton -padding 4 + + ttk::style element create Checkbutton.indicator image \ + [list $I(check-unsel-accent) \ + {alternate disabled} $I(check-tri-basic) \ + {selected disabled} $I(check-basic) \ + disabled $I(check-unsel-basic) \ + {pressed alternate} $I(check-tri-hover) \ + {active alternate} $I(check-tri-hover) \ + alternate $I(check-tri-accent) \ + {pressed selected} $I(check-hover) \ + {active selected} $I(check-hover) \ + selected $I(check-accent) \ + {pressed !selected} $I(check-unsel-pressed) \ + active $I(check-unsel-hover) \ + ] -width 26 -sticky w + + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-accent) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-accent) \ + active $I(off-hover) \ + ] -width 46 -sticky w + + # ToggleButton + ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create ToggleButton.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-basic) \ + {pressed selected} $I(rect-basic) \ + {active selected} $I(rect-accent-hover) \ + selected $I(rect-accent) \ + {pressed !selected} $I(rect-accent) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Radiobutton + ttk::style configure TRadiobutton -padding 4 + + ttk::style element create Radiobutton.indicator image \ + [list $I(radio-unsel-accent) \ + {alternate disabled} $I(radio-tri-basic) \ + {selected disabled} $I(radio-basic) \ + disabled $I(radio-unsel-basic) \ + {pressed alternate} $I(radio-tri-hover) \ + {active alternate} $I(radio-tri-hover) \ + alternate $I(radio-tri-accent) \ + {pressed selected} $I(radio-hover) \ + {active selected} $I(radio-hover) \ + selected $I(radio-accent) \ + {pressed !selected} $I(radio-unsel-pressed) \ + active $I(radio-unsel-hover) \ + ] -width 26 -sticky w + + # Scrollbar + ttk::style element create Horizontal.Scrollbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Scrollbar.thumb image \ + [list $I(hor-accent) \ + disabled $I(hor-basic) \ + pressed $I(hor-hover) \ + active $I(hor-hover) \ + ] -sticky ew + + ttk::style element create Vertical.Scrollbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Scrollbar.thumb image \ + [list $I(vert-accent) \ + disabled $I(vert-basic) \ + pressed $I(vert-hover) \ + active $I(vert-hover) \ + ] -sticky ns + + # Scale + ttk::style element create Horizontal.Scale.trough image $I(scale-hor) \ + -border 5 -padding 0 + + ttk::style element create Horizontal.Scale.slider image \ + [list $I(thumb-hor-accent) \ + disabled $I(thumb-hor-basic) \ + pressed $I(thumb-hor-hover) \ + active $I(thumb-hor-hover) \ + ] -sticky {} + + ttk::style element create Vertical.Scale.trough image $I(scale-vert) \ + -border 5 -padding 0 + + ttk::style element create Vertical.Scale.slider image \ + [list $I(thumb-vert-accent) \ + disabled $I(thumb-vert-basic) \ + pressed $I(thumb-vert-hover) \ + active $I(thumb-vert-hover) \ + ] -sticky {} + + # Progressbar + ttk::style element create Horizontal.Progressbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Progressbar.pbar image $I(hor-accent) \ + -sticky ew + + ttk::style element create Vertical.Progressbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Progressbar.pbar image $I(vert-accent) \ + -sticky ns + + # Entry + ttk::style element create Entry.field image \ + [list $I(border-basic) \ + {focus hover} $I(border-accent) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8} -sticky nsew + + # Combobox + ttk::style map TCombobox -selectbackground [list \ + {!focus} $colors(-selectbg) \ + {readonly hover} $colors(-selectbg) \ + {readonly focus} $colors(-selectbg) \ + ] + + ttk::style map TCombobox -selectforeground [list \ + {!focus} $colors(-selectfg) \ + {readonly hover} $colors(-selectfg) \ + {readonly focus} $colors(-selectfg) \ + ] + + ttk::style element create Combobox.field image \ + [list $I(border-basic) \ + {readonly disabled} $I(rect-basic) \ + {readonly pressed} $I(rect-basic) \ + {readonly focus hover} $I(rect-hover) \ + {readonly focus} $I(rect-hover) \ + {readonly hover} $I(rect-hover) \ + {focus hover} $I(border-accent) \ + readonly $I(rect-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 28 8} + + ttk::style element create Combobox.button image \ + [list $I(combo-button-basic) \ + {!readonly focus} $I(combo-button-focus) \ + {readonly focus} $I(combo-button-hover) \ + {readonly hover} $I(combo-button-hover) + ] -border 5 -padding {2 6 6 6} + + ttk::style element create Combobox.arrow image $I(down) -width 15 -sticky e + + # Spinbox + ttk::style element create Spinbox.field image \ + [list $I(border-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 54 8} -sticky nsew + + ttk::style element create Spinbox.uparrow image $I(spin-button-up) -border 4 -sticky nsew + + ttk::style element create Spinbox.downarrow image \ + [list $I(spin-button-down-basic) \ + focus $I(spin-button-down-focus) \ + ] -border 4 -sticky nsew + + ttk::style element create Spinbox.symuparrow image $I(up) -width 15 -sticky {} + ttk::style element create Spinbox.symdownarrow image $I(down) -width 17 -sticky {} + + # Sizegrip + ttk::style element create Sizegrip.sizegrip image $I(sizegrip) \ + -sticky nsew + + # Separator + ttk::style element create Horizontal.separator image $I(separator) + + ttk::style element create Vertical.separator image $I(separator) + + # Card + ttk::style element create Card.field image $I(card) \ + -border 10 -padding 4 -sticky nsew + + # Labelframe + ttk::style element create Labelframe.border image $I(card) \ + -border 5 -padding 4 -sticky nsew + + # Notebook + ttk::style configure TNotebook -padding 2 + + ttk::style element create Notebook.border image $I(card) -border 5 + + ttk::style element create Notebook.client image $I(notebook) -border 5 + + ttk::style element create Notebook.tab image \ + [list $I(tab-basic) \ + selected $I(tab-accent) \ + active $I(tab-hover) \ + ] -border 5 -padding {14 4} + + # Treeview + ttk::style element create Treeview.field image $I(card) \ + -border 5 + + ttk::style element create Treeheading.cell image \ + [list $I(tree-basic) \ + pressed $I(tree-pressed) + ] -border 5 -padding 6 -sticky nsew + + ttk::style element create Treeitem.indicator image \ + [list $I(right) \ + user2 $I(empty) \ + user1 $I(down) \ + ] -width 17 -sticky {} + + ttk::style configure Treeview -background $colors(-bg) + ttk::style configure Treeview.Item -padding {2 0 0 0} + + ttk::style map Treeview \ + -background [list selected $colors(-selectbg)] \ + -foreground [list selected $colors(-selectfg)] + + # Sashes + #ttk::style map TPanedwindow -background [list hover $colors(-bg)] + } +} diff --git a/src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png b/src/ccma/assets/themes/forest/forest-dark/border-accent-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..9e6cc8e13776930bae33ae496edd5130e09943af GIT binary patch literal 385 zcmV-{0e=38P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10S-w- zK~y-6?bESt!Y~wt;os(_P9Y6T7&@>am1g4s`tFr@h6)BYq^bgzDg-bwc87?HN>xQN zb>Qsx^YOK;CX-3c*n@UYx%2n!n|8w&hFwf#1Lz_@BUF+S?)ST!60rHr24(8a&S`7tpe{0N8QL zag_Wx?V4uCQG$JX06pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10TM|> zK~y-6?b9)C!Y~xZ@xP7jkOYQQs>IZ)zy*+c{|i*ypaRhasg(*tN!-}6J5XplASIbP z@Fp+s=kGnsVzF4nCL3U9Ij+#4B%R6!^v;p4O60+#nry(wV?k7+8+0lqakmM+FpQUb zl%3^AEJV_EiQZad9a>qzNa{&~ogR&9nxR~w0Z7*+$)@ZSPS%0pO9et;XF2}HAWu8f zTib2aUuC{~<3MnJfwp5_N_sx`RxaUJ@CyDXFqr;!NPc=H{`hU`Nzxm1lOX`YN2K04 z0PHl!-lczTYm?00XH*Xv0Q$W~rK=KO7@`u``3O57^-3vSBz-<0<-s#v?lEsQqKTa$ j(L~JaC*!9b!Zv&Xa5G~CEjM@)00000NkvXXu0mjfhT5QX literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/border-basic.png b/src/ccma/assets/themes/forest/forest-dark/border-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..a483271f741ac189a7bb18facfa74d3afdd3f93c GIT binary patch literal 333 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|+<<7sn8b-nUowcC{J^v^_j; zv7|%bqx#Z&&EM-yla{XVoa|sRbB^w`*3{KsZ~TfoaQK60u+QZ$(}E63FfSE3wdu&z z8Ip=DZ+`blD0_Hy7Rn^9J-Tb1hh{8OCr4B^&*6sDNQYw*5!r<@$5me}pCWYbvrC}J zi{Elinyk<7xZij`r+CFGu0=ZD2A^{$%v5Y?*rEIPC8PWY#{aBF#vL!rTwG3AI=2Wc z*t(DRS}E^)+avqlXWSOcypm5Fb2?rjG{H=WZ=$!ESdi(F4TYk9c3zjEHk cs=Xgrx&DRD-{csr5A-F2r>mdKI;Vst05q|OZ~y=R literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/border-hover.png b/src/ccma/assets/themes/forest/forest-dark/border-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd837a4d84c06673ede146505918d1f7c297857 GIT binary patch literal 337 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|-b47sn8b-nUma_O&R8v_3R8 zVByv=X=KTYJMg_;)SLO@g4Q4(mN~j+vvamZ9l!RUao?RekL-4>t1dp5*wZM$G$m-x z$|+3}%6q<7?2W4^I+wWCm4orHfx^-fu9TjInNkti7r)!iJ0{U0V9@ti^TqNhLd!Cl z7&6v6wg}94{!{r)`RPpy)@2KLX%?NEesGR4hoZvtrS^9p@ZNi{|3DCzMDldSV-jAP z3WjbbzQvL`x5aL6Ypi&$xU?nc)naeSPgg&ebxsLQ0NFW)4FCWD literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/border-invalid.png b/src/ccma/assets/themes/forest/forest-dark/border-invalid.png new file mode 100644 index 0000000000000000000000000000000000000000..63cdd6ecafb53e64087c8c045260ef7e1bf0e327 GIT binary patch literal 408 zcmV;J0cZY+P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10VPR9 zK~y-6?bJI?!$1^;;eRGGjy*<7D6<1gRHpO}pyHuqHApmB3`Hc$tbjB~R7hDuI)o(> zd*a7XBtQy~Y*!RH)otd}y;B?!$FUprD#peV>5@RuDw%_2p_pxaR+U7JdKGtr4Mw5rX=CiPhw3dyqfvicqf}6c@Yb+|#C9uQhnb&H&w3f{C zK0?6QSk82b{Q9#+u3BM-dCD~azHofsmbzwV{<`VG+wRIAIf6&anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&3=E9So-U3d5v^};7x%!EJEksD>uvhex%=p*sYk_vQ@ShtKN!?pO?hllm$^1^t!tpjiO({c z-G+~L>E+yR`@PRGDx2pp!*iKwsg4|qniDz#LPdY=J8pAcAzOD&@s01cns2rXF!eol zn3not7vCxku8Q};yW=X~2ToHBnfJWIaM`y#U4~AYtW%{vg)==-bI2n^EKBN9SahW? z+@saNyfZN*+N-!s%*}p?YaA9t=iEndhtmF6EZ{CA-fH!#c?JYD@<);T3K0RULgna2PC literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-accent.png b/src/ccma/assets/themes/forest/forest-dark/check-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..81f4a62b75cfe8781af677d8f00a4426bcb3335c GIT binary patch literal 434 zcmV;j0ZsmiP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10Y6DZ zK~y-6tWmxms!UWD!j;0v`fmLSoz#|va`@bl1PcGJcAsvcD23;Lvq={AUmDYEKC zyJO#SzG8V1BDx#wjTM>W22%UZw&Xm;&YENvf0Av^cfwYdXpj&-&-MppZRw0s#5m@c z@qVDJEuFg*r7g3oL-K>o2i*W=ZNcq^m4cl?Lgzk31*_h~Q3Z7zmeMBCYF0BR@CtX;uZLc!66$Hr6 zTa}?A!PwfH{z@5#1d0APFqmXWd%QrIlAl~N(T?`Z9Fe&p9;P(jvLBzhYASa$Uf0C8 c32K$|2^9E~gB24Jx&QzG07*qoM6N<$g52P`X#fBK literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..dd93bbc3e9860ef95783009a69067ac98c94c591 GIT binary patch literal 406 zcmV;H0crk;P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10V7F7 zK~y-6tlY&P=PG<%Rq4PxkwL z(ppm#1;!Y%EMrVDM!fgbbxqs0d}*!O?RKM~t}M&Q^BiLg&N)<36fa}X@M^U}8DrkY zy5{pa>J#g_lv1cypJR+PP4g3d1q&fyt>tt&y$^;EsHzGPp)AYyz(;Je*)W^U{s%({ zFJrx+wHD_bDW%a^FSuT>@!oSd97rjR!T`RW4~xZu$z;Ouc%-T-LI|Xk24TGm0rEVj zEK6>;8zRDXyB&n}E__@nB519dPN##gKS6-yayc04QZdHSProH1DDVBpz@}+XbzSp` zl`%%n=QFBpTby%TuUAB5G$bNiE*Gq|-0ye30ga@k9&)`CL;wH)07*qoM6N<$f-}RZ AP5=M^ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..6a9005646fb98ad5b9c5fd4209c92f1f56bea661 GIT binary patch literal 434 zcmV;j0ZsmiP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10Y6DZ zK~y-6t<%d&!$1^(;eRF*lQgB?+Jz#xw!1D|2>R|~6>*`sk-mXQ6K}NI37N^bC`HlU zlK3wV%*Wxth+ePfil!p#wlM7gl%!OqKqVRBV1yXwsG_O(JnP{6lF%$AF?$go?SQzS zBC~F*I`$*yOY~6-Y1%<;Y?kc=$kI1kl2d}pn#>BkME{*v!X}oeKP0?=s}D+TiLMev z?$F0O^+2gD(d87Ioa|z^Hdcc|TX5B|xnO*e5M8GT`>n2v%>{M0g_mZ8H_r&`N?`!% z_rM%9$fO{=eVHCVD-dQqY%U0p??<>|o9ONpJi+H_2fOL7#1?|HO|=Er^w!3ff&lqp zV{K@b#1y3;ElL=NR7v)4pc-mqI2a-F`df;;Ab5C3ig8ZdPw}5L!p!+1t03d>A1B1; cL&U`L1q1<;AdP+%DF6Tf07*qoM6N<$f+FL+HUIzs literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..4a49300f9a01c0cb280bb061bc17eb0cfa8f60d9 GIT binary patch literal 317 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|+z*7sn8b-nW+yavm}eIrj1W zUbU%VTy8IztQ-$=%G_aHBHh8PaYJs+HMPt=8WkI+X4$;_KVyQtXT_A%nVSn6s_fdr zgampVXNqmQ(6LRuAXOnp=E07Df~Gdd?<~&Cl_f-#PI?#E%J1*DeZun4ru$&j$^MPi zX;Mw>|0DhN6ns;4+GV6ZzhL~mk*Z1E4!2gfwhGlzf@wZj_HaEB2Gce-Q;eN|I zrJYSC*I(hW-LVL3m)TrrraDfV&hef11Yi3}tCro|lS&y^>8|^(WH({{4Y5$5uNXXC L{an^LB{Ts5lAL+s literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..219b92d77deca37c57a7a6c8e027e72ced9fd681 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;3L7sn8b-nW+wxtk3H+8(YK zKUwf7EiF9!|9|;H9!b^NQXECs^-49j9yN>p-N#wE<3Ma%;*O~7hjW;X4@)!~cX9-I zbr>o=?>QCKz3#Q2bM-L^6JO>uHx9-pbAC%yx7^;wYWVs3jkwtB3xqDe6%<@Cqins^ zE{WqGpB~=#e*Qf1`5*ZIF;(nncd!1o?o+(j#H1Oknu5HJ9w?1+Z?X$&%Is1Ji*L4$2tFRHqa9cp00i_>zopr04+{*i2wiq literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-tri-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9d10895a7a54a71e5558bf2fb737370020fc90 GIT binary patch literal 320 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;aW7sn8b-nWw+`I;PfT~(*WZ+zYCplPYsTAH+a z7bLTCEEc}yP$v2(mCx#$fH2p&$1K-gYwS|wv3>DSM4RQa?GCTS-&4yY`UCrvHuPOR zzDe45(Sf=7-&nL+%C~7>y7t09+@JmP$q$KlD-Z4eAl|aJ{>HcT^*;`sx@9qW;n{JAQnCxKS(XlHleYu1a P=rIOQS3j3^P6mmtT}V`<;yxP|%mVCm1ueXhpt8cy-clofBrVFQcWm zy-_cm_hv8i(idWum(H71W&dyet9LW1Z8wYJvu;Li!--jCW_Xm_ckuJy$UU{V8!#kjt89ZJ6T-G@yGywo=$BpR# literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..a483271f741ac189a7bb18facfa74d3afdd3f93c GIT binary patch literal 333 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|+<<7sn8b-nUowcC{J^v^_j; zv7|%bqx#Z&&EM-yla{XVoa|sRbB^w`*3{KsZ~TfoaQK60u+QZ$(}E63FfSE3wdu&z z8Ip=DZ+`blD0_Hy7Rn^9J-Tb1hh{8OCr4B^&*6sDNQYw*5!r<@$5me}pCWYbvrC}J zi{Elinyk<7xZij`r+CFGu0=ZD2A^{$%v5Y?*rEIPC8PWY#{aBF#vL!rTwG3AI=2Wc z*t(DRS}E^)+avqlXWSOcypm5Fb2?rjG{H=WZ=$!ESdi(F4TYk9c3zjEHk cs=Xgrx&DRD-{csr5A-F2r>mdKI;Vst05q|OZ~y=R literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..da35159a31d1e49e185a5f5190a226c101881cdd GIT binary patch literal 341 zcmV-b0jmCqP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10OCnR zK~y-6?bWev!Y~kk;or_tN5RTc7_+o2W%JDQ_W?RoEHJTwDzyrsaboNat*QrE1Xs{%(fwac}Mv;$V*1kR|U=Lp6T-& z;+G|6+c4SG!zTMl`N-Yo2tfN(qQlIP?V78wzsc^zd2Y~%Mh{%`zqF4eQ4GAm1@H)t z;0Rs>hDb8EyD865A))2q>8s*qu&)Ya-=gz%P4{T&7M7G}S54L}EZxHt^ZJ2u&ZT50 nzb_z(8U(74kbV1&eV+LO@-R}CXbJlT00000NkvXXu0mjf2S$=f literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-dark/check-unsel-pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a88253b6fad6b178350a54fb04328b857a9345 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|fb1f8?+_!m~}?=ob_GFR9*i~ zRlViwmU8JGjnY4)Gu~uLNU~3hTF>TX+EAX#Ej4}pl8a8U?-E|QwYWAvFl*@f+}`6{ h@K$i1c7Xju*4=Nc#9F^kWd}N)!PC{xWt~$(69CZ+YjFSo literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..7582f0effde6403106e83dd438bf9556434c54be GIT binary patch literal 235 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*H`ai(`mI@7t>zxttYw94`se8ZQY`H%*sOI35N%|7WUsxX&oHnm${?9RMrv8dm`xbQkXe{=-7M0Ha d?K*b_^YNM5``QoR*nDw literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..50dba42c537906dbc4a4aa25bc515e8dd96153b7 GIT binary patch literal 245 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*Iboi(`mI@7t>zIhhT494^XR zx9&VAQJ%NSRqce+P0P8VoNG>bR=1e6hc4M+f52H`g5LJ$k9iB~Mf=R2A2}l+7$VwG z5OGt~DdDzRRQ8o9#vSV#etT*57+zERqxbv`_p&K92PGEkb96`B9TfiKUU2Ru_pF)v n8cKBu?`1<{tvBqCJHRebrL*r=%G(a0YZyFT{an^LB{Ts5jqzAZ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png b/src/ccma/assets/themes/forest/forest-dark/combo-button-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..555d685a130208c6b46cd6c2cb6c10e35df276c6 GIT binary patch literal 239 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*IJii(`mI@7t>j`J5Gb94=bV zP>OJxP$M+HK~@*SP!_79eLPW~3?Rav-ktJtcpjtf!D2V#yg^cYsWKWRBR zi0Q#j<~p(DCExd%Kg?Hb*}S0moleGDTkb>8rsaQN`!5)iJ(o4~tB!`!^8-=ZYq#!Y hwtk+{^YJUwy^l+@cPrjK@(Jh;22WQ%mvv4FO#rjkSlR#p literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/down.png b/src/ccma/assets/themes/forest/forest-dark/down.png new file mode 100644 index 0000000000000000000000000000000000000000..8dbdd89798861c1e21d904a5a9c7f94b8c484a51 GIT binary patch literal 251 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!p8<4C?sm%aVoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&y%E{-7_GfVpnxtI(^SmS4@ zMsPZ@cl;&kI` uMvJZ2`V?E9pYNYps^UIr@yycs?|8Fgd`g~L&h!GhiNVv=&t;ucLK6V{Bu@q4;BhGFVdQ&MBb@04Hc4rvLx| literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-accent.png b/src/ccma/assets/themes/forest/forest-dark/hor-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..b471f4bc5680c6c6305a445af166b59fc0711c5b GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0xeo-U3d8WWREOiccqcW^!6 z;(J&E2qNq(I)kn{tlC|0#o-iV_kn%aLrf>U(qm!BbY)qk8Db?5)XL!L>gTe~DWM4f Dd}c6! literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-basic.png b/src/ccma/assets/themes/forest/forest-dark/hor-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..9a73a594589236318ea1b7816eda651ef8a2eda0 GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0xeo-U3d8WWREOiccqcW^!6 z;(J&E2qJ*|6bV=b~2o;Ex^)+-%iI^mTb3&R6lmQ@qYS`C3(89ZJ6T-G@yGywou CiZBKM literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/hor-hover.png b/src/ccma/assets/themes/forest/forest-dark/hor-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..2f8b1965318df1b038038d3ff131f832848b7dfc GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0xeo-U3d8WWREOiccqcW^!6 z;(J&E2qNr$dj?;1Shc(0io+>J?)S@anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt+C@E{-7)t#8j63Nk40Fc>~C zhpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10yIfP zK~zYIy_QW+oIn_WpPAuP*ww>sY^u?Nt2a$d`~QE0XKP{;+Zwh9R~8sxzyoz%*j1Yd zJ~!T(z>}Hx195qIS<^)d@`*z+bupo$_7t@e8{0mDFf}4GsMugf8fTtEwW(rt1I5%O zI}I_O>{X~stps&oQ3V#ozDt%Gyz3Yz%3J$Z7B!I00+#3F!R5A5`HJLxm-Hk6@p4-$ zlWZ2Ck9Iy?tcyP0Av+0H&lWV-+LaR(ICm+|y+$re08F4L_C5502eRLsTqis7Q4a-n zq_L2K>@;l4Wrpx-fr`sk+**`aix}s3Mtm`*_S9gK=`6thn+1H7nQFlCJwTgx~&E0E`3w(85Nhfzp|nH0LUi}(u~@j#<|!3{Q~>Xu!3e}AuG4t zQq=PO(|g8Rn!cS{RG#8P4R5#%Y!HByx@rYYurXX-#RS7uF*MyoszuQX=KJm|+czn` zczrvyuwsL>=k3|24?Ilx{wbP~h5kCMpdD$XGtZIfTCz()d@+7g(u^#Y7rTQ_6PYxB zIg+ZzyN+9nwWoOezDIf%QjRT90+gWKwMoxH9>49;t^U68uVbkH&0;6YF}Df&cx@g_ zfj;!nhyI`<+i-6ao>Omi4E{}m_cQ%;nJ(UKf`5~&o?jBJZmwEA6r{6&${)6cbdia{ z|FOi5+LLUf66{E0&o#x=C7(D{V;kejCJ+0>)F@q{9?RyNk>1+*p8&Y*+2`Zu@SFeu N002ovPDHLkV1iFDJNWpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10u4z- zK~zYIy_UjBK?TMS z6qaSdvMhL>2c;B>qJR)$n)kDjBndW~&Ej&;V2mNlGQ@EVLdc_Fk|YSj@Xy8OL{Wq| zj>onE+@^Idr?p01*J#@oQc3`TX_{EC*YJIR@|WKx3`2BXhqi5D5JHe7$s|E3g*?wk zu{(X=qwjk(O@l1U7F`N)976~Jx>~I!AJSSsmHUa55_z6K-F4r>G)-{MK{@A>j=HX2 z%H5GtqG_6?`~U#1>w?<0JqfmL`}PaY=kvFnCfK$OY8ca_@SV~7B=-{-WBBg%umJRO zxl96n_`biC8-Uw}r9ucO zrN$s31X-57l(cOdS(Yt&n+(HnJE*nRD2igz$r!_Kw?mqya2yAeQcz0aI1bV@#df>J zeD{BwvMiyszTL$_2vk*tD2m1eLI{E&KoA6riaevLs1Pj z9NXhOX{}L~CAzLd9LF$C^H0TwVZh;Vc(}pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10zpYc zK~zYIy_L^T+b|S>zc@~s{tQ|*sTD{AjmzkX1902_&mFcMdVmSk?0`srkfgEW>;NrI zTc8^a-zj-^tS|QSd)D#san3TrLUlZJ*CX>C@~Q;eTAB7PvK%Yc*h7UJBsf6=W>rNl z2Xwc}wC7`L_I8Dqu`Tk3gKjwJ-5P0>;a-G~q6>)GLk>)PKJm$?^5xcGYBtGfo8+hk z*7Ir21=F5S`nB=lV#}z*Cev@N`MZS2V_lZB49Keq?rn@1DFCvjMD5j3`*jdo&dCMR z!v^+!iX6m9gvGSy7viHe^lh_P9RM_DV_jg2e)+Vo(mkQIP7V=MmP#v$3bCzMP z+#+J6sGZ-fy>;8d)Fry(A#~R(49Kg=t8z2MNa0>bW%&W1Iv&FKZXxL2#v3mnZ^{+S zb{vH9L?I}~dgBG`f0+9}4`@^j__8=Sq|#hsxy;@~2+x1PNJx zj5=&C{DMV#P$xa8mnZT9Zhy=y_2y;9`#Hw#Pd;3xKn`wyLgm+Z9(t}Rc~0fW5c@u5 zdep+yH!nBaKs;zFmm_3Q>|`qzau6ef7~QR+Iv!@n#drebyK4F>+8`2x9V%oHBL^|- a>i%EgYu=h^R^6%q0000pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10x?NM zK~zYIy_U;P+dvdR&pd38u@hJ%qACa$1a=^y_WwVEHASLQAIRx~;MgA9W3xyJb;2W3 zW6ti5ujQkeJNH^#US77WA_e8hry2*iSkW3qtHfH`{~%n8C>$zvc!|ZIme8!L=+%X4 z98jFbI3u?zv`VW4ZKP=;O|>6T1IsTnR&T9Z%q^R}{_P~IA-nHT=MBP_- ziA5s?#cABHT;YhXW~j98tm|2go~8J|3et-qtx?No`6R;oQzFZe-)qf@h8N}jMWr?I z)vR-in@~}Wd}K8adXt02vA!kugi33IpG9Y_9QjB$SnD)f#lN!~=X$@pf^MLZP0(*k zQS&`T`RpO(nnz4F=?kb?AL_@<;AXyzBYooOPo#b1u3 zX))Jne}X`36c1nb$j@Txp#~*D3F=*s{4D0-%O0!UB)m?c{X2{Oq{Q83*yA4Ou@upF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10tQJ$ zK~zYIy_Vf>;xG_KkFER)3;~pIK_vPLegD_t21*L5EfHgcdx1a=bXm5G``yjxlX5f} z&y09|ef3;P2tmK!M=3>`ro?fKl=9m4UpgL-IOnj|qP4~tgZDQmMAr$W6jfCb$MI8v zt|g@;O;ge|WiS}voMX9MVvOnfz6MoQF`LbT!+obXj;ZUKvMdoo+yYir#dtjab+Btq zCKJlCyu=Zlersqr?>(E%2CX&LS^$zHVK^L86h)_MCfZ{fbmTFYXw=)B@2Nl;25l~Tdr05+S= zUAVKXwQRTBPOjJMA+szC#Rh=Z`eE2>WdZc=P^2Tw@bJs%v$;06g z^aE*{{@ClBL$+=EY^II|gTarz)>`DhH1SVV6vabT%(9H4C^|WK2LpUmP1CSmuP^bXuHJiA zs};N5jpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10y;@V zK~zYIy_QQ)+dvdXkH=%jc^WmUlnA7P%BEz+qOj}#XAf-`S(FMCcY(+QY{xU6=^`Y- z4uw>Wdsg<ZC$ z3%XxtJ_s=lceg@iTnF3o&@B(W-yn-~{LAq|^ciCLV1fA{B>DWVdbt-c4VUz+OM21) zXT4i1!E6wceQf=Au`=qo&HPhm^)6wttyRl82IP+i{M!UERRH8|i8^SY4x1p>tCk(N zaPKqZC_y3|=7X?Wxg@84afkaPw!|GP-0=keXH0zF#n#tHYktx}{z?(57gT!Y9K(xp z&k<8a%53rWn*9q=@#{8s%X>Q*h zc7b@*)UKw;s5p5xEaWIbMhUuKNA&{Co{tFx$Q}1|Z4jx!9V=v%AV&#o{_*o0Xw%-B TwY6YW00000NkvXXu0mjfg~L2Q literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..099e14886ae0a4f8e2fe924350b72de149e7f5b5 GIT binary patch literal 565 zcmV-50?Pe~P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10m4Z{ zK~y-6rIkr@n?MkSzgb{FSgKM=a&9^}$}K+V5dZ(Pe}b1(*;kaMEFl;Mh8$$$5=)#o z@7Ue%O*g$DHk(aD7gA6zLfq0&pDCJ10U(MRwXX?MOSmn;_o=#Ta7#n+`HVV}z2GL% z_(bDA8j8FozRL(wdzwod4CL#C>~c0J)`gE1+2xFUoq!O1!D5|YSCcn|y0EJW#i!&{ z&@Iio#M)$5F>Yy|1*JHq{h!F!2}pr17a=}YgF(`I;+sACM+rdnAtb+;;FDf2@v*`! zLUi?<>X6v<`X|Eq))u4|{mZetn&VBsapfXJR@44#0P)TKMC=8^dE(psAg7*c@YPinj-D2x8nUrR|pm#|DhSjrgSXNVTg5o%zLtCg>O7gYhv_y9(Le#g>?8 zc2{%UxuH>lMhVJu!|v<6Jr-RAsYQpWrO4~SS@cGdeVGlCd)XD9U|S+-Aim4~iu5N$ zw|nr-vCBbfF^`A$i#;6LGuD3f`2K)-_;=ChF(>-=%6natrXjx1&`%ZlIvL&X7s!zV z+uOS!wFFy>TN=tmh@S=2&)cM|M?N{iZFv;x{{i?7vC_5lvz1BI00000NkvXXu0mjf DJCF*V literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-basic.png b/src/ccma/assets/themes/forest/forest-dark/radio-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..6b745d15d97a65ff549e8bb8a6348f91a7c99be6 GIT binary patch literal 543 zcmV+)0^t3LP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10jx%VIHovNzBG)+HWS!<29ZNG*!O|x8M zi*4$pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10nkZA zK~y-6os>&&+CUUW&y2^|2J=uVAxM|)hOmm%?Aw1{sXrmF4SQQrrD78BV2@|Ia3GCs zRE2Nn`#5v&o$C?T*Vm4wq@bLIn0ZJw_HmH{K;#B5D^ZEYPc_(fs%jd{JYv5bWBXn! z*u_33_A#IP6uXMxevL}>`&y2{zQJJ ztm`YxEJRkXrFOvvKc7%Zj;I`>a;PL{@as2HSDiWKEJRk5_GtjYZ3cyH*+ppy?y}CD zYT_epAUmChHb3HXp+`6EObG;1>ULXDU%)Au6F#9Uq|Ay=r(O(ke!BtZ%ad#7txII{ zUpywpKD!@N%w>dA0;dG!WyJRT6c=@`nM!k%pX3xEfLz1J?95at7XCZdd!-jPuQd{J@z)v+Q$?I3Sp}%glxu}-3 Rb$b8+002ovPDHLkV1kG70jU50 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-tri-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..756ff13571c7930db000ca236eacd8a106e0f2c0 GIT binary patch literal 465 zcmV;?0WSWDP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10bWT& zK~y-6t(7}(gD@0E57&SpB$+_nvXqV;^8bHIA5y!LDJcQ-Fdc}f3RF=-&uHwUE8qJW zXTRTfj1h+__Nfz1yH<380>CL-)K`m_8T>;9c6!tuFVu;qd|J~6GTGckE;e$hA2emr z629hmnfYDIuo)=Qi2QjwD;r}&MgF{{NF#8Zsjy5V%x-m6G{)>!l#kJGVV&r^WJ58# zkUG&9p*Vfn3n|hF#6haq$A)UL(1waS_L1gN>VHthKGJL^lXExi#>KUIeni6894Xt` zZ#Gb~_ih8l^?|zq2XMt$>}uMSdpa-1BI?xqLv20k)F9(5F2TzT;%5da<}d#iRB7=J z6++i{bSc5h7uc@vvX>e3=lOow$9Z_hhOr6X&ghTZrNL)G@cwh|CnjA-_?F`y8;Uf# zns52zft}>e%M9MZP$!xy_OV-!_L3%LEOM;z4^=Ok{sH&~F5il0F#zFi00000NkvXX Hu0mjf?>o+^ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png b/src/ccma/assets/themes/forest/forest-dark/radio-tri-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..0f20b2149e939a08fd516eb2914837b639f4f250 GIT binary patch literal 450 zcmV;z0X_bSP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10Z&Op zK~y-6t(DD=gCG<}KMaVC-Y!gF<5Tqg-w3K3NJ@o-Su`4}PC8>fJ48?T`R-CBlBaAU{&b_@; zEC*U^bX~VATO))(*LBcZgE6)hYOT?>?MKm?wr!!5dKVJWA=y$&L@yy@3|i~MvI(UW z7-J}!ruoyEeWEN&5D|)Lsb^5vHHxaLP7_vD1DAu+34bC|f-g}JW__Zv?2=6_LoHMMoUki;f$T`1m z**S+{7!J$&zMtFJ92;vb`o90SY#0XI@Ar8;r+V6Y2mwl|-6$;?#}U@rO@rN($<*G7 s2z6cKa=Botrj!yMj|aT>zy1P#008)jpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10d`45 zK~y-6t&}@+qc9Xk4?>v7vc|L9z-+l`5-Jz@|9@hgH145`aUK#N#1h?GF%@Q1EDs1wE=5KsbG^V-wNqxOM7Md(P zOZF~}Fetw7!@v z`kM-&y4jkICXK`?lLR)M+_0A5!#H(1U+hT`ctfRbcz!E6YN&*gl>uTWp_@r5chRcDR4UJ43qIKH=Z* z56|xnw(kk<_Q;2p`eup!G3mWWh-ZVp*&mWkg>M6eLY4}Xg|umaT`jP|a1fy_V%Ol6 f8d<2JE06dOq&td~0Mt>m00000NkvXXu0mjfhz-?H literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..b8a2f9572fd9c083a4ff131bd08a29972acac959 GIT binary patch literal 605 zcmV-j0;2tiP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10qRLa zK~y-6rIkTX+b|S>zu2*pGz3~H9nucF&`IpT0d4>PA8+9G#z^2VNs0x>94O3H^a2q!NIr?U9c?sxs*N4*X_l{CdDn6WWgl zM2$n=8M0rFsO`FQ>Im)0PSO<($a+t+F+d z_2idxy6JG?sk@~iEE2!J+{;cH%-uY zW_#2Y)NM;wYJ@9d;s>|4Fs?0q5hHve5Ks4PAEfd4RK8rBeF;DW2=Qdz7oP}(@d&wa zxTB31xHE37kPC-Um7#l=>@Bp%DXKCET^W2PaTB%I?m{L}_Z@-&+0~Nfv>>QxXIW5! z=2JoX;}Luqs;nx5stn7sBZB(>!UrW-o*km^%yS!ij?S(dpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10lY~> zK~y-6t(DuZqA(Od7f>R%5@Q14frM}H|G&mWqk=|5u^`qk4@QpBIWyyU*5gjQ+THH% zTJ!mQ;0epJh@uE7C6mbn*L4B7TrQl?XOvPjO~Wv}Ohr5gq?Dv-O5gV=rP%NHTrQW} zU?B*Cfa!Efnx>RxiBjr&VVb5yQN((^Mr-}Ddf)f-eb4cDBnSesEW`JGs;U}yYd^|7 z&ttJz{8sji)|$m)f$#eyNixRmdzw)cv0AOTUaxvPjdE?RNW? z>`}VeY)Fy>%d)VeD5CFsPN&n`vO8L9j4?zK zVGAL~I{O4;46f^bmCa9K-_4&Qguobs-E|$l?|&=|f`G2;u$59{QGP-k$0((+o2J2a zU4kI^Xm_sb(libBFbq^xMV4haj`Oz6aU8NNqby5?VYuD$y}#vo{+85n9P&IzDK$Qh zv23cULTgP?6#tZd7={!@LEE|^>;O2RN?Hk%>DZJ05Jwrx2a4&QzOzW^xQpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10rW{k zK~y-6rIf!<+dve@Klj7FbE*=z29cDZL#H-FVPWH+Z#R}!RRMKt2XG|nkN}A<_MP1! z1ge0P(tO6JyU)F+clwHhgM*e;q@eCueBTnf5pALY5T!>=YIN?1h7Ov?SM_Sa_Y>xW zE@D~}T{!HITSVyrXc9%p4E5U=R3EySlQBAXPYYY18g%iy7W-v{Dyqkg-+JV&C;Bxd zWCpjN5?;lavm0m^-OAs=#V?WeTy zf9xohxcwCKeF9Cp*s{(Y(M3sln89u~GmcU*u@~yHOO&A^mB^N%S9@$@CO{d*f-gZ;U@q9002ovPDHLkV1g3O B9<=}f literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-dark/radio-unsel-pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..493f02d43de3641d9b7072ef0ccf7aea53fa1fd2 GIT binary patch literal 468 zcmV;_0W1EAP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10bxl* zK~y-6t(47jn=lkc4;RUR>l#n9fyuJnG_1VH`@ck!Y`lkEZRZC8Lh8baGltGMV{>-W zTpfLqMmJcm*PbB~&}A_;kLgp5PZR(_Z3%ZBsx;`zfEzDW!wH)wG)0P=hofS@(Ab5> z-p*;?dZLFdsx+rrj>SMzq}2C6{wo{e<{|a{4b9yWf?zCciDh;#E^To2+<^@&Nhor|Ho}SScNN1ZDqNn|F{Mlf=Cw|#|Y zEz!gNm~1Tkm?&#hRb%snE{k!i87@8?M2thWExIzOvOaX>0sjD4+=5YBJH`S40000< KMNUMnLSTa6fX<-+ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png b/src/ccma/assets/themes/forest/forest-dark/rect-accent-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a88253b6fad6b178350a54fb04328b857a9345 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|fb1f8?+_!m~}?=ob_GFR9*i~ zRlViwmU8JGjnY4)Gu~uLNU~3hTF>TX+EAX#Ej4}pl8a8U?-E|QwYWAvFl*@f+}`6{ h@K$i1c7Xju*4=Nc#9F^kWd}N)!PC{xWt~$(69CZ+YjFSo literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-accent.png b/src/ccma/assets/themes/forest/forest-dark/rect-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..bebf94847ad275619252bc6e5d3136137aca9193 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|=Pi!%wSNzq@|3|28qa<+vSd^y2j7;7z>;51!xm zMEOwBt=*zA2SxwzPAN3(InrF3EYAG3ltKNgtV*5Pv1`hfr3vp?Dz7T-<53XjoBX-s hy#JHqY*(!JFr4DDaxH1vlnit_gQu&X%Q~loCIF$HYeWD5 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-basic.png b/src/ccma/assets/themes/forest/forest-dark/rect-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..c6474d5352235267ad08d6ebaf2b68237ac83960 GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;jZ7sn8b-nZur^BN2U7!IDb zEGq2ayS}IKe?8kymv;=_V!OPxW`!sgp0rEYB7b^Q%~1(Y&4sI41S%%Y49s3S#VNb@ zyoB;+8~e6*P8>atJ6269b_?>VKUR0>vWeoVU-@tHB-oZ5zdN@>;qDgc+JoXX2k$qe zUE?h-Uvljhug!U3&7~I}UpjMR8*k>?iN((>`0o5QeDdzi?{mA>#ausXzE8&fL-=9i R4M2x7c)I$ztaD0e0syrwZF>L! literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/rect-hover.png b/src/ccma/assets/themes/forest/forest-dark/rect-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..b6694076a3c4e9f7592ad9cf808c5f29b1eeb923 GIT binary patch literal 273 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|-Y37sn8b-nZur`I-#`7!IDb zoHX0{$`8gbfBvhUu+4Mav0=hFPuIy>Dtb5Bd%hX^UOrvz&%qda)bjY$Gd**RHC4Sd zZT5ced0ZiBRebK^ciUB7J%XHye|l;U9X3!{^~=2MTB+=%NAG-GCZyf!y!U|j-UIUm zGffYl;W=q(&iuXBJ;-az4in>Psg7QnMy3Jyy5icYUF*(#K2s>Ohw)*M{oWUA SH*W#Dl)=;0&t;ucLK6V8m~E*5 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/right.png b/src/ccma/assets/themes/forest/forest-dark/right.png new file mode 100644 index 0000000000000000000000000000000000000000..336945ce751f6d5eee57fc8e19fe27d47a343fc9 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1g!3HF2ETPZ!4!j+wQ6ihKw&~H->KXTvpe2w{o`1~S}-#*SjO`CEhWvRSDrtf|3lnF)T4C%<+XW08yP%Z L{an^LB{Ts5oqbAu literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/scale-hor.png b/src/ccma/assets/themes/forest/forest-dark/scale-hor.png new file mode 100644 index 0000000000000000000000000000000000000000..37172d02cffacd6825bc394f101c5f6cfca567a5 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?52xi(`mIZ?cJr$)EEMt_NIv z54-WoEMeP}k&zK_^}xaRPX#j+PWMWiugH4v;QOZ)jZ>Hz7z|kUnQOWg0<|-Ey85}S Ib4q9e02Sjim;e9( literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/scale-vert.png b/src/ccma/assets/themes/forest/forest-dark/scale-vert.png new file mode 100644 index 0000000000000000000000000000000000000000..f268b60e887d8a44bffa2e453ef1f37e2acc86ca GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?4Uei(`mIZ?cJr$)EEL%o!OO z38MQ8AG7J{^7vS!o?uzb)+c3=^lHuk|nMY zCBgY=CFO}lsSM@i<$9TU*~Q6;1*v-ZMd`EO*+>Bu@p`&AhH%VG?&#_H!_UB?%ET=$ Sz10jT!QkoY=d#Wzp$P!D6dvUO literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/sizegrip.png b/src/ccma/assets/themes/forest/forest-dark/sizegrip.png new file mode 100644 index 0000000000000000000000000000000000000000..5bfc9672c215849fd76e17d727f24074aab8d4a4 GIT binary patch literal 459 zcmV;+0W|)JP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10a!^y zK~yM_jgsxks!$MxA3t`6sqG+pGgIVH`*5x<*7W#!yuie*s!nsD#Wu72E&-002ovPDHLkV1fy! B%NYOw literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png b/src/ccma/assets/themes/forest/forest-dark/spin-button-down-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..f4e0890205476eeb3fef8d69f1cb8dd30f7d98ee GIT binary patch literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^B0wy}!3HFwFZ>e#Qk(@Ik;OpyY!GHl4_J8!C@5Lt z8c`CQpH@mmtT}V`<;yxP?4Iai(`mIZ*osh&!6)Sss|1p uJb1uG_i#eOl>>py?z}Rl1y>w67#Lc0nA|LQwN3zaFnGH9xvXe#Qk(@Ik;OpyY!GHl4_J8!C@5Lt z8c`CQpH@mmtT}V`<;yxP?4^ui(`mIZ*quE+3)jTXU}we zzmmmtT}V`<;yxP*Jw0i(`mI@7psAd0QO>91d>Z zJuiT35>v*bR|o!Zt$1?o0%McYhX|nux4j(#8D}TtaY=A0#_ajbo@V^}sDyYShy3oD zTVG9KykhzMps|aK)pdatdz`%@4hhu>%H4bTL^5yHt?CwagH>6RgQD`;D@4C#{a^hA PXf1=MtDnm{r-UW|aZyiE literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/tab-accent.png b/src/ccma/assets/themes/forest/forest-dark/tab-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..ffdf1a02d0167d1caafa4ab7d61daa137f5d0e24 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3oCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-OOE{-7{oyjI9CV$QwFf?Q; z=*VuZxbi5l+g(=V$uzqcbanMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-OOE{-7{oyjI9CV$QwFf?Q; z=*VuZxbi5l+g(=anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-OOE{-7{oyjI9CV$QwFf?Q; z=*VuZxbi5l+g(==!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIa+o-U3d8WZ34So1YGh%h|2 zwPWL8Rf}`*cX;$yKVe7XM}}D(tqQw#+evHoT#B@M>*Mxn*R~1nJ+qm@RT)&)DqHhp zyzg|DE4&#Nq2sgTDpUN@DZcG}vmg2TMd-v_x3tRL|8Tmd*P6|Zu7~#2e&nwc|8abc zNPfnpT{jgh?_Ydlpm5Lb`neO|Y*+1Fo?~_CvMgsCi?Z!KjdEQhwt2UcEbM=MHp>Az Olfl!~&t;ucLK6TwvSXS6 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png b/src/ccma/assets/themes/forest/forest-dark/thumb-hor-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..1426d8d7fe60c7abf88ef041fa9a63ed46d5567c GIT binary patch literal 258 zcmeAS@N?(olHy`uVBq!ia0vp^96&6>!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIZ$PZ!4!jfro24Eb6VL|D`9 zrMY!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIZxo-U3d8WZ3481gkah_F7- zpVrVA;P_B*$zz6lcUz1l@)CG1NCYVC-e8uLG~wK(%;$S2+MK^DlX*<%n3^5KE;-Iq zVlR2B`b_$hyFQC>m+gN%??;Pkv()Av&UzO9hB8T4CjC7&<(I~nUw_wSB;MZlgTKyv z!{=opuLE|;nX~Vb3UIuslol&!{CIx8#!H^Wiw|Pik60bC+T-nCv1&ryBTkzw76LUr SnU8=jW$<+Mb6Mw<&;$U!sA$Ci literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..3db6b23b6d1c90d040a0e502bfcf334100f4704f GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6ei@^oeDF{#4@t>)Umxnn+NycU3wrj$j=D{C%KgiC@nx*!`CUF&aPO1ZU;7;*n z+f(k!O3L1P&bi&$^@0{()TO4oxdowKH~CJ4i0@C9XI)$Iruax$n(4MD+cYO0-Y4SV zlmGwO%-20YZBGwn`Q#{A_jdI3ZIboSH$HXz@G167pC^C1u~_ZAwDKx5-CaHJHvnD9 N;OXk;vd$@?2>@j#W)T1Y literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a558732fce4b106c4eb854afc9dc2cd46c07c4 GIT binary patch literal 253 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6dfdAc};XiQvt#+vW2f`G$8 z@eYk^i;TCPfABm0*dmt_GmjHdlhpDq{WNBuaoy&;{Bs#a78jOm-Sib&Tf>UZZMjxq z{d~rZL_NP%NO6Xe!Xjx4)^bUpIL8+Ti#fBitjhjRScf4elF{r5}E)F@?`}8 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png b/src/ccma/assets/themes/forest/forest-dark/thumb-vert-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..6137ff1a91fa2962584a02277093496cecb4c6b6 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6ei@^oh? zdbP_U%!#br{R+F~4(y&?z;>9ohtZ5pWY)B6qMe29Klna8ooJ}m=992mX5WPd1GA^h zOE!DVVl=VxIKi^MxO0V>Q}&?`$C>A~x_1S>E;N41dcT;#KC`woQ@xeSKfj z%QyE8GSdq#@hFIwB|l;M@|AJfMa8w%D}M8QDztv0d?nYZD%0_DlX<3|$-i^|c%}kf O$>8bg=d#Wzp$P!{FJ{O9 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/tree-basic.png b/src/ccma/assets/themes/forest/forest-dark/tree-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..06e9b18273066232593958ecff02e3234cb0bb86 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?3_Si(`mIZ*osh&!6)St_NIv p54-Wom=;_)5XkJ#E91q+#E^EE$!O0_F$mmtT}V`<;yxP?3eFi(`mIZ*quE+3)iXt_Kbs zIB+20qt6Vjwz(6kP9Ja)ojl>dipIrkeNq-#4^}h^02MH3X0Bvqu=>h&g++hWVxSoe Mp00i_>zopr0Iu~n^8f$< literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-dark/up.png b/src/ccma/assets/themes/forest/forest-dark/up.png new file mode 100644 index 0000000000000000000000000000000000000000..b02eda4f99e8f9d49ba7b61d01da1f02a2b50aa3 GIT binary patch literal 250 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!p8<4C?sm%aVoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt=7IE{-7_Gh@39xtI(ET-DDC ztzl7YGQV-_*r`*_Zv^*DuV~N}a}mfF=9u|&GmAE7&sDD*;kEy&U3k;3G0tG{NxQc7 zoUZb*9Ut%ApIdBh{9EYa)@xQR0(SqSLbo%>ovF#(w|SMFx2Cm{l9%RN*C(n<>QRR0 sKMNkPeqN)%pltm8bw=aNYKF)B+gA9L%ba4#Pn3!y0V)EzwfddCV y9Q2&RC@pZ~`>aL~;9RXEX0WOtba4#Pn3!y0V)EzwfddB? yEL?c7f%ndi!sbR0;9RXEX0WOtba4#Pn3!y0V)EzwfddCV y-1MKtDE;8>mnn@Pz`0sS%wSbP$ioj|91L@9SaxMEOF0G9#^CAd=d#Wzp$PzWATx3R literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light.tcl b/src/ccma/assets/themes/forest/forest-light.tcl new file mode 100644 index 0000000..1abb62d --- /dev/null +++ b/src/ccma/assets/themes/forest/forest-light.tcl @@ -0,0 +1,541 @@ +# Copyright (c) 2021 rdbende + +# The Forest theme is a beautiful and modern ttk theme inspired by Excel. + +package require Tk 8.6 + +namespace eval ttk::theme::forest-light { + + variable version 1.0 + package provide ttk::theme::forest-light $version + variable colors + array set colors { + -fg "#313131" + -bg "#ffffff" + -disabledfg "#595959" + -disabledbg "#ffffff" + -selectfg "#ffffff" + -selectbg "#217346" + } + + proc LoadImages {imgdir} { + variable I + foreach file [glob -directory $imgdir *.png] { + set img [file tail [file rootname $file]] + set I($img) [image create photo -file $file -format png] + } + } + + LoadImages [file join [file dirname [info script]] forest-light] + + # Settings + ttk::style theme create forest-light -parent default -settings { + ttk::style configure . \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -troughcolor $colors(-bg) \ + -focuscolor $colors(-selectbg) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -insertwidth 1 \ + -insertcolor $colors(-fg) \ + -fieldbackground $colors(-selectbg) \ + -font {TkDefaultFont 10} \ + -borderwidth 1 \ + -relief flat + + ttk::style map . -foreground [list disabled $colors(-disabledfg)] + + tk_setPalette background [ttk::style lookup . -background] \ + foreground [ttk::style lookup . -foreground] \ + highlightColor [ttk::style lookup . -focuscolor] \ + selectBackground [ttk::style lookup . -selectbackground] \ + selectForeground [ttk::style lookup . -selectforeground] \ + activeBackground [ttk::style lookup . -selectbackground] \ + activeForeground [ttk::style lookup . -selectforeground] + + option add *font [ttk::style lookup . -font] + + + # Layouts + ttk::style layout TButton { + Button.button -children { + Button.padding -children { + Button.label -side left -expand true + } + } + } + + ttk::style layout Toolbutton { + Toolbutton.button -children { + Toolbutton.padding -children { + Toolbutton.label -side left -expand true + } + } + } + + ttk::style layout TMenubutton { + Menubutton.button -children { + Menubutton.padding -children { + Menubutton.indicator -side right + Menubutton.label -side right -expand true + } + } + } + + ttk::style layout TOptionMenu { + OptionMenu.button -children { + OptionMenu.padding -children { + OptionMenu.indicator -side right + OptionMenu.label -side right -expand true + } + } + } + + ttk::style layout Accent.TButton { + AccentButton.button -children { + AccentButton.padding -children { + AccentButton.label -side left -expand true + } + } + } + + ttk::style layout TCheckbutton { + Checkbutton.button -children { + Checkbutton.padding -children { + Checkbutton.indicator -side left + Checkbutton.label -side right -expand true + } + } + } + + ttk::style layout Switch { + Switch.button -children { + Switch.padding -children { + Switch.indicator -side left + Switch.label -side right -expand true + } + } + } + + ttk::style layout ToggleButton { + ToggleButton.button -children { + ToggleButton.padding -children { + ToggleButton.label -side left -expand true + } + } + } + + ttk::style layout TRadiobutton { + Radiobutton.button -children { + Radiobutton.padding -children { + Radiobutton.indicator -side left + Radiobutton.label -side right -expand true + } + } + } + + ttk::style layout Vertical.TScrollbar { + Vertical.Scrollbar.trough -sticky ns -children { + Vertical.Scrollbar.thumb -expand true + } + } + + ttk::style layout Horizontal.TScrollbar { + Horizontal.Scrollbar.trough -sticky ew -children { + Horizontal.Scrollbar.thumb -expand true + } + } + + ttk::style layout TCombobox { + Combobox.field -sticky nswe -children { + Combobox.padding -expand true -sticky nswe -children { + Combobox.textarea -sticky nswe + } + } + Combobox.button -side right -sticky ns -children { + Combobox.arrow -sticky nsew + } + } + + ttk::style layout TSpinbox { + Spinbox.field -sticky nsew -children { + Spinbox.padding -expand true -sticky nswe -children { + Spinbox.textarea -sticky nsew + } + + } + null -side right -sticky nsew -children { + Spinbox.uparrow -side right -sticky nsew -children { + Spinbox.symuparrow + } + Spinbox.downarrow -side left -sticky nsew -children { + Spinbox.symdownarrow + } + } + } + + ttk::style layout Horizontal.TSeparator { + Horizontal.separator -sticky nswe + } + + ttk::style layout Vertical.TSeparator { + Vertical.separator -sticky nswe + } + + ttk::style layout Card { + Card.field { + Card.padding -expand 1 + } + } + + ttk::style layout TLabelframe { + Labelframe.border { + Labelframe.padding -expand 1 -children { + Labelframe.label -side left + } + } + } + + ttk::style layout TNotebook { + Notebook.border -children { + TNotebook.Tab -expand 1 -side top + Notebook.client -sticky nsew + } + } + + ttk::style layout TNotebook.Tab { + Notebook.tab -children { + Notebook.padding -side top -children { + Notebook.label + } + } + } + + ttk::style layout Treeview.Item { + Treeitem.padding -sticky nswe -children { + Treeitem.indicator -side left -sticky {} + Treeitem.image -side left -sticky {} + Treeitem.text -side left -sticky {} + } + } + + + # Elements + + # Button + ttk::style configure TButton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Button.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-basic) \ + disabled $I(rect-basic) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Toolbutton + ttk::style configure Toolbutton -padding {8 4 8 4} -width -10 -anchor center + + ttk::style element create Toolbutton.button image \ + [list $I(empty) \ + {selected disabled} $I(empty) \ + disabled $I(empty) \ + selected $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-basic) \ + ] -border 4 -sticky nsew + + # Menubutton + ttk::style configure TMenubutton -padding {8 4 4 4} + + ttk::style element create Menubutton.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create Menubutton.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # OptionMenu + ttk::style configure TOptionMenu -padding {8 4 4 4} + + ttk::style element create OptionMenu.button image \ + [list $I(rect-basic) \ + disabled $I(rect-basic) \ + pressed $I(rect-basic) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + ttk::style element create OptionMenu.indicator image \ + [list $I(down) \ + active $I(down) \ + pressed $I(down) \ + disabled $I(down) \ + ] -width 15 -sticky e + + # AccentButton + ttk::style configure Accent.TButton -padding {8 4 8 4} -width -10 -anchor center -foreground #eeeeee + + ttk::style element create AccentButton.button image \ + [list $I(rect-accent) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-accent-hover) \ + selected $I(rect-accent) \ + pressed $I(rect-accent) \ + active $I(rect-accent-hover) \ + ] -border 4 -sticky nsew + + # Checkbutton + ttk::style configure TCheckbutton -padding 4 + + ttk::style element create Checkbutton.indicator image \ + [list $I(check-unsel-accent) \ + {alternate disabled} $I(check-tri-basic) \ + {selected disabled} $I(check-basic) \ + disabled $I(check-unsel-basic) \ + {pressed alternate} $I(check-tri-hover) \ + {active alternate} $I(check-tri-hover) \ + alternate $I(check-tri-accent) \ + {pressed selected} $I(check-hover) \ + {active selected} $I(check-hover) \ + selected $I(check-accent) \ + {pressed !selected} $I(check-unsel-pressed) \ + active $I(check-unsel-hover) \ + ] -width 26 -sticky w + + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-accent) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-accent) \ + active $I(off-hover) \ + ] -width 46 -sticky w + + # ToggleButton + ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center -foregound $colors(-fg) + + ttk::style map ToggleButton -foreground \ + [list {pressed selected} $colors(-fg) \ + {pressed !selected} #ffffff \ + selected #ffffff] + + ttk::style element create ToggleButton.button image \ + [list $I(rect-basic) \ + {selected disabled} $I(rect-accent-hover) \ + disabled $I(rect-basic) \ + {pressed selected} $I(rect-basic) \ + {active selected} $I(rect-accent-hover) \ + selected $I(rect-accent) \ + {pressed !selected} $I(rect-accent) \ + active $I(rect-hover) \ + ] -border 4 -sticky nsew + + # Radiobutton + ttk::style configure TRadiobutton -padding 4 + + ttk::style element create Radiobutton.indicator image \ + [list $I(radio-unsel-accent) \ + {alternate disabled} $I(radio-tri-basic) \ + {selected disabled} $I(radio-basic) \ + disabled $I(radio-unsel-basic) \ + {pressed alternate} $I(radio-tri-hover) \ + {active alternate} $I(radio-tri-hover) \ + alternate $I(radio-tri-accent) \ + {pressed selected} $I(radio-hover) \ + {active selected} $I(radio-hover) \ + selected $I(radio-accent) \ + {pressed !selected} $I(radio-unsel-pressed) \ + active $I(radio-unsel-hover) \ + ] -width 26 -sticky w + + # Scrollbar + ttk::style element create Horizontal.Scrollbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Scrollbar.thumb image \ + [list $I(hor-accent) \ + disabled $I(hor-basic) \ + pressed $I(hor-hover) \ + active $I(hor-hover) \ + ] -sticky ew + + ttk::style element create Vertical.Scrollbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Scrollbar.thumb image \ + [list $I(vert-accent) \ + disabled $I(vert-basic) \ + pressed $I(vert-hover) \ + active $I(vert-hover) \ + ] -sticky ns + + # Scale + ttk::style element create Horizontal.Scale.trough image $I(scale-hor) \ + -border 5 -padding 0 + + ttk::style element create Horizontal.Scale.slider image \ + [list $I(thumb-hor-accent) \ + disabled $I(thumb-hor-basic) \ + pressed $I(thumb-hor-hover) \ + active $I(thumb-hor-hover) \ + ] -sticky {} + + ttk::style element create Vertical.Scale.trough image $I(scale-vert) \ + -border 5 -padding 0 + + ttk::style element create Vertical.Scale.slider image \ + [list $I(thumb-vert-accent) \ + disabled $I(thumb-vert-basic) \ + pressed $I(thumb-vert-hover) \ + active $I(thumb-vert-hover) \ + ] -sticky {} + + # Progressbar + ttk::style element create Horizontal.Progressbar.trough image $I(hor-basic) \ + -sticky ew + + ttk::style element create Horizontal.Progressbar.pbar image $I(hor-accent) \ + -sticky ew + + ttk::style element create Vertical.Progressbar.trough image $I(vert-basic) \ + -sticky ns + + ttk::style element create Vertical.Progressbar.pbar image $I(vert-accent) \ + -sticky ns + + # Entry + ttk::style element create Entry.field image \ + [list $I(border-basic) \ + {focus hover} $I(border-accent) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8} -sticky nsew + + # Combobox + ttk::style map TCombobox -selectbackground [list \ + {!focus} $colors(-selectbg) \ + {readonly hover} $colors(-selectbg) \ + {readonly focus} $colors(-selectbg) \ + ] + + ttk::style map TCombobox -selectforeground [list \ + {!focus} $colors(-selectfg) \ + {readonly hover} $colors(-selectfg) \ + {readonly focus} $colors(-selectfg) \ + ] + + ttk::style element create Combobox.field image \ + [list $I(border-basic) \ + {readonly disabled} $I(rect-basic) \ + {readonly pressed} $I(rect-basic) \ + {readonly focus hover} $I(rect-hover) \ + {readonly focus} $I(rect-hover) \ + {readonly hover} $I(rect-hover) \ + {focus hover} $I(border-accent) \ + readonly $I(rect-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 28 8} + + ttk::style element create Combobox.button image \ + [list $I(combo-button-basic) \ + {!readonly focus} $I(combo-button-focus) \ + {readonly focus} $I(combo-button-hover) \ + {readonly hover} $I(combo-button-hover) + ] -border 5 -padding {2 6 6 6} + + ttk::style element create Combobox.arrow image $I(down) -width 15 -sticky e + + # Spinbox + ttk::style element create Spinbox.field image \ + [list $I(border-basic) \ + invalid $I(border-invalid) \ + disabled $I(border-basic) \ + focus $I(border-accent) \ + hover $I(border-hover) \ + ] -border 5 -padding {8 8 54 8} -sticky nsew + + ttk::style element create Spinbox.uparrow image $I(spin-button-up) -border 4 -sticky nsew + + ttk::style element create Spinbox.downarrow image \ + [list $I(spin-button-down-basic) \ + focus $I(spin-button-down-focus) \ + ] -border 4 -sticky nsew + + ttk::style element create Spinbox.symuparrow image $I(up) -width 15 -sticky {} + ttk::style element create Spinbox.symdownarrow image $I(down) -width 17 -sticky {} + + # Sizegrip + ttk::style element create Sizegrip.sizegrip image $I(sizegrip) \ + -sticky nsew + + # Separator + ttk::style element create Horizontal.separator image $I(separator) + + ttk::style element create Vertical.separator image $I(separator) + + # Card + ttk::style element create Card.field image $I(card) \ + -border 10 -padding 4 -sticky nsew + + # Labelframe + ttk::style element create Labelframe.border image $I(card) \ + -border 5 -padding 4 -sticky nsew + + # Notebook + ttk::style configure TNotebook -padding 2 + + ttk::style element create Notebook.border image $I(card) -border 5 + + ttk::style element create Notebook.client image $I(notebook) -border 5 + + ttk::style element create Notebook.tab image \ + [list $I(tab-basic) \ + selected $I(tab-accent) \ + active $I(tab-hover) \ + ] -border 5 -padding {14 4} + + # Treeview + ttk::style element create Treeview.field image $I(card) \ + -border 5 + + ttk::style element create Treeheading.cell image \ + [list $I(tree-basic) \ + pressed $I(tree-pressed) + ] -border 5 -padding 6 -sticky nsew + + ttk::style element create Treeitem.indicator image \ + [list $I(right) \ + user2 $I(empty) \ + {user1 focus} $I(down-focus) \ + focus $I(right-focus) \ + user1 $I(down) \ + ] -width 17 -sticky {} + + ttk::style configure Treeview -background $colors(-bg) + ttk::style configure Treeview.Item -padding {2 0 0 0} + + ttk::style map Treeview \ + -background [list selected $colors(-selectbg)] \ + -foreground [list selected $colors(-selectfg)] + + # Sashes + #ttk::style map TPanedwindow -background [list hover $colors(-bg)] + } +} diff --git a/src/ccma/assets/themes/forest/forest-light/border-accent-hover.png b/src/ccma/assets/themes/forest/forest-light/border-accent-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..72c3e600d27d2327828b76a70469e0448bc1b506 GIT binary patch literal 445 zcmV;u0Yd(XP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10ZK_k zK~y-6?bJI+f?*WK@xQmcd>R!BK@dny4OzJvMPkq>MQuSr4y}<5ZS5Le#&WR8%|K|? zT*_wM(;*wY4&G8M-Xeh@i`WQwsWj_7K|eF7lL&i`QTw7nD$P0rY7KkNP2AHamYcp;4(xq?6>TC6t{%bK9J zhq!#aCEvrO`D&SqhcIXH5C97kvnbMqtXL21|M|cU8BE;mo}&;#h)nSnf9#4xI!Ue7 n=m|1v4J=O1ak#R>=%ATT2_SQmApF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10bEH$ zK~y-6?bJIgLva+x@o!)Hu4l!-Kq7`t=pqIZBtjw{F_><>eV|zUZ`>Yky8Nd zCM&w3i7`)HN^o|4fkCIIuCfN3(NYkUlvC`64_G_iAt!S5xZ2RfrGyyXnEmyEEw?xb zE^MI@LWryTTULU8La`{y%X3kX&19jY$<6Y>G&QAFd;q}Dc44z~P*DH?002ovPDHLk FV1gMZ!~_5U literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/border-basic.png b/src/ccma/assets/themes/forest/forest-light/border-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..1094b6d01d4f932486e8adbcbf39ae930db48708 GIT binary patch literal 311 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|*=j7sn8b-nUl`yIKuI+#W7g zOiBzo;&kxKyL!u}wG%j2Hfgj>xNg5p;<%);i)4l3vmCK^8Z!L*EZospB!TUMIGRGZdlaEMz z=3svDd!B@H>A9cZ&di?_G^u!5olfo7CjB2x`Lf~xZYU;?zx%dMdu1Qja7`*(*Z1-VM)x<#Ea8#q;z0i}c)I$ztaD0e F0sskoe3k$J literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/border-hover.png b/src/ccma/assets/themes/forest/forest-light/border-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..a09fb3b7afe225fb5aae8f4ce39659bf7acdce95 GIT binary patch literal 324 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|-zC7sn8b-nUl`yIKuIS|27e zF-m)OMPvwWV)(vaaATN9&RVp|9|))X1#sr4q+KihY?`%y+!7 z;pv=(TkBdF9qxbk&}^I^d RCILOi;OXk;vd$@?2>?Q*dh`GQ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/border-invalid.png b/src/ccma/assets/themes/forest/forest-light/border-invalid.png new file mode 100644 index 0000000000000000000000000000000000000000..2d01fa59db00aff34656baa1841ba9c94db34c78 GIT binary patch literal 444 zcmV;t0YmpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10ZB0$~`(@$a2pq32l%iUut~w6sXI2sPPUL^THzZ9-5>Q@cffrV5K3g&>DE zn{BIhv%v=S@DJD}#&unTNYM~_Ee-z0hxh&PyzlTlf@zxO+jEXWJjTnzEgy|nx{2tN zQGy`?kuCa1MiH9%M>88;#?b0rK|f=2xfwgU#9oNUFtj>?)Q5j{gQ4&|lE>E(qE}UF z=@i9_eGIKm;d~EUWI*3Bb)7(D6W_`jHiz7KR}L9@ViG};(C#w4mrIE4Kl6kaEIk## zGq(tU(a;dvMZ3pq8T@`%{NJ=3=c|tL92!Jv*gV4<9%xrY&?*rC7$oTO!LTd#)Q8?R0yU$=W mwC*6M3uSti;q`4Kub)panMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt=C7T^vIqTHoH>*wtzv(E2dh z>4}({tBy$0```9`3EC=7s}#5tQ+}5wc54=O|D5+A-On<1?N+<<&5tc61UYH4>UwG} z+}c*mH~&2Q-P-%5x6RIc=E>Hb66DKVe6GV#simPb_Tt;$aqWo*OniM0*L}YCc$Z$s z6rnX+*H*_JD|GSVTr}lU&9(g+O9Q+bGh{!>^DTMgphbuf3i5g~C%SyklR1h@TP{Q? zAG@*Zx#}^FWS*8{K1G&__xm4BvP`ddUpP;FTB_ss-h7}8ka7R}560cEl68`6rbhvN O&fw|l=d#Wzp$Pyq9gai* literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-accent.png b/src/ccma/assets/themes/forest/forest-light/check-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..a789b5dc110684807ee5ab5cd4605160d24bbd0a GIT binary patch literal 526 zcmV+p0`dKcP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10h>ug zK~y-6tzIH;3|5?7&Mp;KBB1Wg5-5Qvf`L`^jEI<&4r_2D_>belGH5IZgj1IT2r zh$j=snP>Xc^-Z>95FnmRAQ^qL`%0xRkcwS)+8p$>bmYv-ioxpjT2_31WJ#v?!%%5p zo$h4C=SR2Lc=P#v&bgs0*Xd4HeS!bjUgYp$@QGH9EXhS-cY+%u^DMvph*EAgjNJ)- z+FGQm={ZZDX_RuaAq>FOIQB~19=cxiF+2E<{b-mf)s9_J3&J4JF9&~pLzZM_1}C`6 zUNf*T%6|0dK`bu_uo>FHQlW5?h!u?83j(bD*e)3Qmt-|7hL`eXtF$6@H@80;*w^|J zY24*OQ`C|%hec!3?ZqU75XWax0>Q8BhC`&%7lt6U(n5c0CzEb3Pwh4Q1!shc{Jsz? Q`~Uy|07*qoM6N<$f~2DBhX4Qo literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-basic.png b/src/ccma/assets/themes/forest/forest-light/check-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..198019ef1f95cf6b72aa3a883d8f90b1762ee04c GIT binary patch literal 390 zcmV;10eSw3P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10TW3? zK~y-6t<}4Z!Y~kp;ooD&q9A^OL_(3czW>WaLZZSQg2Ey|VVNqstKG09B&VFwd^+8z zlv29i?|APCArNC^NVL}Ec}`Ij%w{vx?RHCbT@Qx3;O%ycc<)DJ05L|q_ecm~G}b1B zfK1V@O^gxw^f=2hoO6HXAA#0dHk%EL#bP{Yt!2GlV~pW&IE({-V*CA`>-G8`%(4un z)JIq+xZCYmtyYM9uli!0;PH55KA*E(E)kJI7=ZabIG@j?l&Gre3+jt?f&iDx1*H^K zRgqGnu4|g6d5gUS+omGIXP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10ia1l zK~y-6tsvN-m7wrw1o`16nqI<7)cAkKR|*A#EB7?ZB)XAB0_`U zTiYP4a5kdQLJ*W(2vMsRY2l)BAT<@O!1r-xdR)0sN1bQJ-|`&Jhr>BODWzQ6TA}~_ z8$Lzn*^3>Z5=%Fz4OG%mA0*)2#`kk`Sw4K#&i3J+5_C3>-(sR=5bda@uxD{SniU2R zOT^jO`HL36oBjI zdNFczWh^DwH#5TB(h6Qa?#9T`l`u&0<;jIPlK+zoHMSE8hAA)|T*FQaO9=u@efxkW zGzJ^nI7-BMH1dqEzkghbT?n3RYUcX+^=~b0>{1Y5?DKTm&`E@)8_Gi$#r9i>+Cb&C zf%nU*5gqkG9KV${mT%g0HiYm=DP?qPm4Vr}d|dj%Zfsu(%F|8i%4_Is2yx5tI0h#= VjH>K?5-9)x002ovPDHLkV1hW=@A&`# literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-accent.png b/src/ccma/assets/themes/forest/forest-light/check-tri-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..27bcb369ab4841d0eb192076a11ed5bf49442ad3 GIT binary patch literal 358 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;^k7sn8b-sC_3fnfJ~IpdPt zxu*->oMUZUZ@=Go0@IG0mXd2ic_mNnnJhWQB~?P={hpdH_7z2phnK&P_b}p+{ZRPH zV9n{-Cl35^?q2`!*XC*w$zul&9Qg40ynfsC`S*+r{$CNx`0@8U|4}w00|SFSf4)p> z?7U)dn0?=$AIA>3m|kAcz&icN-g$E?Otq%&NEThbi`$)7hLg=mOKkVw<;xncd^a+; zR=>*g`00bRgiT?lOJ+5ey*kF@lX~wYPt6<~5oxs(M~?@*oqq1X50DqW$Jr;oxM$V> z@WtHEJn?lk$|n`%`1IJSONHCA_Ls16)(NsP#Cdo=h>5ma1oS?Gr>mdKI;Vst0F|_w AK>z>% literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-basic.png b/src/ccma/assets/themes/forest/forest-light/check-tri-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..a8970990f7632e0a00410db8650caf1b11589086 GIT binary patch literal 281 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|-3^7sn8b-nUaX@*Yv(ajiFS zGTFeuGr{h6zr%c1n?~+S5i!Lt56xY)V|VpZUHe+^X?uDMj}^)!q;+tFh?XlTEtwMF z<=7+W{l50i@4Dy`)wt`TeKX}xGdP4EJX4_bDWXhCsX*+dUx?_rGvAJ#m%6>JRwid{ z?6tGP|62De%(?gQ&AEMv`;7!=p6uYzTbiPF#`5sgpoEE+s{5m~#k$2O6uYezRb<)2 Y&?x6#7Tzopr0Fy*%CIA2c literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-tri-hover.png b/src/ccma/assets/themes/forest/forest-light/check-tri-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..f38f8b95481d38d598550e21dc7d70ef5bc6cd5e GIT binary patch literal 358 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;^k7sn8b-sC_3fuK5nof1zOF7gVbQG2fCEk5kID|G?U4$VuG!cXe7AKmTOSLvWaz8ff5Dd>URBSWJ$G_e z+sC2;V}nS|SHWi;qEnsOmIX~`VYB1Q>*5uD@F;afiGNxG*bBe&=NWx@6MLfI%{|vP wef#|rQv~>}#hd4L2|6#WHfm;>&%w+Pl;HVk>nR0Yp!XR(UHx3vIVCg!0G2S48vpP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10U$|4 zK~y-6?bJUn191Sx@uz3Fo>S7qB?b#g)5M=dqm7}7+6fkFm+%e@I=Gn^pa%9csHGEX zV$$#e?hGo?UjM{h`sccMOqQGca_fCxz$ef2L`c%IYd9@m;@NydZ#aNRs;nTJNa1LG zAG6vtU^YIO(eu1`yk^5`Q?!QDX0c*0eX>i-u5rYh8`n3MayVGqfhsFP>;4v|XXf!k z;^yTJSUBFI?RJEh`Lo>)7Yo~zdcy&mmtT}V`<;yxP|*=j7sn8b-nUl`yIKuI+#W7g zOiBzo;&kxKyL!u}wG%j2Hfgj>xNg5p;<%);i)4l3vmCK^8Z!L*EZospB!TUMIGRGZdlaEMz z=3svDd!B@H>A9cZ&di?_G^u!5olfo7CjB2x`Lf~xZYU;?zx%dMdu1Qja7`*(*Z1-VM)x<#Ea8#q;z0i}c)I$ztaD0e F0sskoe3k$J literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..1690b975ad02dd3699c6763b795f767341119e5a GIT binary patch literal 398 zcmV;90df9`P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10UJp~ zK~y-6?bN+W!vFxp@oOAnOcW|D2!arF5}{fLYsG>nI5>!|RuJ?R9Ne61U%uhzvQt zhw)JvLSD_FW@wSYnxSERb_p>(aSVmIEY7x%F*T9G532&xjkP)rjQ{`u07*qoM6N<$g4TJb(EtDd literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-light/check-unsel-pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..2f4a5a8d9d0a688470b660987a082452abc440fa GIT binary patch literal 335 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|+Pv7sn8b-sC_3fuK5nof1zOF7gVbQG20E4UtD;gKG38gBSTdQB?dHnQ2TEeEFE6Zm!ZadY+mmtT}V`<;yxP*IDgi(`mI@7t?}T+E6Btq<*I zWr=PH+Vj73Pt(!`TN9Rh3Nk4^&DwZ$Ur|>-JF{`W#N+PCf=&yj9I|ZR`pww7oAHcg z=GuutO3!%?6tn*E)nr}!HR|yHD^cH@_I}^{Kq6V6qdR)vLFqk88cXkR%N^e+{^j0# pW@iqGmmtT}V`<;yxP|-wB7sn8b-nUmbavnAiaJVS1 zIW@&1+%6<-=9>wYo1CYe^wHxD$}Lb=cjcH-;ugN-gZ}yo85xh|r~m$9s<bP0l+XkK D(1Kr9 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/combo-button-hover.png b/src/ccma/assets/themes/forest/forest-light/combo-button-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..297c88b25bf49c087b96be92d786e5279971b805 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*J0&i(`mI@7t?}T+E6B4j1S9 zoZ5IHXzzc|J%_I~-QeD~X(f~5Q!Cf5zw8w}JZEgYG}Xn01w*DvC@Zos%nLVc*eaIH z)AxA6HLe5M?0?p6Z7a^pKXkw9{k8{lE{nxv-)!2j^QrWngY_(~N5n!xqYaj(us@sg n@!osc8|S$_G#~C|f53a@l8i>v``7bbP0l+XkKp3Yj{ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/down-focus.png b/src/ccma/assets/themes/forest/forest-light/down-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..70921a3c81ac9ce36d3ec09f2a569c11ab010890 GIT binary patch literal 200 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!p8<4C?sm%aVoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-XRE{-7_GfU5H8mo6*!F;I3nz=wIen r8x~fq5B8BTOmO@5UHxE@Wo4PVkZ-th{UXgepal$`u6{1-oD!ManMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&KtDWp2Mz_7`Z-;6%Y2(zHirILq1sXVZSQn_^J6xrHr15H zeosrfc5CUiUq;@VZ(WycyS7eEMO`WV!HnY)`=eg7t>|9Fq1YncV3@``XG+lRZMn&2 z*Us&1?YPsRZNSjJ?|u2b-~0~W>O(`X#%PwZduZnF`F{9BUFr=BC;e@^{sNuI;OXk; Jvd$@?2>@cuWEKDb literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/empty.png b/src/ccma/assets/themes/forest/forest-light/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..202e3de5cc0f3b6284905c2e0cce05ccf2e265b0 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k&H|6fVjyh;!i=xBu@q4;BhGFVdQ&MBb@04Hc4rvLx| literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/hor-accent.png b/src/ccma/assets/themes/forest/forest-light/hor-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..e92bc7d133db904ab6385e3ad4a3eeee7c69e5d6 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0xOo-U3d8WWTM{Qv*Io=uBM vElcXy0U%fr@y2XNtVNc>(}uze%bgjnN^m|7gTe~DWM4fgK04A literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/hor-basic.png b/src/ccma/assets/themes/forest/forest-light/hor-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..eb18d1e4d205ab465662debda3f4778df196bf23 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0voo-U3d8WWTM{Qv*Io=uBM yElcXy0U#*)_9k+blt-y}pOlAXg3Onf0t~LMTqT)@B3*#G7(8A5T-G@yGywnwA2A~U literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/hor-hover.png b/src/ccma/assets/themes/forest/forest-light/hor-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..92bc070d5566aa0fdd54587c844beb5473b921f3 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W=!3HF^gw{O+Qk(@Ik;M!Qe1}1p@p%4<6riAF ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0xOo-U3d8WWTM{Qv*Io=uBM vElcXy0U%fr^xk+!tVNc>(}uze%bgjxlsJF9n6DfO)WYED>gTe~DWM4fgds63 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/notebook.png b/src/ccma/assets/themes/forest/forest-light/notebook.png new file mode 100644 index 0000000000000000000000000000000000000000..5be429492fd93df907eb1b587abaf21019ecf43b GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU1|)m_?Z^dEoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&KtpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10*XmQ zK~zYI&6dwglW`o!U(eH=C6tjX=agx}Fxd)rbL-NgEpq5BLr>MT>|TNg3o6L!FHkq> zq>yw-oTLLaNVk)R+%z1Dn#>9Fpa-k{a2-VLnWEG7Sg+gj{(hd%_r>$`BP5f_Bq_ua zaRw$v@dUgC7K4OWBdkY%rk4E`Im(l$b5-bzbl41LYz8w~CltHtgjB&#U%$}mcCxUt zlu@CB(B>7;T-Cr$!_~b76B`@6b~|x?{&1{fsXVxLn>E$Gz608EGH=p7%X%jYHzB(70IS}FXfv46<`ry* z(Rd7pt7Tuw$pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10kBC# zK~zYI&6dHgqA(OdPn(ej+%yCtiR@(O|9^@*+(9BC!LEw1ptB(QgfWiK(RrQ~y}7wL zX>aKj&N;^n=N!fuj4@bi>HD5x80KmJgp?A`^9X_ftu#x~_Yv z(BDu>k)|nH>)V3PIm)u6s;d7gHfOb3k*4WoEzcjHAO}!Y6=hjo*1upp*14RNk|>G@ z!w}#10chKnrfI0_nj5YAG7g6W%jI(H%V8MYZnvBCoacFDS#}Yd)3z;np40dJ+a#4z zY`0saF=iqsrMxQ#;QKyVmfgH{ucGTZj4`9&HBl7Zl{@46K5-ns%MV}_wAN07VHiGK z;5)g`V68>Y`{rqfCbqPA>$n*TJq_vhj&)?dEN~!TB zan3no4Ez0lk|2a2j^iJB>Hs#I&6glRQ4}9BEC3`)!g{?1uBMqZO-YjE$E40&%fXF- l)fhuj6rVS+Uh_#K|8EO<@sy`G&7%MS002ovPDHLkV1hpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10+2~W zK~zYI&6Yh(6LA=apPr?l2^gtJ+eAJh!WqHDK++OO7`SQ|7?h!EqG+tfuo(%1i6#st zL`S0oD7p|F#E65~cF+MR1B<9_l@E~w4*4*tiJE>oln<{oP_RXx?SB9F-g|O)|GQTe zi9{mA5C{day9aT4UA&(eL-sB3)Bh{B?626V$v~H-<*@z$X0Z~pXd-QgdQF`wR`At_ z0ooj$OnsA+Dzp)K*?L;c=Q(z$GQMCa9Okz35f28QZL3%;7pqRtEHxsiQ~(Ie3;%;0 zKu3QMcbtz`j@1x-tg&+GY7N#33-x6+9L&iB;N#axdPm;SJ=}*s7)Y69d;e38?iVqO zCRF}lAaeZf6~#NJ%i2r(nVX2Y1#ugAv`F955evpRLG^PHS5X& zh`K`BPv1bJPD`2K zIK4x> zn;u2>&GRG4BmXW<2KwDOlpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10)I(F zK~zYI&6dwglW`o!U(eH=C6tjX=agx}Fxd)rbL-NgEpq5BLr>MT>|TNg3o6L!FHkq> zq>yw-oTLLaNVk)R+%z1Dn#>9Fpa-k{a2>?iGeynzn6KOS{e8pp;`{vi2+3qJNgA<4 zoPmi^JOM9(#USC;29v0%M|l!;t_ppT4x7P@&0r?$gknceNEiI{^$V?TCkrb} z85PLEY)orevh%Z9Gk(65RJ!@);IOC zK6C0^6`l1jxm0j**Zrx`EDl!-;nhegD^_V3eA0n9Ffl5tT(&~FryM}JrWBXs1xiI$ zDw~gl@c73NJpr$5asYKE`<`-Jlxs?5*0Ppfp~;LsR8_;E!ph$?o8xrRKj$!RE;|T55QF>)v*jKClk@ z$9UD(!F(iiq;e&yLYk`@u;3=InV4 kMLMi{6IQ(m#V#ZNZ}$iAezXM8mH+?%07*qoM6N<$f={7iG5`Po literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/on-basic.png b/src/ccma/assets/themes/forest/forest-light/on-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..fd0f6c07bdd65f5b5ce6222e73f8bf9c9bf8c21c GIT binary patch literal 538 zcmV+#0_FXQP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10jEhs zK~zYI&6dH6qd*WqpUuWbb7@405O498|NoTS%uOL8m_D`9gS`hyMxB{#n2o!>lT{S> z=q{~tthJUI)>^dIXst2E&~+Vs-_Ohb3Lylp>*D)9N-305IF9qF&Y25duUCqqpl#c? z3jGNwB}tN?l=@oGT1!zBlx6u(#pdjGJCY=M#Nz(2204JTEGdfO5&weWT9=j+LJ)=_ zK@i}19so_#P}en8RWa_g%jLp$yB+$n?|XYXofhpm*L6wL^dUBfwrH&f!6ga9@TuG#&+~|)Xp+-fBaAUixd9La!H;W= zF^KWKFTpFP_g!t*bsxW(H83A)j^iL)*Io3bP1C$wd%N8x@n*9@_`d&crwMgk|G3uo zeZ+kBy_>44UOGi%3{_Q4a!RQ|P)fO!AJ$s3EPEpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10*OgP zK~zYI&6ZC{lW`n}pZ9G}3p8?>+aUjt=^JSf3Y#c+@Llbqhw@+rWzmZ87Kw+Tz(XK) zj5=sp7qLS|cF49J>Y&bpm&~@6e=U;H&q8 zv^%<(`Q}Th&}QUi8)&th=h&gj^##L`2)CV&crf^EOU0C2tU5u9+=M6y0EjCa+d&SX zbD)htuOibb4K?$pJK0ST~egLDCn}SZ+u%dn((RkX*4;)h9ExWJIwNw4DK;$F-9t1#xuu#UfL4=6W4V>OvYPm5 z_0E6d#qzIit(<_s{ZrSeT@eImcXZJ?(5tFsvRtli;F^36SToJE$W7R)PX0G3h~2qM z)eT&&J-eDI2Uds2MZ5hW(@S$(Dp#O0(rUSYESVDWKLi0n;Sdgw3#WIOceCU8{0sa@ u@yNeRn}K0>4yA@7WXXhFVnVad$p0I;_VACb?{zBx0000pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10xwBK zK~y-6rIk%j6Hyd~pP5ccGzugIu?BEq3X8TY+yH7yOd+9y8{`L=w$v>#q7o7}M1-(5 z4aFZ|bVD2>bb%6ER)%2Pg)|{5jWqIMz@|27%ea76(z&SAcX!Ww&XarZnK@U|G)<#K z*?f+n(KJ+UT^kVKgYErAVc~*B{<7rUv-3y`f7!sz#HU z2RA%)+1tgy{Qf>q)Bu6EFaHQCmB-P?kjsrMNdU+NqZ}B()SDMf%swlwWjHuyIk1BK z3(uL4h7W+*e2y_SP&TZf(HTG4d=6zU5<0d4=IUBTdVO4NZUG<^UuImLW^FT}%U$0| z5{!m1)LvfV4MlPLe;AYEpW|kL7WzxDDqhdskB;vx|jr97c zK7CdfuR3*x;U2HBCbGVY{O!k%&@3ENq-)KWh4JLx_V3T;B+}6RTmZ#wBu5Bjhp7b%hvPdeE zCY4E}t}No3yi*(&rE*$Zk+Qk0=A7{GdF!h%>0i}oG84QPKvoP2_g(J)f&mQo4q!AW z2VL&6x8rg2l@0TC^y9R)0!OCJkjstNaqGmefsTIecHb_pbpv)j8s_n@P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10dq-2 zK~y-6t(C#5>M#(7|J0g5K*_EJ=LLNKcd0v93D{Kzi=~Z*+=aAPYdmoDe5+4>FvdU#u`GpH3cdGm&S4zKm(7nTr9eb5#^i-5 zB{=8Mw(XCiB~?{{h~{-MJ$xbqfVOSnoO>2}?>~}FoK7b&^IbR{^;@j9NGYLY=C=)y zQi6!E-EIK@UDv@mhhZ3A_KxESX1)vG$@cp_%Ceke#u#ii8#GNbkJ2N|jN)>+WQ9cZ zB%4^RR!?1hr0;ta*XuQ#oR0aMTnOR+*!%@T2ozGvY_jXRw}t2PIU8TE*C@2s*`#w0 zF~*np?RJB;Hd~{$MzQ=BCx&4_(=_njBgTjrBfR%G91htI1NSK?jWJM4WoI#jfaCF) zEncFOf-we#5aR6^{01VrH)VR&s;WMg)pb2zWAoS$(MQs{uCd$g=Jm@3%gnIWet*F} c^35;cA4dGoTKq!(YybcN07*qoM6N<$f}--+asU7T literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/radio-hover.png b/src/ccma/assets/themes/forest/forest-light/radio-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9f2bd6a581edca37b62a46a6706e62641cf9ec GIT binary patch literal 679 zcmV;Y0$BZtP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10yIfP zK~y-6rIpW1lW`o!UwigEf{>1M*f#oc*rE;dP#1L?MD3Yf46M$fxJ5*D3H#-xQ(<-r zA|b8y2ZRTqn0qdf&2py=iND7t*0|6`Dzz%#zCMhlv^Yk!rQ z8)`dlhf@qpZ*J17`4|We{t=WfkF$*~wF60~13=0erN99CgM&QKp6A9AY%Q}CSXNKo zz90||Z2^<%6!$e>$*`>M`uj=UqkLIe5H^_&M!H+PG+U1V5R8n`tNDn;<_dbh ztuCU4L+EQxIPQE}niax^EJfYvLP;f{Y?7NaG9B!r67V zcrlb$6x0pnySU}<6lOlme@0qbUlE$wWr}pnc2rnjTwDHqZFVBT#|40-laTgQR13{u zWUMIg&CGjYy{4iHsn%i@n!TEjA3x&-_3w#w?)e9VnL5isB-P$1G$XM&TKlf>YGQ== zMuPZ8f|nB`wDw&kx)u{=T=sf&$y73P>d_@;i`>P3)xO;({Ffgi$$FXVYWsh|0B*N< zQ1tTFDZ3p`JkGX~VHcXu;j-5Q+g_V4wF6JnnH|HtO=r1w@_cSw@W2MbA$o?N{&i0e zRGGQy?##`sC=ei(P7w%)(5BuoK0lp1Ra2#r!xkH=y#d#udSt;b;1{;J&uDtbS(E?( N002ovPDHLkV1o7!IH>>t literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png b/src/ccma/assets/themes/forest/forest-light/radio-tri-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..5a5b54c35b0398180431356d0d46df5a1b1e0523 GIT binary patch literal 549 zcmV+=0^0qFP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10kTO% zK~y-6t(D788c`U4zd4L+n@CL|UKU0-GduveK_iez?Mi(B1Ly;ocW+oZz244blOTCozZQJ2g(U2v%x#vAhO{_~&z%Q-ohU$QLA!ba6J*ee{M74sC! zB`Q~E0KDmTqx20ERs)3909`KGUW#NFe);j8WG2m@^W&DucNysMu<|xe#5d|JG;eSD zm`RiVw)3FKPCPixQZ$B;BmhJs(T)rt^>v%|&zsFyKy91t$Qo?yZ;{n=HDT2>NM_P) z%Nl(6v_aK05POA#M-SjB99~q=^c+H~FSQeB#XO==E_F;;IQ)rxa&zIBT%Ahbef8Qg zcXWBe+HR_4ymEd1|JvAbctDRLBnfFeIcNPkEkC>1iJ{(pgxWXU5gCB5e*_U$1055F z6h9)O1{idEUo0Ew@i3wI5wc5WIU0Mh@cp}ax?FPYlq0@T;=$>*Wea1ogcLt;=eJpk z#z+Kbo-Lann`Lowt{Jy{uvtCF+HQ*9XMZ0Q4Z6LoP9>TvyH^NcmpF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10TW3? zK~y-6t(DELgdhw?znT#eR^4f0-~Y)V(Usk3{8{uajAlm7%#C_hK;fhWC}d`a9cBhq zg{q?OdrZ>=0C3JBgaBp+GlPisOF?^K+qOt4VI0T90@?kEP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10kcU& zK~y-6t(8qr6G0e&pPepO3_yY`Q z+PzYivj}a#80Dh{EyQ~4F6*JTlHH=R?Q@%X=Y8@{X67BmaU2I1726`cyNQuE`B?Zw zv0MTm)ZWQRxF4yFBDEN80o9#~;ufy&WJzV-a&TVsEWS)nFw8SKg+3f_EVM5!STt67 zlU@6($W1an!MvPCC<*|g7VskjSkA8T%6L~F3#e|>kF3V)jrU}7W>r|REnXU{zGXFD ztS?isEkt^EldormriF*+Cm1;sVf-$&6&U#~#CBo7Wx@}~d#I;B&l(07^cf;IZZ?b^ z{rE;Iz2ccaDVP3S8#kWk^B)LBpva{s&gasRk9046UAGV;k^Yv*0PaKv5mFm%nJ}S6 z5xN$mHyCbO*4+_ed?<=g11eADRMW!e_mZ>))aohg!*P=F3E#5EcOR0_qQIqYGcTt} zj@`eu?9tc*=Ef)MV^4$4I;{X5v07*qoM6N<$f`St86aWAK literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..fdfe1f0bf045123e624c5751f3caa1997e12053b GIT binary patch literal 676 zcmV;V0$crwP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10x?NM zK~y-6rPaSr8gUo~@Q2f`(B?2e{gG(Y<&L1NfW{R8OKPF?uXoenC0 zg+MAWQ3Xm(6X0MGu!B_5Dj-x7;o$86m;g;E@QnA(eRBC;-shDN5uq)WstAt|fG-h3 zB9lb+@*Du5l*z%3&A`BGVc@m6Gc?@(DkSaTPHYc0w*wQb3eV?scsQkoQYHrgoD_az z|7RTTh#yC}EZ)C(gJt!TR*@PJt5q^@Z_uJ`oxZNdfjU?gEA-6rnu5tl z&sa0zbc}T76)KArVmv|s6RZlF>A8+S_P=>FrN%g`f-fG#(8$x-k4xNRaHG?Gg8*hiuFTe~eHBuu2BawhnNLgfy7XyRY z%X28CEEs-l2K$G?z~J{o0qXHtFa}-=SHwRs=#GAcp3{I?=C#P=PZ3H*`o_ZP7=GnX zvBYWW<**amBb|ALa(@bakDfOktdCyvz~bB_quoULt71=>(Qcwo?d3p3RISy>>fE9Q z>l%ep(XLo98KHUWD%o6)mip=_SZ_QCt7{9R_Z0Plo#b)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10fk9K zK~y-6t<_D7t1uXb;ahDZh=u5OLM^oa{}?*Pz&Qti5P~F0kWwP0BnX1%r3BBQF^0OXiQ||o%Sh7{Ap`)S(ZI&?2kUwT4SxHD2iWy#kIb&EPFJ~3kYDX uMQe=^f;`W^E!f-bhI8)cg8iB;PrxVEG4245`Ha#40000pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10xwBK zK~y-6rPWVw(r_Hd@eg$#hi>R#7+K6Tt1EgEVP={|D9Q8*;LT)VdkE|q3_K0)fKNgq zg&R%^oHpoyI0suMjQtCg$YBG%9{6JzCOCK>e@$P9w%>0RL`1mORih3e9Yr`RLMr~m zv3w2y&?r^lrf0#k3*cE3Zr>Waek&B$!Qk$9xI%vDCN+4+y@o-@L8DXw0E+S%5(g3j z(l;EHOK|WXVSW0lSEP3JMuRqjUgAA#6wfDVP_bN+ES^)-;N+tOmQ{$6hIPEMF zhtkMk;y}VP?GrFOyMQmL$jD$oion7e!B|-new9mz=5~h0;`tPg$|o?-8C%OCxVuG5 zn=5qjyE63Xh09T!O!6ef2HaP)3AH@ZrJTDJ*k~Fz7f;-?>s~?KR$YqCPl&W)c^nh07*qo IM6N<$f^R7ylK=n! literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png b/src/ccma/assets/themes/forest/forest-light/radio-unsel-pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..7428dc9863b085b7516acaabbc9f47f314df02aa GIT binary patch literal 512 zcmV+b0{{JqP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10gXvS zK~y-6t<}pe15q5u@$a0DC5aN+CRiY*!Mas;I<jdewdtTAR?!S zTs@_$25+A4(2_brd!MQm(Nb|lB7JTdcyxP)xPN)F47|%eejFPsA|!!mu5GXkY^t>* zx?Sy-fxWIygsSve2Kp5bBA|F_vpcKCTIwAPc6ku8O=eD=s2W@x3Q}W}3%eX}`w04m zE5>F9#_%g1;L~dpQYQ)akNg{(?jL1#aJ*E1d0?YSo#n_Tmw(*vHoKFBz*K2wKY{>; zX%J27XsI}f+jBD62LK#(jr6!WP!%u!E)ViIU%(q<9FH0t^lT0Q0000mmtT}V`<;yxP|+Pv7sn8b-sC_3fuK5nof1zOF7gVbQG20E4UtD;gKG38gBSTdQB?dHnQ2TEeEFE6Zm!ZadY+mmtT}V`<;yxP|+Pv7sn8b-sC_3fnfJ~IpdPt zxu*->oMUZUZ@=Go0@IG0mXd2ic_mNnnJhWQB~?P={hpdH_7z2phnK&P_b}p+{ZRPH zV9n{-Cl35^?q2`!*XC*w$zul&9Qg40ynfsC`S*+r{$CNx`0@8U|4}w00|SFSf4)p> z?7U)dn0?=$AIA>3m|k9xaOFTCvpcWKDTll=zg3dQP9HpbAZ2Zqhbi;hE4`94KsNud zsMIj?nb6(8@Rt7E#*ZMkzTaDS=)&Goxx*K&pGyMGa+~C6BNM~(?UfqO)w-8FLVuKa Z7=B)IW&8Zw@dePI44$rjF6*2UngG1Ol+XYG literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/rect-basic.png b/src/ccma/assets/themes/forest/forest-light/rect-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..4fbd3c56bb0a2194d612b64a888d07f77d63ca6b GIT binary patch literal 254 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*Jz1i(`mI@7t>zd51;g8w2HSsD$7M`@+{Xj?f6L-a|oNb23UhG}GYKG*ybCX?KrU)$+N$Sa2 zyR|U(dh!eLntAGuq4CKK@A_5=oq1zgmY&uk`TE_Rxdy(!w;f>B`=B4-vMf=_$wSkZ xx%ga55SMY-yWf6mr{*4GeKKd|)*=~p`C?}$?!@1x&jFpq;OXk;vd$@?2>^z1U%LPR literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/rect-hover.png b/src/ccma/assets/themes/forest/forest-light/rect-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..2fc43f64c567dc93f639e87626fb83551ead0f4f GIT binary patch literal 272 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP|;jZ7sn8b-nUmb@*Y+YalM%R zZ@Ox^0b>&5d;50v2Hpi5r3Eq{skw?ApR$j6&Lh+OS9`xdw&*Yn@>;m{*i;vmY~6d+ z(^3;}eczjZ+imSvp)WI6ZCa6PZ!4!j+wRR4EY=aL|8A% z_cpa16ABb9=(^040gS#pja_wVHKHO25(xn-ltznY+)N i-`RKHm7{Np|1ri&`2RUpYWNmtEQ6=3pUXO@geCyG-anTB literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/right.png b/src/ccma/assets/themes/forest/forest-light/right.png new file mode 100644 index 0000000000000000000000000000000000000000..cba43280811447b76b2831c001a1b16b9a54425c GIT binary patch literal 284 zcmeAS@N?(olHy`uVBq!ia0vp^tU%1g!3HF2ET&$Ca zJ@Pis@3?7}dQD0(@i=&}4~(88%$=W6b%zFMdkuPo?BDEggNa*L6SJtY>r(y0|a*c4(l; c*A2fIR%%;IXZ_E70dzEjr>mdKI;Vst00sDI761SM literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/scale-hor.png b/src/ccma/assets/themes/forest/forest-light/scale-hor.png new file mode 100644 index 0000000000000000000000000000000000000000..86e2fcbe89915019b19404c84d1a987cc0df7e77 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?4Uei(`mIZ}Ol2|NqysX)&o~ zNttCmP%xeF^v%u9A*KZ%sw-KfPFO@)^hkMFF)$=JaCLMC?rH?;WbkzLb6Mw<&;$Sw Ct1x2# literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/scale-vert.png b/src/ccma/assets/themes/forest/forest-light/scale-vert.png new file mode 100644 index 0000000000000000000000000000000000000000..05312420b105ce17a2b690920e60648deffd0ec4 GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?5f;i(`mIZ}Ol2|Nqys$vk*- zb92Pe-rnBA%*2F4n$BtxrUh3H1Twqx%9s|ckllHuk|nMY zCBgY=CFO}lsSM@i<$9TU*~Q6;1*v-ZMd`EO*+>Bu@p`&AhH%VGwkUpfrk;VrfH`H^ S)2;kK2?kGBKbLh*2~7ahDj=}{ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/sizegrip.png b/src/ccma/assets/themes/forest/forest-light/sizegrip.png new file mode 100644 index 0000000000000000000000000000000000000000..8f0bddf3f9aca55e2d7f82e4ce939757fa26dd6e GIT binary patch literal 471 zcmV;|0Vw{7P)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10c1%; zK~yM_jgn1^@-P&IpY$d*T~vaLE`(ymh5!Gz=*EQz4s_F60$udWw(nw?zQ{P>-JJtZ z&dE*GJkK*9PEi!(d5(yXWf|&IxbJ&Tr_-XXwFIB0j^p^EpU>y#a1=#sHX8txQf#-| z&ujlo+qQ@Z#+bj8z4v^5ec`=dysqmg%aX_A!8A?plfCy`uh&H%4hPojHRto0VHf~# z&Y|C>)^+_$hheyYKi=EgaUB2B)>@1)FW#RE^WIa}HE|r{oO`*mh>#@7H{AC<<2VvU z(E?A?MBBFCpCR~PuxT1ZgfI-zMNx3S-vQWcHthF%x~}7LxiAa^#u$#rBdgVl+wDeG zRVbxM)0803^X1g4s%YDmq9~T-48uTKmNZR6RaF4Y^Gu%S1R}D)N-0DHtzREOL=X{_ zQj4dxCdjf3Yb{!9(lo^wLy{yo=LmuT=Nw6r5QZUXnxeJFTFY*?;~#xqs!OlVh@t=h N002ovPDHLkV1jMZ*1P}! literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png b/src/ccma/assets/themes/forest/forest-light/spin-button-down-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..81535817395249ee1255a9c6524c47cff500f2df GIT binary patch literal 156 zcmeAS@N?(olHy`uVBq!ia0vp^Vn8gy!3HEdYt9+~Db50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWD=vPZ!4!i{50L;%8^-*|eJF w?duE-0&fRMNSIb!c@Ws_&MOCGBz$6E2o7W`e*4E`El?AKr>mdKI;Vst024$l{r~^~ literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png b/src/ccma/assets/themes/forest/forest-light/spin-button-down-focus.png new file mode 100644 index 0000000000000000000000000000000000000000..544545c96a17ae298a561c9aa79a8801c774990c GIT binary patch literal 163 zcmeAS@N?(olHy`uVBq!ia0vp^Vn8gy!3HEdYt9+~Db50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWD<)PZ!4!i{9iAowDEO4;(nK z!D>@)W7LD={qj6MA;!GCJyJGVA66V(%+?QN7*sMaxHGa9PZT=u3)IWt>FVdQ&MBb@ E0MJ7*EdT%j literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/spin-button-up.png b/src/ccma/assets/themes/forest/forest-light/spin-button-up.png new file mode 100644 index 0000000000000000000000000000000000000000..e04e75744321096d114f45a482baa618d69a31da GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP*IMji(`mI@7psQd7Tw`91ea@ zZprw-RPjGkqJj0r#EgtJOa9twh?*a;-Fi)5+NEWV@luf>ubMI=hVt|t$L!v%*Xn-s zRlk4k)L{Ai=7qcG+Ya#NR~-J|u2f_ea-gV9*|jhj-_s-(0d5|;?5 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/tab-accent.png b/src/ccma/assets/themes/forest/forest-light/tab-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..37b37c4fb950340fd8e726289dfce6738f8d4c82 GIT binary patch literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3oCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-;eE{-7{oymXx|Nmd##>n8q zocMCZ(Zyo@Q8rm0kr-bXb#Aas^}4e%>*U^fb0w0L^;fW;Nji3M-jNO4B^VU?xJ{ip S?kE8bWbkzLb6Mw<&;$T(gg>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt(Q|E{-7{oo}yir`TQa0>~-9x UE@G-Df#xxIy85}Sb4q9e0CanMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt-;eE{-7{oymXx|Nmd##>n8q zocMCZ(Zyo@Q8rm0kr-bXb#AbPO4qE+I{CKktVEKs{tEUpNyjeEJF;QB1cSO9x2gM` Sdv!nq89ZJ6T-G@yGywou06%;H literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png b/src/ccma/assets/themes/forest/forest-light/thumb-hor-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..5179d9d4bd8666a6d9cf5eba1aa54809e953c73a GIT binary patch literal 316 zcmeAS@N?(olHy`uVBq!ia0vp^96&6>!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIa0o-U3d8WWTM{0D;F>*b6~ za_62dcyo@G4Fs}7o!gAwr?9C8O0pfZ)|dawKQHFMhnK&_*-{Q1KY4hAUcM6ZXMWv% zZLiP2GhEO-zrN0J&z~>T8hK(70L@3lJ5 x-RS3rGJA{XH3GrGxVtS2Z|ToIDTWSIHIo-KiqMIPuW22WQ%mvv4FO#mgNeue-5 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png b/src/ccma/assets/themes/forest/forest-light/thumb-hor-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..ba727d75e4b977309fb7e1d97f668683adda2647 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^96&6>!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIZyPZ!4!jfrn3ZRB-U=-Us;}~TFWVrYY?!3HEZNY`WoDb50q$YKTtzQZ8Qcszea3Q$n8 z#5JNMI6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#WDWIa0o-U3d8WWTM{0D;S{CNf? z?;{Ts{Q0NK1_IiywQWJuS=d|!Gue*qUN8Tje{S%h4=<00v!xt3e(>xDwWZS=e@>qm zaQLhEcgYI}&)e^pjH|0Ve&EmTh$Rod-?x|Gk^lmr(CGtTzCJHsGl>TX;_K>?6Ao!| zvo7XZv8-YCT;b&^`t0V+lcz4S^JCq1s*T5IsU9a!jm#a1NlY<&c&5HP$W3H@Uf;224&0q31oRYxr>mdKI;Vst0K>?D AhyVZp literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..f793ad9fa33abb8aab1db753fce4774d7fe3c55e GIT binary patch literal 311 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6cc@pN$v(U_S0=RXkaUN2`{ zk~{Zw!JBieZR_p#8&6=`ann+AO(?JAsXdb=r?{j_NW9-u^TocRi1G09_wgP^9I_t@ zKN+k!J^RFgKhE9jAO6~0Eh2gBz<~oFKA+cbn?C=Zk-`5fVi`aFe&;{RW@KPsu;|Z3eop$2PmiTN**+E&7~WVt+aTokQe%$~-3AN{o`>w-O@4jB1Lz+HPgg&ebxsLQ E0RE|luK)l5 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..a58440d271a460cd9d5d740e9711fde093280797 GIT binary patch literal 234 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6d*sA`k0D z{v-*Zm5hN0zQ*(Nu3%!FkWrA7?w6OqU2$>Q-tTeQ2hTis*TJ!;v*W;-fY2@1COn%{ z-SbiE7rXuW=A5+S5)3XZXLxSj?cW&xuCK=T_q3TmrPP#8tm0C#I_9PMVz2Vu8*VNa ds@>O$%JVB*e|}lcG#Tgw22WQ%mvv4FO#qdRR<-~D literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png b/src/ccma/assets/themes/forest/forest-light/thumb-vert-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..faa6f6b69a3e9e85bb7955098f24e8e67c6a4cfa GIT binary patch literal 310 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W+!3HFM&b|K@NO2Z;L>4nJ@ErzW#^d=bQh9gP2NC6cc_H=O!(U_S0=RXis=g%`J zc^`S8;LktRw)OV=jVCbexM?Z5M&zrcSFDhPi0cwbiTC^J{@9oJaUNbSAMa_zA^V}Q zz<7=4%hZH_sVY|r{vNIE;z>?ONci#dx%uJK=if`rsF&_G`Sb60dlIvxgoH#~UES#e zE~1kcG)$a0vGJr=;QTDMTUyda22*#b7y!)*&Y8g=uJ63G+KBlv$VvC>KTlbh{@$rA z`MaJ`&Cj2`D$Nx)c1pYn>5P5%QCyyX=ggG1HHrG>FVdQ&MBb@0KO7{ AFaQ7m literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/tree-basic.png b/src/ccma/assets/themes/forest/forest-light/tree-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..98c26e095f2c87c31010a69aa6eabb8b21c7a44b GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`kNVGw3Kp1&dmC@5Lt z8c`CQpH@mmtT}V`<;yxP?3_Si(`mIZ?aACvorNyxs`dJteF!0`GybN7O&r>+21GkCiCxvXmmtT}V`<;yxP?4#pi(`mIZ*quE+3)iXt_Kbs zIB;Nt)uvvpwpP9MNePR(JUtSw90+7~=an%nxZ=Q(u%t|sfgw7M?RK8vqaL6E44$rj JF6*2UngEtHGo}Cl literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/up.png b/src/ccma/assets/themes/forest/forest-light/up.png new file mode 100644 index 0000000000000000000000000000000000000000..196e038a95d438968dcf5abbf40b7b7459016ac6 GIT binary patch literal 278 zcmeAS@N?(olHy`uVBq!ia0vp^AT}!p8<4C?sm%aVoCO|{#S9F5he4R}c>anMprB-l zYeY$Kep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt+o^T^vI=X2$j@@*Oe|ak%dv z%))hilj`mRYSX@|SP1Dg94lgF;qY@fpb${N*7U-_G-Fy^LradZt8Qe)E7kKhi4N0J zmDhe{UGRF>%;I%#XMaiRk!%;}JHVLHn0ncy`1u`n2SyQBL&kFl)~#B#`ujgo*Nf4b zTm3J83A_F^{FBZ6thN41o3>rM*RGu!IZNOWTf_<#Pd}wio37dYJY4<$u|*xjZTa|T W=Ib7wsSX1=m%-E3&t;ucLK6U?lxbc7 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/vert-accent.png b/src/ccma/assets/themes/forest/forest-light/vert-accent.png new file mode 100644 index 0000000000000000000000000000000000000000..7dab8747c1ee4d1ed011f4cae5a0ba9cd13c0ec8 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^Ahrkx8<5=cZcP}F;wba4#Pn3(+M|NsB>Y(TJ7 wsGKe5LEL#i5Qj}|&8CP0D-H#=9*$OI@MvIHk=ZD}1*na|)78&qol`;+0NiaZ@c;k- literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/vert-basic.png b/src/ccma/assets/themes/forest/forest-light/vert-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f61ec929dce1436a301c89dfb475313334a923 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^Ahrkx8<5=cZcP}F;wba4#Pn3(+M|NsB>Y;0_8 xo72x5B`_a5c8muEd?HdeHUzdV=4#*W#E@ISuJUUaH_##m22WQ%mvv4FO#lw1Ff9N8 literal 0 HcmV?d00001 diff --git a/src/ccma/assets/themes/forest/forest-light/vert-hover.png b/src/ccma/assets/themes/forest/forest-light/vert-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..bd8957d5fdbecb6ca463fa97a326e85f133c911e GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^Ahrkx8<5=cZcP}F;wba4#Pn3(+M|NsB>Y(Q|8 wEuJmsLD}In5Qj}|&8CP0D-H#=9*$OI@Ty=}F<-(e4b;Zq>FVdQ&MBb@0Ncqfb^rhX literal 0 HcmV?d00001 diff --git a/src/ccma/config.py b/src/ccma/config.py new file mode 100644 index 0000000..97cd80a --- /dev/null +++ b/src/ccma/config.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from ccma.storage.atomic import write_json_atomic + +if TYPE_CHECKING: + from ccma.services.housekeeper import HousekeeperSettings + + +@dataclass(slots=True) +class AppConfig: + store_path: str = "" + gnucash_path: str = "" + theme_mode: str = "dark" + run_housekeeper_on_startup: bool = True + birthday_days_before: int = 7 + birthday_days_after: int = 2 + anniversary_days_before: int = 14 + anniversary_days_after: int = 7 + anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y" + window_geometry: str = "" + window_state: str = "normal" + monitor_bounds: tuple[int, int, int, int] | None = None + + @property + def path(self) -> Path: + return config_directory() / "config.json" + + def save(self) -> None: + write_json_atomic( + self.path, + { + "schema_version": 1, + "store_path": self.store_path, + "gnucash_path": self.gnucash_path, + "theme_mode": self.theme_mode, + "run_housekeeper_on_startup": self.run_housekeeper_on_startup, + "birthday_days_before": self.birthday_days_before, + "birthday_days_after": self.birthday_days_after, + "anniversary_days_before": self.anniversary_days_before, + "anniversary_days_after": self.anniversary_days_after, + "anniversary_intervals": self.anniversary_intervals, + "window_geometry": self.window_geometry, + "window_state": self.window_state, + "monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None, + }, + ) + + def housekeeper_settings(self) -> HousekeeperSettings: + from ccma.services.housekeeper import HousekeeperSettings + from ccma.services.intervals import IntervalValidationError + + try: + return HousekeeperSettings.from_values( + birthday_days_before=self.birthday_days_before, + birthday_days_after=self.birthday_days_after, + anniversary_days_before=self.anniversary_days_before, + anniversary_days_after=self.anniversary_days_after, + anniversary_intervals=self.anniversary_intervals, + ) + except IntervalValidationError: + return HousekeeperSettings() + + +def config_directory() -> Path: + override = os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR") + if override: + return Path(override).expanduser() + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home())) / "CCMA" + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "ccma" + + +def load_config() -> AppConfig: + path = config_directory() / "config.json" + if not path.exists() and not (os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR")): + legacy_path = _legacy_config_directory() / "config.json" + if legacy_path.exists(): + path = legacy_path + store_override = os.environ.get("CCMA_STORE") or os.environ.get("C3MA_STORE", "") + if not path.exists(): + return AppConfig(store_path=store_override) + try: + data = json.loads(path.read_text(encoding="utf-8")) + monitor_raw = data.get("monitor_bounds") + monitor_bounds = None + if isinstance(monitor_raw, list) and len(monitor_raw) == 4: + monitor_bounds = tuple(int(value) for value in monitor_raw) + return AppConfig( + store_path=store_override or str(data.get("store_path", "")), + gnucash_path=str(data.get("gnucash_path", "")), + theme_mode=str(data.get("theme_mode", "dark")), + run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)), + birthday_days_before=int(data.get("birthday_days_before", 7)), + birthday_days_after=int(data.get("birthday_days_after", 2)), + anniversary_days_before=int(data.get("anniversary_days_before", 14)), + anniversary_days_after=int(data.get("anniversary_days_after", 7)), + anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")), + window_geometry=str(data.get("window_geometry", "")), + window_state=str(data.get("window_state", "normal")), + monitor_bounds=monitor_bounds, + ) + except (OSError, ValueError, TypeError): + return AppConfig(store_path=store_override) + + +def _legacy_config_directory() -> Path: + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home())) / "C3MA" + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "c3ma" diff --git a/src/ccma/domain/__init__.py b/src/ccma/domain/__init__.py new file mode 100644 index 0000000..1ac8bec --- /dev/null +++ b/src/ccma/domain/__init__.py @@ -0,0 +1,3 @@ +from ccma.domain.models import ContributionData, Event, Member + +__all__ = ["ContributionData", "Event", "Member"] diff --git a/src/ccma/domain/dates.py b/src/ccma/domain/dates.py new file mode 100644 index 0000000..97097cc --- /dev/null +++ b/src/ccma/domain/dates.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import locale +import os +import re +from datetime import date, datetime + + +class DateValidationError(ValueError): + pass + + +def setup_system_locale() -> None: + try: + locale.setlocale(locale.LC_TIME, "") + except locale.Error: + pass + + +def system_date_pattern() -> str: + try: + system_pattern = locale.nl_langinfo(locale.D_FMT) + except (AttributeError, ValueError): + system_pattern = "" + year_position = system_pattern.find("%Y") + day_position = system_pattern.find("%d") + month_position = system_pattern.find("%m") + if ( + year_position >= 0 + and day_position >= 0 + and month_position >= 0 + and year_position < min(day_position, month_position) + ): + return "%Y-%m-%d" + if day_position >= 0 and month_position >= 0 and day_position < month_position: + return "%d.%m.%Y" + locale_hint = " ".join( + filter( + None, + ( + os.environ.get("LC_TIME"), + os.environ.get("LANGUAGE"), + os.environ.get("LANG"), + ), + ) + ).lower() + day_first_languages = ("de", "at", "ch", "fr", "it", "es", "pt", "nl", "pl", "cs") + return "%d.%m.%Y" if locale_hint.startswith(day_first_languages) else "%Y-%m-%d" + + +def date_input_hint() -> str: + return "DD.MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM-DD" + + +def parse_date_input(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: + text = value.strip() + if not text: + if allow_empty: + return None + raise DateValidationError(f"{field_name} ist erforderlich.") + patterns = (system_date_pattern(), "%Y-%m-%d", "%d.%m.%Y") + for pattern in dict.fromkeys(patterns): + expected = r"\d{2}\.\d{2}\.\d{4}" if pattern == "%d.%m.%Y" else r"\d{4}-\d{2}-\d{2}" + if not re.fullmatch(expected, text): + continue + try: + return datetime.strptime(text, pattern).date() + except ValueError: + continue + formats = date_input_hint() + if formats != "YYYY-MM-DD": + formats += " oder YYYY-MM-DD" + raise DateValidationError(f"{field_name} muss ein gültiges Datum im Format {formats} sein.") + + +def normalize_date_input(value: str, field_name: str) -> str: + parsed = parse_date_input(value, field_name) + return parsed.isoformat() if parsed else "" + + +def format_date_for_display(value: str) -> str: + if not value.strip(): + return "" + parsed = parse_iso_date(value, "Datum") + return parsed.strftime(system_date_pattern()) if parsed else "" + + +def parse_iso_date(value: str, field_name: str, *, allow_empty: bool = True) -> date | None: + text = value.strip() + if not text: + if allow_empty: + return None + raise DateValidationError(f"{field_name} ist erforderlich.") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + raise DateValidationError(f"{field_name} muss das Format JJJJ-MM-TT haben.") + try: + return date.fromisoformat(text) + except ValueError as exc: + raise DateValidationError(f"{field_name} ist kein gültiges Kalenderdatum.") from exc + + +def validate_birth_date(value: str, *, today: date | None = None) -> date | None: + parsed = parse_iso_date(value, "Geburtsdatum") + if parsed is None: + return None + reference = today or date.today() + _validate_birth_date_value(parsed, reference) + return parsed + + +def validate_member_dates( + *, + birth_date: str, + accepted_at: str = "", + membership_started_at: str = "", + today: date | None = None, +) -> None: + reference = today or date.today() + birth = validate_birth_date(birth_date, today=reference) + accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference) + started = _validate_not_future(membership_started_at, "Mitglied seit", reference) + if birth and accepted and accepted < birth: + raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.") + if birth and started and started < birth: + raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.") + if accepted and started and started < accepted: + raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.") + + +def calculate_age(birth_date: date, on_date: date | None = None) -> int: + reference = on_date or date.today() + return ( + reference.year + - birth_date.year + - ((reference.month, reference.day) < (birth_date.month, birth_date.day)) + ) + + +def age_label(value: str, *, today: date | None = None) -> str: + if not value.strip(): + return "Alter: —" + try: + parsed = parse_date_input(value, "Geburtsdatum") + if parsed: + _validate_birth_date_value(parsed, today or date.today()) + except DateValidationError: + return "UNGÜLTIGES DATUM" + return f"Alter: {calculate_age(parsed, today)} Jahre" if parsed else "Alter: —" + + +def _validate_not_future(value: str, field_name: str, reference: date) -> date | None: + parsed = parse_iso_date(value, field_name) + if parsed and parsed > reference: + raise DateValidationError(f"{field_name} darf nicht in der Zukunft liegen.") + return parsed + + +def _validate_birth_date_value(parsed: date, reference: date) -> None: + if parsed > reference: + raise DateValidationError("Geburtsdatum darf nicht in der Zukunft liegen.") + if calculate_age(parsed, reference) > 120: + raise DateValidationError("Geburtsdatum ist unplausibel: Das berechnete Alter liegt über 120.") diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py new file mode 100644 index 0000000..bf1dd2f --- /dev/null +++ b/src/ccma/domain/models.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from typing import Any + + +def _iso_now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +MEMBERSHIP_STATUS_LABELS = { + "application": "ANTRAG", + "accepted_pending_payment": "ANGENOMMEN / ZAHLUNG OFFEN", + "active": "AKTIV", + "suspended_contribution": "RUHEND / BEITRAG", + "resigned_end_of_year": "AUSTRITT ZUM JAHRESENDE", + "honorary": "EHRENMITGLIED", + "ended": "BEENDET", +} + + +@dataclass(slots=True) +class Member: + member_id: str + member_number: str + first_name: str + last_name: str + email: str = "" + birth_date: str = "" + status: str = "application" + accepted_at: str = "" + membership_started_at: str = "" + payment_frequency: str = "annual" + contribution_rule_id: str = "standard-2022" + honorary: bool = False + notes: str = "" + created_at: str = field(default_factory=_iso_now) + updated_at: str = field(default_factory=_iso_now) + schema_version: int = 1 + + @property + def display_name(self) -> str: + return " ".join(part for part in (self.first_name, self.last_name) if part).strip() + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "member_id": self.member_id, + "member_number": self.member_number, + "person": { + "first_name": self.first_name, + "last_name": self.last_name, + "birth_date": self.birth_date, + "email": self.email, + }, + "membership": { + "status": self.status, + "accepted_at": self.accepted_at, + "started_at": self.membership_started_at, + "honorary": self.honorary, + }, + "contribution_profile": { + "rule_id": self.contribution_rule_id, + "payment_frequency": self.payment_frequency, + }, + "notes": self.notes, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Member: + person = data.get("person") or {} + membership = data.get("membership") or {} + contribution = data.get("contribution_profile") or {} + return cls( + schema_version=int(data.get("schema_version", 1)), + member_id=str(data["member_id"]), + member_number=str(data.get("member_number", "")), + first_name=str(person.get("first_name", "")), + last_name=str(person.get("last_name", "")), + email=str(person.get("email", "")), + birth_date=str(person.get("birth_date", "")), + status=str(membership.get("status", "application")), + accepted_at=str(membership.get("accepted_at", "")), + membership_started_at=str(membership.get("started_at", "")), + honorary=bool(membership.get("honorary", False)), + contribution_rule_id=str(contribution.get("rule_id", "standard-2022")), + payment_frequency=str(contribution.get("payment_frequency", "annual")), + notes=str(data.get("notes", "")), + created_at=str(data.get("created_at", _iso_now())), + updated_at=str(data.get("updated_at", _iso_now())), + ) + + +@dataclass(slots=True) +class Event: + event_id: str + timestamp: str + event_type: str + summary: str + actor_type: str = "system" + actor_name: str = "CCMA" + references: dict[str, str] = field(default_factory=dict) + data: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": 1, + "event_id": self.event_id, + "timestamp": self.timestamp, + "type": self.event_type, + "actor": {"type": self.actor_type, "name": self.actor_name}, + "summary": self.summary, + "references": self.references, + "data": self.data, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Event: + actor = data.get("actor") or {} + return cls( + event_id=str(data["event_id"]), + timestamp=str(data["timestamp"]), + event_type=str(data.get("type", "unknown")), + summary=str(data.get("summary", "")), + actor_type=str(actor.get("type", "system")), + actor_name=str(actor.get("name", "CCMA")), + references=dict(data.get("references") or {}), + data=dict(data.get("data") or {}), + ) + + +@dataclass(slots=True) +class ContributionData: + claims: list[dict[str, Any]] = field(default_factory=list) + payments: list[dict[str, Any]] = field(default_factory=list) + schema_version: int = 1 + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "claims": self.claims, + "payments": self.payments, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ContributionData: + return cls( + schema_version=int(data.get("schema_version", 1)), + claims=list(data.get("claims") or []), + payments=list(data.get("payments") or []), + ) + + +@dataclass(frozen=True, slots=True) +class HousekeeperFinding: + severity: str + member_id: str + code: str + title: str + detail: str + due_date: date | None = None + + +def money(value: str | int | float | Decimal) -> Decimal: + return Decimal(str(value)).quantize(Decimal("0.01")) diff --git a/src/ccma/services/__init__.py b/src/ccma/services/__init__.py new file mode 100644 index 0000000..6803af4 --- /dev/null +++ b/src/ccma/services/__init__.py @@ -0,0 +1,3 @@ +from ccma.services.housekeeper import Housekeeper + +__all__ = ["Housekeeper"] diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py new file mode 100644 index 0000000..105719f --- /dev/null +++ b/src/ccma/services/housekeeper.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import calendar +from dataclasses import dataclass, field +from datetime import date, timedelta + +from ccma.domain.dates import DateValidationError, parse_iso_date, validate_birth_date +from ccma.domain.models import HousekeeperFinding +from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals +from ccma.storage.repository import MemberRepository + + +@dataclass(frozen=True, slots=True) +class HousekeeperSettings: + birthday_days_before: int = 7 + birthday_days_after: int = 2 + anniversary_days_before: int = 14 + anniversary_days_after: int = 7 + anniversary_intervals: tuple[AnniversaryInterval, ...] = field( + default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y")) + ) + + @classmethod + def from_values( + cls, + *, + birthday_days_before: int, + birthday_days_after: int, + anniversary_days_before: int, + anniversary_days_after: int, + anniversary_intervals: str, + ) -> HousekeeperSettings: + return cls( + birthday_days_before=min(365, max(0, birthday_days_before)), + birthday_days_after=min(365, max(0, birthday_days_after)), + anniversary_days_before=min(365, max(0, anniversary_days_before)), + anniversary_days_after=min(365, max(0, anniversary_days_after)), + anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)), + ) + + +class Housekeeper: + def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None): + self.repository = repository + self.settings = settings or HousekeeperSettings() + + def run(self, today: date | None = None) -> list[HousekeeperFinding]: + current_date = today or date.today() + findings: list[HousekeeperFinding] = [] + for member in self.repository.list_members(): + if member.status in { + "active", + "suspended_contribution", + "resigned_end_of_year", + "honorary", + }: + birthday = self._birthday_finding( + member.member_id, member.display_name, member.birth_date, current_date + ) + if birthday: + findings.append(birthday) + findings.extend( + self._anniversary_findings( + member.member_id, + member.display_name, + member.membership_started_at, + current_date, + ) + ) + if not member.contribution_rule_id and not member.honorary: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="missing_contribution_rule", + title=f"{member.display_name}: Beitragsregel fehlt", + detail="Dem Mitglied ist keine Beitragsregel zugeordnet.", + ) + ) + + if member.status == "accepted_pending_payment" and member.accepted_at: + accepted = _parse_date(member.accepted_at) + if accepted: + deadline = accepted + timedelta(days=28) + days = (deadline - current_date).days + if days < 0: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="initial_payment_overdue", + title=f"{member.display_name}: Erstzahlung überfällig", + detail=f"Die Vierwochenfrist ist seit {-days} Tagen überschritten.", + due_date=deadline, + ) + ) + elif days <= 7: + findings.append( + HousekeeperFinding( + severity="warning", + member_id=member.member_id, + code="initial_payment_due_soon", + title=f"{member.display_name}: Erstzahlung bald fällig", + detail=f"Die Vierwochenfrist endet in {days} Tagen.", + due_date=deadline, + ) + ) + + contributions = self.repository.get_contributions(member.member_id) + for claim in contributions.claims: + if str(claim.get("status", "open")) not in {"open", "partially_paid"}: + continue + due = _parse_date(str(claim.get("due_date", ""))) + if not due: + continue + days = (due - current_date).days + title = str(claim.get("title") or "Beitragsforderung") + if days < 0: + findings.append( + HousekeeperFinding( + severity="error", + member_id=member.member_id, + code="claim_overdue", + title=f"{member.display_name}: {title} überfällig", + detail=f"Fälligkeit war vor {-days} Tagen.", + due_date=due, + ) + ) + elif days <= 14: + findings.append( + HousekeeperFinding( + severity="info", + member_id=member.member_id, + code="claim_due_soon", + title=f"{member.display_name}: {title} bald fällig", + detail=f"Fälligkeit in {days} Tagen.", + due_date=due, + ) + ) + severity_order = {"error": 0, "warning": 1, "info": 2} + return sorted( + findings, key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max) + ) + + def _birthday_finding( + self, + member_id: str, + name: str, + birth_date_value: str, + today: date, + ) -> HousekeeperFinding | None: + try: + birth_date = validate_birth_date(birth_date_value, today=today) + except DateValidationError: + return None + if not birth_date: + return None + occurrences = [_birthday_in_year(birth_date, year) for year in range(today.year - 1, today.year + 2)] + occurrence = min(occurrences, key=lambda value: abs((value - today).days)) + delta = (occurrence - today).days + if delta > self.settings.birthday_days_before or delta < -self.settings.birthday_days_after: + return None + age = occurrence.year - birth_date.year + title = _relative_title(name, delta, "Geburtstag") + detail = f"Wird {age} Jahre alt." if delta >= 0 else f"Ist {age} Jahre alt geworden." + return HousekeeperFinding( + severity="info", + member_id=member_id, + code="birthday", + title=title, + detail=detail, + due_date=occurrence, + ) + + def _anniversary_findings( + self, + member_id: str, + name: str, + started_at_value: str, + today: date, + ) -> list[HousekeeperFinding]: + try: + started_at = parse_iso_date(started_at_value, "Mitglied seit") + except DateValidationError: + return [] + if not started_at: + return [] + findings: list[HousekeeperFinding] = [] + for interval in self.settings.anniversary_intervals: + try: + target = interval.target_date(started_at) + except (OverflowError, ValueError): + continue + delta = (target - today).days + if delta > self.settings.anniversary_days_before or delta < -self.settings.anniversary_days_after: + continue + occasion = _anniversary_name(interval) + findings.append( + HousekeeperFinding( + severity="info", + member_id=member_id, + code="membership_anniversary", + title=_relative_title(name, delta, occasion), + detail=f"Mitglied seit {started_at:%d.%m.%Y}.", + due_date=target, + ) + ) + return findings + + +def _parse_date(value: str) -> date | None: + try: + return date.fromisoformat(value[:10]) + except (TypeError, ValueError): + return None + + +def _birthday_in_year(birth_date: date, year: int) -> date: + day = min(birth_date.day, calendar.monthrange(year, birth_date.month)[1]) + return date(year, birth_date.month, day) + + +def _relative_title(name: str, delta: int, occasion: str) -> str: + if delta == 0: + return f"{name} hat heute {occasion}" + days = "Tag" if abs(delta) == 1 else "Tagen" + if delta > 0: + return f"{name} hat in {delta} {days} {occasion}" + return f"{name} hatte vor {-delta} {days} {occasion}" + + +def _anniversary_name(interval: AnniversaryInterval) -> str: + if interval.unit == "D": + return f"{interval.value}-Tage-Mitgliedsjubiläum" + if interval.unit == "M": + return f"{interval.value}-Monats-Mitgliedsjubiläum" + return f"{interval.value}-jähriges Mitgliedsjubiläum" diff --git a/src/ccma/services/intervals.py b/src/ccma/services/intervals.py new file mode 100644 index 0000000..a541866 --- /dev/null +++ b/src/ccma/services/intervals.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import calendar +import re +from dataclasses import dataclass +from datetime import date, timedelta + + +class IntervalValidationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class AnniversaryInterval: + value: int + unit: str + + @property + def token(self) -> str: + return f"{self.value}{self.unit}" + + @property + def label(self) -> str: + labels = { + "D": "Tag" if self.value == 1 else "Tage", + "M": "Monat" if self.value == 1 else "Monate", + "Y": "Jahr" if self.value == 1 else "Jahre", + } + return f"{self.value} {labels[self.unit]}" + + def target_date(self, start: date) -> date: + if self.unit == "D": + return start + timedelta(days=self.value) + if self.unit == "M": + return _add_months(start, self.value) + return _add_years(start, self.value) + + +def parse_anniversary_intervals(value: str) -> list[AnniversaryInterval]: + tokens = [token.strip().upper() for token in re.split(r"[;,]", value) if token.strip()] + if not tokens: + raise IntervalValidationError("Mindestens ein Jubiläumsintervall ist erforderlich.") + intervals: list[AnniversaryInterval] = [] + seen: set[str] = set() + maximums = {"D": 36_600, "M": 1_200, "Y": 100} + for token in tokens: + match = re.fullmatch(r"(\d+)([DMY]?)", token) + if not match: + raise IntervalValidationError( + f"Ungültiges Intervall {token!r}. Erwartet werden Angaben wie 1, 30D, 2M oder 10Y." + ) + amount, unit = int(match.group(1)), match.group(2) or "Y" + if amount < 1 or amount > maximums[unit]: + raise IntervalValidationError(f"Intervall {token!r} liegt außerhalb des erlaubten Bereichs.") + normalized = f"{amount}{unit}" + if normalized not in seen: + intervals.append(AnniversaryInterval(amount, unit)) + seen.add(normalized) + return intervals + + +def normalize_anniversary_intervals(value: str) -> str: + return ";".join(interval.token for interval in parse_anniversary_intervals(value)) + + +def _add_months(value: date, months: int) -> date: + month_index = value.month - 1 + months + year = value.year + month_index // 12 + month = month_index % 12 + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) + + +def _add_years(value: date, years: int) -> date: + target_year = value.year + years + day = min(value.day, calendar.monthrange(target_year, value.month)[1]) + return date(target_year, value.month, day) diff --git a/src/ccma/storage/__init__.py b/src/ccma/storage/__init__.py new file mode 100644 index 0000000..55f69be --- /dev/null +++ b/src/ccma/storage/__init__.py @@ -0,0 +1,3 @@ +from ccma.storage.repository import MemberRepository, RepositoryError + +__all__ = ["MemberRepository", "RepositoryError"] diff --git a/src/ccma/storage/atomic.py b/src/ccma/storage/atomic.py new file mode 100644 index 0000000..2cfd925 --- /dev/null +++ b/src/ccma/storage/atomic.py @@ -0,0 +1,25 @@ +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def write_json_atomic(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def read_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py new file mode 100644 index 0000000..fb3b977 --- /dev/null +++ b/src/ccma/storage/repository.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import json +import os +import unicodedata +from collections.abc import Iterable +from datetime import date, datetime +from pathlib import Path +from string import Formatter +from uuid import uuid4 + +from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member +from ccma.storage.atomic import read_json, write_json_atomic + + +class RepositoryError(RuntimeError): + pass + + +DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}" + + +DEFAULT_CONFIGURATION = { + "schema_version": 1, + "organization": "Chaos Computer Club Mannheim e.V.", + "member_number_policy": { + "mode": "automatic", + "pattern": DEFAULT_MEMBER_NUMBER_PATTERN, + }, + "member_number_sequences": {}, + "contribution_rules": [ + { + "rule_id": "standard-2022", + "name": "Regulärer Beitrag ab 2022", + "valid_from": "2022-01-01", + "annual_amount": "150.00", + "admission_fee": "10.00", + "annual_due": "01-31", + "semiannual_due": ["01-31", "07-31"], + "first_payment_due_days_after_acceptance": 28, + "reminder_fee": "5.00", + "failed_debit_fee": "5.00", + } + ], +} + + +class MemberRepository: + def __init__(self, root: Path | str): + self.root = Path(root).expanduser().resolve() + self.members_root = self.root / "members" + + def initialize(self) -> None: + self.members_root.mkdir(parents=True, exist_ok=True) + config_path = self.root / "repository.json" + if not config_path.exists(): + write_json_atomic(config_path, DEFAULT_CONFIGURATION) + + def validate(self) -> list[str]: + errors: list[str] = [] + try: + config = read_json(self.root / "repository.json") + if int(config.get("schema_version", 0)) != 1: + errors.append("repository.json: nicht unterstützte schema_version") + policy = config.get("member_number_policy") or {} + if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}: + errors.append("repository.json: ungültiger Mitgliedsnummernmodus") + validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))) + except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc: + errors.append(f"repository.json: {exc}") + + seen_numbers: dict[str, str] = {} + for member_dir in self._member_directories(): + try: + member = self.get_member(member_dir.name) + validate_member_dates( + birth_date=member.birth_date, + accepted_at=member.accepted_at, + membership_started_at=member.membership_started_at, + ) + if member.member_id != member_dir.name: + errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein") + normalized_number = member.member_number.casefold().strip() + if normalized_number and normalized_number in seen_numbers: + errors.append( + f"{member_dir.name}/member.json: Mitgliedsnummer {member.member_number} ist doppelt" + ) + elif normalized_number: + seen_numbers[normalized_number] = member.member_id + except ( + OSError, + ValueError, + TypeError, + KeyError, + json.JSONDecodeError, + DateValidationError, + ) as exc: + errors.append(f"{member_dir.name}/member.json: {exc}") + return errors + + def list_members(self) -> list[Member]: + members: list[Member] = [] + for directory in self._member_directories(): + try: + members.append(self.get_member(directory.name)) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): + continue + return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold())) + + def get_member(self, member_id: str) -> Member: + path = self._member_path(member_id) / "member.json" + if not path.is_file(): + raise RepositoryError(f"Mitglied nicht gefunden: {member_id}") + return Member.from_dict(read_json(path)) + + def create_member( + self, + *, + first_name: str, + last_name: str, + email: str = "", + birth_date: str = "", + member_number: str = "", + ) -> Member: + if not first_name.strip() or not last_name.strip(): + raise RepositoryError("Vorname und Nachname sind erforderlich.") + try: + birth_date = normalize_date_input(birth_date, "Geburtsdatum") + validate_member_dates(birth_date=birth_date) + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + selected_number = member_number.strip() + policy = self.get_member_number_policy() + if selected_number: + self._assert_member_number_available(selected_number) + elif policy["mode"] == "manual": + raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.") + else: + selected_number = self._allocate_member_number(policy["pattern"]) + member_id = str(uuid4()) + directory = self._member_path(member_id) + directory.mkdir(parents=True, exist_ok=False) + (directory / "files").mkdir() + member = Member( + member_id=member_id, + member_number=selected_number, + first_name=first_name.strip(), + last_name=last_name.strip(), + email=email.strip(), + birth_date=birth_date, + ) + write_json_atomic(directory / "member.json", member.to_dict()) + write_json_atomic(directory / "contributions.json", ContributionData().to_dict()) + self.append_event( + member_id, + event_type="member_created", + summary="Mitgliederakte angelegt", + actor_type="user", + actor_name="Vorstand", + ) + return member + + def save_member(self, member: Member, *, actor_name: str = "Vorstand") -> None: + existing = self.get_member(member.member_id) + try: + member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum") + member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") + member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit") + validate_member_dates( + birth_date=member.birth_date, + accepted_at=member.accepted_at, + membership_started_at=member.membership_started_at, + ) + except DateValidationError as exc: + raise RepositoryError(str(exc)) from exc + if member.member_number != existing.member_number: + self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id) + changes = self._summarize_changes(existing, member) + member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + write_json_atomic(self._member_path(member.member_id) / "member.json", member.to_dict()) + if changes: + self.append_event( + member.member_id, + event_type="member_data_changed", + summary=f"Mitgliedsdaten geändert: {', '.join(changes)}", + actor_type="user", + actor_name=actor_name, + ) + + def get_contributions(self, member_id: str) -> ContributionData: + path = self._member_path(member_id) / "contributions.json" + if not path.exists(): + return ContributionData() + return ContributionData.from_dict(read_json(path)) + + def save_contributions(self, member_id: str, data: ContributionData) -> None: + self.get_member(member_id) + write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict()) + + def append_event( + self, + member_id: str, + *, + event_type: str, + summary: str, + actor_type: str = "system", + actor_name: str = "CCMA", + references: dict[str, str] | None = None, + data: dict[str, object] | None = None, + ) -> Event: + directory = self._member_path(member_id) + if not (directory / "member.json").is_file(): + raise RepositoryError(f"Mitglied nicht gefunden: {member_id}") + event = Event( + event_id=str(uuid4()), + timestamp=datetime.now().astimezone().isoformat(timespec="seconds"), + event_type=event_type, + summary=summary.strip(), + actor_type=actor_type, + actor_name=actor_name, + references=references or {}, + data=data or {}, + ) + path = directory / "events.jsonl" + line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n" + with path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(line) + handle.flush() + os.fsync(handle.fileno()) + return event + + def get_events(self, member_id: str) -> list[Event]: + path = self._member_path(member_id) / "events.jsonl" + if not path.exists(): + return [] + events: list[Event] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + events.append(Event.from_dict(json.loads(line))) + except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc + return events + + def search(self, query: str) -> list[Member]: + tokens = [_normalize(token) for token in query.split() if token.strip()] + if not tokens: + return self.list_members() + scored: list[tuple[int, Member]] = [] + for member in self.list_members(): + fields = [ + member.member_number, + member.first_name, + member.last_name, + member.display_name, + member.email, + member.birth_date, + _german_date(member.birth_date), + ] + normalized = [_normalize(value) for value in fields if value] + if not all(any(token in value for value in normalized) for token in tokens): + continue + exact = sum(token == value for token in tokens for value in normalized) + prefix = sum(value.startswith(token) for token in tokens for value in normalized) + scored.append((exact * 100 + prefix * 10, member)) + scored.sort(key=lambda item: (-item[0], item[1].last_name.casefold(), item[1].first_name.casefold())) + return [member for _, member in scored] + + def member_count(self) -> int: + return sum(1 for _ in self._member_directories()) + + def get_member_number_policy(self) -> dict[str, str]: + try: + config = read_json(self.root / "repository.json") + except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + raise RepositoryError(f"Mitgliedsnummernregel konnte nicht gelesen werden: {exc}") from exc + policy = config.get("member_number_policy") or {} + mode = str(policy.get("mode", "automatic")) + if mode not in {"automatic", "manual"}: + mode = "automatic" + pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)) + validate_member_number_pattern(pattern) + return {"mode": mode, "pattern": pattern} + + def save_member_number_policy(self, *, mode: str, pattern: str) -> None: + if mode not in {"automatic", "manual"}: + raise RepositoryError("Ungültiger Mitgliedsnummernmodus.") + validate_member_number_pattern(pattern) + config = read_json(self.root / "repository.json") + config["member_number_policy"] = {"mode": mode, "pattern": pattern.strip()} + config.setdefault("member_number_sequences", {}) + write_json_atomic(self.root / "repository.json", config) + + def preview_member_number(self, pattern: str | None = None) -> str: + selected_pattern = pattern or self.get_member_number_policy()["pattern"] + validate_member_number_pattern(selected_pattern) + config = read_json(self.root / "repository.json") + return self._next_available_member_number(config, selected_pattern)[0] + + def _member_directories(self) -> Iterable[Path]: + if not self.members_root.exists(): + return [] + return ( + path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".") + ) + + def _member_path(self, member_id: str) -> Path: + if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}: + raise RepositoryError("Ungültige Mitglieds-ID.") + return self.members_root / member_id + + def _allocate_member_number(self, pattern: str) -> str: + config = read_json(self.root / "repository.json") + member_number, next_value = self._next_available_member_number(config, pattern) + sequences = config.get("member_number_sequences") + if not isinstance(sequences, dict): + sequences = {} + config["member_number_sequences"] = sequences + sequences[pattern] = next_value + write_json_atomic(self.root / "repository.json", config) + return member_number + + def _next_available_member_number(self, config: dict, pattern: str) -> tuple[str, int]: + sequences = config.get("member_number_sequences") + if not isinstance(sequences, dict): + sequences = {} + try: + number = max(1, int(sequences.get(pattern, 1))) + except (TypeError, ValueError): + number = 1 + existing = {member.member_number.casefold() for member in self.list_members() if member.member_number} + for _attempt in range(1_000_000): + candidate = format_member_number(pattern, number) + number += 1 + if candidate.casefold() not in existing: + return candidate, number + raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.") + + def _assert_member_number_available( + self, + member_number: str, + *, + exclude_member_id: str | None = None, + ) -> None: + normalized = member_number.casefold().strip() + if not normalized: + raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.") + for member in self.list_members(): + if member.member_id != exclude_member_id and member.member_number.casefold() == normalized: + raise RepositoryError(f"Die Mitgliedsnummer {member_number} ist bereits vergeben.") + + @staticmethod + def _summarize_changes(before: Member, after: Member) -> list[str]: + labels = { + "member_number": "Mitgliedsnummer", + "first_name": "Vorname", + "last_name": "Nachname", + "email": "E-Mail-Adresse", + "birth_date": "Geburtsdatum", + "status": "Status", + "payment_frequency": "Zahlungsweise", + "contribution_rule_id": "Beitragsregel", + "honorary": "Ehrenmitgliedschaft", + "notes": "interne Notiz", + } + changes: list[str] = [] + for field, label in labels.items(): + old_value = getattr(before, field) + new_value = getattr(after, field) + if old_value == new_value: + continue + if field == "status": + old_label = MEMBERSHIP_STATUS_LABELS.get(str(old_value), str(old_value)) + new_label = MEMBERSHIP_STATUS_LABELS.get(str(new_value), str(new_value)) + changes.append(f"Status von {old_label} zu {new_label}") + else: + changes.append(label) + return changes + + +def _normalize(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value.casefold().strip()) + return "".join(character for character in normalized if not unicodedata.combining(character)) + + +def _german_date(value: str) -> str: + try: + return date.fromisoformat(value).strftime("%d.%m.%Y") + except ValueError: + return "" + + +def validate_member_number_pattern(pattern: str) -> None: + selected = pattern.strip() + if not selected: + raise RepositoryError("Das Mitgliedsnummern-Pattern darf nicht leer sein.") + has_number = False + try: + for _literal, field_name, format_spec, conversion in Formatter().parse(selected): + if field_name is None: + continue + if field_name not in {"number", "year"}: + raise RepositoryError(f"Unbekannter Platzhalter: {{{field_name}}}") + if conversion: + raise RepositoryError("Konvertierungen wie !r sind im Pattern nicht erlaubt.") + if "{" in format_spec or "}" in format_spec: + raise RepositoryError("Verschachtelte Formatierungen sind nicht erlaubt.") + has_number = has_number or field_name == "number" + if not has_number: + raise RepositoryError("Das Pattern muss den Platzhalter {number} enthalten.") + first = format_member_number(selected, 1) + second = format_member_number(selected, 2) + if first == second: + raise RepositoryError("Das Pattern erzeugt keine eindeutigen Mitgliedsnummern.") + except RepositoryError: + raise + except (KeyError, ValueError) as exc: + raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc + + +def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str: + try: + value = pattern.strip().format(number=number, year=year or date.today().year) + except (KeyError, ValueError) as exc: + raise RepositoryError(f"Mitgliedsnummer konnte nicht formatiert werden: {exc}") from exc + if not value or len(value) > 80 or any(character in value for character in "\r\n\t"): + raise RepositoryError("Das Pattern erzeugt eine ungültige Mitgliedsnummer.") + return value diff --git a/src/ccma/ui/__init__.py b/src/ccma/ui/__init__.py new file mode 100644 index 0000000..09e76d6 --- /dev/null +++ b/src/ccma/ui/__init__.py @@ -0,0 +1 @@ +"""Tk user interface for CCMA.""" diff --git a/src/ccma/ui/changelog_view.py b/src/ccma/ui/changelog_view.py new file mode 100644 index 0000000..07bb0d4 --- /dev/null +++ b/src/ccma/ui/changelog_view.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import tkinter as tk +from pathlib import Path +from tkinter import ttk +from typing import Any + +from ccma import __version__ +from ccma.ui.icons import IconStore + +CHANGELOG_PATH = Path(__file__).resolve().parent.parent / "assets" / "CHANGELOG.json" + + +def load_changelog(path: Path = CHANGELOG_PATH) -> list[dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return [] + if not isinstance(data, list): + return [] + return [entry for entry in data if isinstance(entry, dict)] + + +class ChangelogView(ttk.Frame): + def __init__(self, master: tk.Misc): + super().__init__(master, padding=14) + self.icons = IconStore(self) + self.entries = load_changelog() + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 14)) + header.columnconfigure(1, weight=1) + history_icon = self.icons.get("history", 28) + icon_label = ttk.Label(header, image=history_icon) + icon_label.image = history_icon + icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10)) + ttk.Label(header, text="Änderungsverlauf", style="TabTitle.TLabel").grid(row=0, column=1, sticky="w") + ttk.Label( + header, + text=f"Aktuelle Version {__version__} · {len(self.entries)} Release-Einträge", + style="Mono.TLabel", + ).grid(row=1, column=1, sticky="w", pady=(2, 0)) + + container = ttk.Frame(self) + container.grid(row=1, column=0, sticky="nsew") + container.columnconfigure(0, weight=1) + container.rowconfigure(0, weight=1) + background = ttk.Style(self).lookup("TFrame", "background") or "#ffffff" + self.canvas = tk.Canvas(container, highlightthickness=0, borderwidth=0, background=background) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview) + self.canvas.configure(yscrollcommand=scrollbar.set) + self.canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + self.content = ttk.Frame(self.canvas) + self.content.columnconfigure(0, weight=1) + self.content_window = self.canvas.create_window((0, 0), anchor="nw", window=self.content) + self.content.bind("", self._update_scroll_region) + self.canvas.bind("", self._resize_content) + self._render_entries() + self._bind_mousewheel_tree(self.canvas) + self._bind_mousewheel_tree(self.content) + + def _render_entries(self) -> None: + if not self.entries: + ttk.Label(self.content, text="Kein Changelog verfügbar.", style="Muted.TLabel").grid( + row=0, column=0, sticky="w", padx=8, pady=8 + ) + return + for row, entry in enumerate(self.entries): + version = str(entry.get("version", "unbekannt")) + card = ttk.LabelFrame(self.content, padding=14) + card.grid(row=row, column=0, sticky="ew", padx=(0, 8), pady=(0, 12)) + card.columnconfigure(1, weight=1) + ttk.Label(card, text=f"VERSION {version}", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w" + ) + if version == __version__: + ttk.Label(card, text="CURRENT", style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(12, 0) + ) + ttk.Label(card, text=str(entry.get("date", "")), style="Muted.TLabel").grid( + row=0, column=2, sticky="e" + ) + changes = entry.get("changes", []) + if not isinstance(changes, list): + continue + for change_row, change in enumerate(changes, start=1): + ttk.Label(card, text="•", style="TimelineHeader.TLabel").grid( + row=change_row, column=0, sticky="nw", pady=(7, 0) + ) + ttk.Label( + card, + text=str(change), + justify="left", + wraplength=650, + ).grid(row=change_row, column=1, columnspan=2, sticky="w", padx=(8, 0), pady=(7, 0)) + + def _update_scroll_region(self, _event: tk.Event | None = None) -> None: + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def _resize_content(self, event: tk.Event) -> None: + self.canvas.itemconfigure(self.content_window, width=event.width) + + def _bind_mousewheel_tree(self, widget: tk.Misc) -> None: + widget.bind("", self._on_mousewheel, add="+") + widget.bind("", self._on_mousewheel, add="+") + widget.bind("", self._on_mousewheel, add="+") + for child in widget.winfo_children(): + self._bind_mousewheel_tree(child) + + def _on_mousewheel(self, event: tk.Event) -> None: + if getattr(event, "num", None) == 4: + delta = -1 + elif getattr(event, "num", None) == 5: + delta = 1 + else: + delta = -1 if getattr(event, "delta", 0) > 0 else 1 + self.canvas.yview_scroll(delta * 3, "units") diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py new file mode 100644 index 0000000..a8c997b --- /dev/null +++ b/src/ccma/ui/dialogs.py @@ -0,0 +1,90 @@ +import tkinter as tk +from collections.abc import Callable +from tkinter import messagebox, ttk + +from ccma.domain.dates import age_label, date_input_hint +from ccma.domain.models import Member +from ccma.storage.repository import MemberRepository, RepositoryError + + +class NewMemberDialog(tk.Toplevel): + def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]): + super().__init__(master) + self.repository = repository + self.on_created = on_created + self.title("Neue Mitgliederakte") + self.transient(master.winfo_toplevel()) + self.grab_set() + self.resizable(False, False) + self.number_policy = repository.get_member_number_policy() + self.variables = { + name: tk.StringVar() + for name in ("first_name", "last_name", "email", "birth_date", "member_number") + } + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._create()) + self.after_idle(self._focus_first) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + fields = [ + ("Vorname *", "first_name"), + ("Nachname *", "last_name"), + ("E-Mail-Adresse", "email"), + (f"Geburtsdatum ({date_input_hint()})", "birth_date"), + ] + if self.number_policy["mode"] == "manual": + fields.append(("Mitgliedsnummer *", "member_number")) + self.entries: dict[str, ttk.Entry] = {} + for row, (label, key) in enumerate(fields): + ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + if key == "birth_date": + birth_row = ttk.Frame(frame) + birth_row.grid(row=row, column=1, sticky="ew", pady=5) + birth_row.columnconfigure(0, weight=1) + entry = ttk.Entry(birth_row, textvariable=self.variables[key], width=24) + entry.grid(row=0, column=0, sticky="ew") + self.birth_age_var = tk.StringVar(value="Alter: —") + ttk.Label(birth_row, textvariable=self.birth_age_var, style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(10, 0) + ) + self.variables[key].trace_add( + "write", + lambda *_args: self.birth_age_var.set(age_label(self.variables["birth_date"].get())), + ) + else: + entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) + entry.grid(row=row, column=1, sticky="ew", pady=5) + self.entries[key] = entry + button_row = len(fields) + if self.number_policy["mode"] == "automatic": + preview = self.repository.preview_member_number(self.number_policy["pattern"]) + ttk.Label(frame, text="Mitgliedsnummer").grid( + row=button_row, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Label(frame, text=f"Automatisch: {preview}", style="TimelineHeader.TLabel").grid( + row=button_row, column=1, sticky="w", pady=5 + ) + button_row += 1 + buttons = ttk.Frame(frame) + buttons.grid(row=button_row, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Akte anlegen", style="Accent.TButton", command=self._create).pack( + side="left" + ) + + def _focus_first(self) -> None: + self.entries["first_name"].focus_set() + + def _create(self) -> None: + try: + member = self.repository.create_member( + **{key: variable.get() for key, variable in self.variables.items()} + ) + except RepositoryError as exc: + messagebox.showerror("Akte konnte nicht angelegt werden", str(exc), parent=self) + return + self.destroy() + self.on_created(member) diff --git a/src/ccma/ui/icons.py b/src/ccma/ui/icons.py new file mode 100644 index 0000000..bbe9107 --- /dev/null +++ b/src/ccma/ui/icons.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import tkinter as tk +from importlib import import_module, resources +from pathlib import Path +from tkinter import ttk + + +class IconStore: + """Load and cache Material Design icons used by ttk widgets.""" + + def __init__(self, master: tk.Misc): + self.master = master + self.style = ttk.Style(master) + self._cache: dict[tuple[str, int, str], tk.PhotoImage | None] = {} + self._mat_icon = self._load_mat_icon_class() + + def get(self, name: str, size: int = 16, color: str | None = None) -> tk.PhotoImage | None: + resolved_color = color or self._theme_color() + key = (name, size, resolved_color) + if key not in self._cache: + self._cache[key] = self._load(name, size, resolved_color) + return self._cache[key] + + def clear(self) -> None: + self._cache.clear() + + def _load(self, name: str, size: int, color: str) -> tk.PhotoImage | None: + if self._mat_icon: + try: + return self._mat_icon(name, size=size, color=color).image + except Exception: + pass + return self._load_asset_fallback(name, size) + + def _load_asset_fallback(self, name: str, size: int) -> tk.PhotoImage | None: + for module_name in ("ttkbootstrap_icons_mat", "ttkbootstrap_icons"): + try: + root = resources.files(import_module(module_name)) + except (ImportError, TypeError): + continue + candidates = {name, name.replace("-", "_"), name.replace("_", "-")} + for candidate in candidates: + for extension in (".png", ".gif"): + try: + path = Path(next(root.rglob(f"{candidate}{extension}"))) + image = tk.PhotoImage(master=self.master, file=str(path)) + if size and image.width() > size: + factor = max(1, round(image.width() / size)) + image = image.subsample(factor, factor) + return image + except (StopIteration, tk.TclError, OSError): + continue + return None + + @staticmethod + def _load_mat_icon_class(): + try: + module = import_module("ttkbootstrap_icons_mat") + except ImportError: + return None + return getattr(module, "MatIcon", None) + + def _theme_color(self) -> str: + try: + theme_name = self.style.theme_use().lower() + except tk.TclError: + theme_name = "" + if "dark" in theme_name: + return "#eeeeee" + if "light" in theme_name: + return "#313131" + background = self.style.lookup("TFrame", "background") or "#ffffff" + try: + red, green, blue = self.master.winfo_rgb(background) + luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 65535 + return "#eeeeee" if luminance < 0.5 else "#313131" + except tk.TclError: + return "#313131" diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py new file mode 100644 index 0000000..e93ce18 --- /dev/null +++ b/src/ccma/ui/main_window.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk + +from ccma import __version__ +from ccma.config import AppConfig +from ccma.domain.models import HousekeeperFinding, Member +from ccma.services.housekeeper import Housekeeper +from ccma.storage.repository import MemberRepository +from ccma.ui.dialogs import NewMemberDialog +from ccma.ui.icons import IconStore +from ccma.ui.member_tab import MemberTab +from ccma.ui.options_dialog import OptionsDialog +from ccma.ui.theme import load_theme +from ccma.ui.work_tabs import DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab + + +class TabManager: + def __init__(self, notebook: ttk.Notebook): + self.notebook = notebook + self.tabs: dict[str, tk.Widget] = {} + self.images: dict[str, tk.PhotoImage] = {} + self.icon_names: dict[str, str] = {} + + def add( + self, + key: str, + widget: tk.Widget, + title: str, + *, + select: bool = True, + image: tk.PhotoImage | None = None, + icon_name: str | None = None, + ) -> None: + self.tabs[key] = widget + options: dict[str, object] = {"text": title, "padding": (6, 3)} + if image: + options.update(image=image, compound="left") + self.images[key] = image + if icon_name: + self.icon_names[key] = icon_name + self.notebook.add(widget, **options) + if select: + self.notebook.select(widget) + + def focus(self, key: str) -> bool: + widget = self.tabs.get(key) + if widget and widget.winfo_exists(): + self.notebook.select(widget) + return True + self.tabs.pop(key, None) + return False + + def close(self, key: str) -> None: + widget = self.tabs.pop(key, None) + self.images.pop(key, None) + self.icon_names.pop(key, None) + if widget and widget.winfo_exists(): + self.notebook.forget(widget) + widget.destroy() + + def refresh_icons(self, icons: IconStore) -> None: + for key, icon_name in self.icon_names.items(): + widget = self.tabs.get(key) + image = icons.get(icon_name, 16) + if widget and widget.winfo_exists() and image: + self.images[key] = image + self.notebook.tab(widget, image=image, compound="left") + + +class MainWindow(ttk.Frame): + def __init__( + self, + master: tk.Tk, + repository: MemberRepository, + config: AppConfig, + findings: list[HousekeeperFinding], + validation_errors: list[str], + ): + super().__init__(master, padding=(12, 8)) + self.repository = repository + self.config = config + self.findings = findings + self.validation_errors = validation_errors + self.search_var = tk.StringVar() + self.status_var = tk.StringVar(value="Bereit.") + self.search_counter = 1 + self.icons = IconStore(self) + self.ribbon: ttk.Frame | None = None + self.housekeeper_button: ttk.Button | None = None + self._build_ui() + if validation_errors: + self.after_idle(self._show_validation_warning) + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + self._build_ribbon() + self.notebook = ttk.Notebook(self) + self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0)) + self.tabs = TabManager(self.notebook) + self.dashboard = DashboardTab( + self.notebook, + self.repository.member_count(), + self.findings, + self.open_housekeeper, + ) + self.tabs.add( + "dashboard", + self.dashboard, + "Dashboard", + image=self.icons.get("view-dashboard", 16), + icon_name="view-dashboard", + ) + status = ttk.Frame(self, padding=(6, 5)) + status.grid(row=2, column=0, sticky="ew") + status.columnconfigure(0, weight=1) + ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label( + status, + text=f"STORE {self.repository.root} · VERSION {__version__}", + style="Status.TLabel", + ).grid(row=0, column=1, sticky="e") + + def _build_ribbon(self) -> None: + ribbon = ttk.Frame(self, style="Ribbon.TFrame") + self.ribbon = ribbon + ribbon.grid(row=0, column=0, sticky="ew") + ribbon.columnconfigure(2, weight=1) + title = ttk.Frame(ribbon) + title.grid(row=0, column=0, sticky="w", padx=(0, 18)) + ttk.Label(title, text="CCMA", style="AppTitle.TLabel").pack(anchor="w") + ttk.Label(title, text="// MEMBER ADMIN", style="Mono.TLabel").pack(anchor="w") + search = ttk.Frame(ribbon) + search.grid(row=0, column=1, sticky="w") + entry = ttk.Entry(search, textvariable=self.search_var, font=("TkDefaultFont", 11), width=40) + entry.grid(row=0, column=0, sticky="w") + entry.bind("", lambda _event: self.search()) + entry.bind("", lambda _event: entry.focus_set()) + ttk.Label(search, text="Name · E-Mail · Geburtsdatum · Mitgliedsnummer", style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 0) + ) + search_icon = self.icons.get("magnify", 24) + search_button = ttk.Button( + search, + text="Suchen", + image=search_icon, + compound="top", + width=14, + command=self.search, + ) + search_button.image = search_icon + search_button.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(6, 0)) + + ttk.Separator(ribbon, orient="vertical").grid( + row=0, + column=3, + sticky="ns", + padx=16, + ) + actions = ttk.Frame(ribbon) + actions.grid(row=0, column=4, sticky="e") + members_icon = self.icons.get("account-group", 24) or self.icons.get("account-multiple", 24) + members_button = ttk.Button( + actions, + text="Mitglieder", + image=members_icon, + compound="top", + width=14, + command=self.open_members, + ) + members_button.image = members_icon + members_button.pack(side="left", padx=(0, 6)) + new_icon = self.icons.get("account-plus", 24) + new_button = ttk.Button( + actions, + text="Neues Mitglied", + image=new_icon, + compound="top", + width=14, + command=self.new_member, + ) + new_button.image = new_icon + new_button.pack(side="left", padx=(0, 6)) + housekeeper_icon = self.icons.get("broom", 24) or self.icons.get("clipboard-check", 24) + self.housekeeper_button = ttk.Button( + actions, + text=f"Hausmeister ({len(self.findings)})", + image=housekeeper_icon, + compound="top", + width=14, + style="Accent.TButton", + command=self.open_housekeeper, + ) + self.housekeeper_button.image = housekeeper_icon + self.housekeeper_button.pack(side="left", padx=(0, 6)) + theme_icon = self.icons.get("theme-light-dark", 24) + theme_button = ttk.Button( + actions, + text="Light/Dark", + image=theme_icon, + compound="top", + width=14, + command=self.toggle_theme, + ) + theme_button.image = theme_icon + theme_button.pack(side="left", padx=(0, 6)) + options_icon = self.icons.get("cog", 24) + options_button = ttk.Button( + actions, + text="Optionen", + image=options_icon, + compound="top", + width=14, + command=self.open_options, + ) + options_button.image = options_icon + options_button.pack(side="left", padx=(0, 6)) + exit_icon = self.icons.get("logout", 24) or self.icons.get("exit-to-app", 24) + exit_button = ttk.Button( + actions, + text="Beenden", + image=exit_icon, + compound="top", + width=14, + command=getattr(self.master, "close", self.master.destroy), + ) + exit_button.image = exit_icon + exit_button.pack(side="left") + + def search(self) -> None: + query = self.search_var.get().strip() + if not query: + self.status_var.set("Bitte einen Suchbegriff eingeben.") + return + results = self.repository.search(query) + self.search_var.set("") + if len(results) == 1: + self.open_member(results[0].member_id) + self.status_var.set(f"Eindeutiger Treffer: {results[0].display_name}") + return + key = f"search:{self.search_counter}" + self.search_counter += 1 + tab = SearchResultsTab( + self.notebook, + query, + results, + self.open_member, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + f"Suche: {query[:20]}", + image=self.icons.get("format-list-text", 16), + icon_name="format-list-text", + ) + self.status_var.set(f"{len(results)} Treffer für {query}") + + def open_member(self, member_id: str) -> None: + key = f"member:{member_id}" + if self.tabs.focus(key): + return + member = self.repository.get_member(member_id) + tab = MemberTab( + self.notebook, + self.repository, + member_id, + on_close=lambda: self.tabs.close(key), + on_changed=self.refresh_overview, + ) + self.tabs.add( + key, + tab, + member.display_name or member.member_number, + image=self.icons.get("account", 16), + icon_name="account", + ) + + def open_members(self) -> None: + key = "members" + if self.tabs.focus(key): + return + tab = MembersTab( + self.notebook, + self.repository.list_members(), + self.open_member, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + "Mitglieder", + image=self.icons.get("account-group", 16) or self.icons.get("account-multiple", 16), + icon_name="account-group", + ) + + def new_member(self) -> None: + NewMemberDialog(self, self.repository, self._member_created) + + def _member_created(self, member: Member) -> None: + self.refresh_overview() + self.open_member(member.member_id) + self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.") + + def open_housekeeper(self) -> None: + key = "housekeeper" + if self.tabs.focus(key): + return + tab = HousekeeperTab( + self.notebook, + self.findings, + self.open_member, + self.run_housekeeper, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + "Hausmeister", + image=self.icons.get("broom", 16), + icon_name="broom", + ) + + def run_housekeeper(self) -> list[HousekeeperFinding]: + self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() + self.refresh_overview(run_housekeeper=False) + self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") + return self.findings + + def refresh_overview(self, *, run_housekeeper: bool = True) -> None: + if run_housekeeper: + self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() + self.dashboard.update_data(self.repository.member_count(), self.findings) + members_tab = self.tabs.tabs.get("members") + if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): + members_tab.refresh(self.repository.list_members()) + if self.housekeeper_button and self.housekeeper_button.winfo_exists(): + self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})") + + def toggle_theme(self) -> None: + self.config.theme_mode = "light" if self.config.theme_mode == "dark" else "dark" + self.config.save() + load_theme(self.master, self.config.theme_mode) + self._refresh_ribbon_icons() + self.status_var.set(f"Theme: {self.config.theme_mode}.") + + def open_options(self) -> None: + OptionsDialog(self, self.config, self.repository, self._options_saved) + + def _options_saved(self, store_changed: bool) -> None: + load_theme(self.master, self.config.theme_mode) + self._refresh_ribbon_icons() + if store_changed: + self.status_var.set("Mitglieder-Store geändert. Die Änderung wird beim Neustart aktiv.") + messagebox.showinfo( + "Neustart erforderlich", + "Der neue Mitglieder-Store wird beim nächsten Programmstart geöffnet.", + parent=self, + ) + else: + self.status_var.set("Optionen gespeichert.") + + def _refresh_ribbon_icons(self) -> None: + if self.ribbon and self.ribbon.winfo_exists(): + self.ribbon.destroy() + self.icons = IconStore(self) + self._build_ribbon() + self.tabs.refresh_icons(self.icons) + + def _show_validation_warning(self) -> None: + messagebox.showwarning( + "Datenprüfung", + "Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]), + parent=self, + ) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py new file mode 100644 index 0000000..3fced02 --- /dev/null +++ b/src/ccma/ui/member_tab.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import subprocess +import sys +import tkinter as tk +from collections.abc import Callable +from datetime import datetime +from tkinter import messagebox, ttk + +from ccma.domain.dates import age_label, date_input_hint, format_date_for_display +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS +from ccma.domain.models import Event +from ccma.storage.repository import MemberRepository, RepositoryError + + +class MemberTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + member_id: str, + on_close: Callable[[], None], + on_changed: Callable[[], None], + ): + super().__init__(master, padding=12) + self.repository = repository + self.member_id = member_id + self.on_close = on_close + self.on_changed = on_changed + self.member = repository.get_member(member_id) + self.variables: dict[str, tk.Variable] = {} + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + self.status_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 0) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid( + row=0, column=1, rowspan=2, sticky="e" + ) + + self.pane = ttk.Panedwindow(self, orient="horizontal") + self.pane.grid(row=1, column=0, sticky="nsew") + self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) + self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) + self.pane.add(self.details_pane, weight=2) + self.pane.add(self.timeline_pane, weight=3) + self._build_details(self.details_pane) + self._build_timeline(self.timeline_pane) + self._pane_position_initialized = False + self.pane.bind("", self._set_initial_pane_position, add="+") + + def _set_initial_pane_position(self, event: tk.Event | None = None) -> None: + if self._pane_position_initialized: + return + try: + width = int(getattr(event, "width", 0)) or self.pane.winfo_width() + if width > 1: + self.pane.sashpos(0, max(360, int(width * 0.4))) + self._pane_position_initialized = True + except tk.TclError: + return + + def _build_details(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(0, weight=1) + notebook = ttk.Notebook(parent) + notebook.grid(row=0, column=0, sticky="nsew") + data_tab = ttk.Frame(notebook, padding=16) + contribution_tab = ttk.Frame(notebook, padding=16) + documents_tab = ttk.Frame(notebook, padding=16) + notebook.add(data_tab, text="Stammdaten") + notebook.add(contribution_tab, text="Beiträge") + notebook.add(documents_tab, text="Dokumente") + + fields = [ + ("Mitgliedsnummer", "member_number"), + ("Vorname", "first_name"), + ("Nachname", "last_name"), + ("E-Mail-Adresse", "email"), + (f"Geburtsdatum ({date_input_hint()})", "birth_date"), + (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), + (f"Mitglied seit ({date_input_hint()})", "membership_started_at"), + ] + for row, (label, key) in enumerate(fields): + variable = tk.StringVar() + self.variables[key] = variable + ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + if key == "birth_date": + birth_row = ttk.Frame(data_tab) + birth_row.grid(row=row, column=1, sticky="ew", pady=5) + birth_row.columnconfigure(0, weight=1) + ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew") + self.age_var = tk.StringVar(value="Alter: —") + ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid( + row=0, column=1, sticky="w", padx=(10, 0) + ) + variable.trace_add( + "write", lambda *_args, source=variable: self.age_var.set(age_label(source.get())) + ) + else: + ttk.Entry(data_tab, textvariable=variable, width=42).grid( + row=row, column=1, sticky="ew", pady=5 + ) + self.variables["status"] = tk.StringVar() + ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Combobox( + data_tab, + textvariable=self.variables["status"], + values=list(STATUS_LABELS), + state="readonly", + width=39, + ).grid(row=len(fields), column=1, sticky="ew", pady=5) + self.variables["notes"] = tk.StringVar() + ttk.Label(data_tab, text="Interne Notiz").grid( + row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12) + ) + ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid( + row=len(fields) + 1, column=1, sticky="ew", pady=5 + ) + data_tab.columnconfigure(1, weight=1) + ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid( + row=len(fields) + 2, column=1, sticky="e", pady=(18, 0) + ) + + contribution_tab.columnconfigure(0, weight=1) + contribution_tab.rowconfigure(1, weight=1) + self.contribution_summary = tk.StringVar() + ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.claims = ttk.Treeview( + contribution_tab, columns=("title", "due", "amount", "status"), show="headings" + ) + for key, title, width in ( + ("title", "Forderung", 220), + ("due", "Fällig", 100), + ("amount", "Betrag", 90), + ("status", "Status", 110), + ): + self.claims.heading(key, text=title) + self.claims.column(key, width=width, anchor="w") + self.claims.grid(row=1, column=0, sticky="nsew") + + documents_tab.columnconfigure(0, weight=1) + documents_tab.rowconfigure(1, weight=1) + ttk.Button(documents_tab, text="Dateiordner öffnen", command=self._open_files).grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.documents = tk.Listbox(documents_tab, borderwidth=0, highlightthickness=0) + self.documents.grid(row=1, column=0, sticky="nsew") + + def _build_timeline(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) + self.timeline = ttk.Treeview( + parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview" + ) + self.timeline.heading("time", text="Zeit") + self.timeline.heading("summary", text="Ereignis") + self.timeline.column("time", width=135, stretch=False) + self.timeline.column("summary", width=320, stretch=True) + self.timeline.grid(row=1, column=0, sticky="nsew") + compose = ttk.Frame(parent) + compose.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + compose.columnconfigure(0, weight=1) + self.comment_var = tk.StringVar() + comment = ttk.Entry(compose, textvariable=self.comment_var) + comment.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + comment.bind("", lambda _event: self._add_comment()) + ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) + + def refresh(self) -> None: + self.member = self.repository.get_member(self.member_id) + self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}") + self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper())) + date_fields = {"birth_date", "accepted_at", "membership_started_at"} + for key, variable in self.variables.items(): + value = getattr(self.member, key) + variable.set(format_date_for_display(value) if key in date_fields else value) + self._refresh_events() + self._refresh_contributions() + self._refresh_documents() + + def _refresh_events(self) -> None: + self.timeline.delete(*self.timeline.get_children()) + try: + events = self.repository.get_events(self.member_id) + except RepositoryError as exc: + messagebox.showerror("Chronik beschädigt", str(exc), parent=self) + return + for event in reversed(events): + self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) + + def _refresh_contributions(self) -> None: + data = self.repository.get_contributions(self.member_id) + self.claims.delete(*self.claims.get_children()) + for claim in data.claims: + self.claims.insert( + "", + "end", + values=( + claim.get("title", "Beitrag"), + claim.get("due_date", ""), + claim.get("amount", ""), + claim.get("status", "open"), + ), + ) + self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") + + def _refresh_documents(self) -> None: + self.documents.delete(0, "end") + root = self.repository.members_root / self.member_id / "files" + for path in sorted(root.rglob("*")): + if path.is_file(): + self.documents.insert("end", str(path.relative_to(root))) + + def _save(self) -> None: + for key, variable in self.variables.items(): + setattr(self.member, key, variable.get().strip()) + try: + self.repository.save_member(self.member) + except RepositoryError as exc: + messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) + return + self.refresh() + self.on_changed() + + def _add_comment(self) -> None: + text = self.comment_var.get().strip() + if not text: + return + self.repository.append_event( + self.member_id, + event_type="board_comment", + summary=text, + actor_type="user", + actor_name="Vorstand", + ) + self.comment_var.set("") + self._refresh_events() + + def _open_files(self) -> None: + path = self.repository.members_root / self.member_id / "files" + if sys.platform == "win32": + subprocess.Popen(["explorer", str(path)]) + elif sys.platform == "darwin": + subprocess.Popen(["open", str(path)]) + else: + subprocess.Popen(["xdg-open", str(path)]) + + +def _format_timestamp(event: Event) -> str: + try: + return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M") + except ValueError: + return event.timestamp[:16] + + +def _event_label(event: Event) -> str: + if event.actor_type == "system": + return f"[AUTO] {event.summary}" + return event.summary diff --git a/src/ccma/ui/monitors.py b/src/ccma/ui/monitors.py new file mode 100644 index 0000000..01603d8 --- /dev/null +++ b/src/ccma/ui/monitors.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import re +import tkinter as tk +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class MonitorBounds: + x: int + y: int + width: int + height: int + primary: bool = False + + @property + def center(self) -> tuple[int, int]: + return self.x + self.width // 2, self.y + self.height // 2 + + def as_tuple(self) -> tuple[int, int, int, int]: + return self.x, self.y, self.width, self.height + + +def available_monitors(root: tk.Misc | None = None) -> list[MonitorBounds]: + try: + from screeninfo import get_monitors + + monitors = [ + MonitorBounds( + int(monitor.x), + int(monitor.y), + int(monitor.width), + int(monitor.height), + bool(getattr(monitor, "is_primary", False)), + ) + for monitor in get_monitors() + ] + if monitors: + return monitors + except Exception: + pass + if root is None: + return [] + return [ + MonitorBounds( + int(root.winfo_vrootx()), + int(root.winfo_vrooty()), + int(root.winfo_vrootwidth()), + int(root.winfo_vrootheight()), + True, + ) + ] + + +def preferred_monitor( + root: tk.Misc, + saved_bounds: tuple[int, int, int, int] | None = None, +) -> MonitorBounds: + monitors = available_monitors(root) + if saved_bounds: + for monitor in monitors: + if monitor.as_tuple() == saved_bounds: + return monitor + return next((monitor for monitor in monitors if monitor.primary), monitors[0]) + + +def monitor_for_geometry(root: tk.Misc, geometry: str) -> MonitorBounds: + monitors = available_monitors(root) + parsed = parse_geometry(geometry) + if not parsed: + return next((monitor for monitor in monitors if monitor.primary), monitors[0]) + width, height, x, y = parsed + center_x, center_y = x + width // 2, y + height // 2 + containing = [ + monitor + for monitor in monitors + if monitor.x <= center_x < monitor.x + monitor.width + and monitor.y <= center_y < monitor.y + monitor.height + ] + if containing: + return containing[0] + return min(monitors, key=lambda monitor: _distance_squared(monitor.center, (center_x, center_y))) + + +def centered_geometry(width: int, height: int, monitor: MonitorBounds) -> str: + fitted_width = min(width, max(640, monitor.width - 80)) + fitted_height = min(height, max(480, monitor.height - 80)) + x = monitor.x + (monitor.width - fitted_width) // 2 + y = monitor.y + (monitor.height - fitted_height) // 2 + return format_geometry(fitted_width, fitted_height, x, y) + + +def ensure_visible_geometry(geometry: str, monitor: MonitorBounds) -> str: + parsed = parse_geometry(geometry) + if not parsed: + return centered_geometry(1500, 860, monitor) + width, height, x, y = parsed + width = min(width, max(640, monitor.width - 40)) + height = min(height, max(480, monitor.height - 40)) + x = min(max(x, monitor.x), monitor.x + monitor.width - width) + y = min(max(y, monitor.y), monitor.y + monitor.height - height) + return format_geometry(width, height, x, y) + + +def parse_geometry(geometry: str) -> tuple[int, int, int, int] | None: + match = re.fullmatch(r"(\d+)x(\d+)([+-]\d+)([+-]\d+)", geometry.strip()) + if not match: + return None + return tuple(int(value) for value in match.groups()) # type: ignore[return-value] + + +def format_geometry(width: int, height: int, x: int, y: int) -> str: + return f"{width}x{height}{x:+d}{y:+d}" + + +def _distance_squared(left: tuple[int, int], right: tuple[int, int]) -> int: + return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py new file mode 100644 index 0000000..63746b5 --- /dev/null +++ b/src/ccma/ui/options_dialog.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from ccma.config import AppConfig +from ccma.services.intervals import ( + IntervalValidationError, + normalize_anniversary_intervals, +) +from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern +from ccma.ui.changelog_view import ChangelogView +from ccma.ui.icons import IconStore + + +class OptionsDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + config: AppConfig, + repository: MemberRepository, + on_saved: Callable[[bool], None] | None = None, + ): + super().__init__(master) + self.config_obj = config + self.repository = repository + self.on_saved = on_saved + self.icons = IconStore(self) + self.store_var = tk.StringVar(value=config.store_path) + self.gnucash_var = tk.StringVar(value=config.gnucash_path) + self.theme_var = tk.StringVar(value=config.theme_mode) + self.housekeeper_var = tk.BooleanVar(value=config.run_housekeeper_on_startup) + self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before)) + self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after)) + self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before)) + self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after)) + self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals) + number_policy = repository.get_member_number_policy() + self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual") + self.number_pattern_var = tk.StringVar(value=number_policy["pattern"]) + self.number_preview_var = tk.StringVar() + self.title("Optionen") + self.geometry("900x650") + self.minsize(760, 540) + self.transient(master.winfo_toplevel()) + self.grab_set() + self.resizable(True, True) + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.after_idle(self._center_on_parent) + + def _build_ui(self) -> None: + root = ttk.Frame(self, padding=16) + root.grid(row=0, column=0, sticky="nsew") + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + + notebook = ttk.Notebook(root) + notebook.grid(row=0, column=0, sticky="nsew") + paths = ttk.Frame(notebook, padding=16) + appearance = ttk.Frame(notebook, padding=16) + member_numbers = ttk.Frame(notebook, padding=16) + automation = ttk.Frame(notebook, padding=16) + changelog = ChangelogView(notebook) + notebook.add(paths, text="Pfade") + notebook.add(appearance, text="Darstellung") + notebook.add(member_numbers, text="Mitgliedsnummern") + notebook.add(automation, text="Hausmeister") + notebook.add(changelog, text="Changelog") + self._build_paths(paths) + self._build_appearance(appearance) + self._build_member_numbers(member_numbers) + self._build_automation(automation) + + buttons = ttk.Frame(root) + buttons.grid(row=1, column=0, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + save_icon = self.icons.get("content-save", 16) + save = ttk.Button( + buttons, + text="Speichern", + image=save_icon, + compound="left", + style="Accent.TButton", + command=self._save, + ) + save.image = save_icon + save.pack(side="left") + + def _build_paths(self, parent: ttk.Frame) -> None: + parent.columnconfigure(1, weight=1) + folder_icon = self.icons.get("folder-open", 18) + file_icon = self.icons.get("file-search", 18) or folder_icon + + ttk.Label(parent, text="Mitglieder-Store").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6) + ttk.Entry(parent, textvariable=self.store_var, width=62).grid(row=0, column=1, sticky="ew", pady=6) + browse_store = ttk.Button( + parent, + text="Auswählen", + image=folder_icon, + compound="left", + command=self._choose_store, + ) + browse_store.image = folder_icon + browse_store.grid(row=0, column=2, padx=(8, 0), pady=6) + ttk.Label( + parent, + text="Ein Wechsel wird nach einem Neustart aktiv. Neue Verzeichnisse können angelegt werden.", + style="Muted.TLabel", + wraplength=620, + ).grid(row=1, column=1, columnspan=2, sticky="w", pady=(0, 12)) + + ttk.Label(parent, text="GnuCash-Datei").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=6) + ttk.Entry(parent, textvariable=self.gnucash_var).grid(row=2, column=1, sticky="ew", pady=6) + browse_gnucash = ttk.Button( + parent, + text="Auswählen", + image=file_icon, + compound="left", + command=self._choose_gnucash, + ) + browse_gnucash.image = file_icon + browse_gnucash.grid(row=2, column=2, padx=(8, 0), pady=6) + ttk.Label( + parent, + text="Optional. Die Datei wird zukünftig ausschließlich lesend eingebunden.", + style="Muted.TLabel", + ).grid(row=3, column=1, columnspan=2, sticky="w") + + def _build_appearance(self, parent: ttk.Frame) -> None: + parent.columnconfigure(1, weight=1) + ttk.Label(parent, text="Farbschema").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6) + ttk.Combobox( + parent, + textvariable=self.theme_var, + values=("dark", "light"), + state="readonly", + width=18, + ).grid(row=0, column=1, sticky="w", pady=6) + ttk.Label( + parent, + text="Das Theme wird direkt nach dem Speichern umgeschaltet.", + style="Muted.TLabel", + ).grid(row=1, column=1, sticky="w") + + def _build_automation(self, parent: ttk.Frame) -> None: + parent.columnconfigure(1, weight=1) + ttk.Checkbutton( + parent, + text="Hausmeister beim Programmstart ausführen", + variable=self.housekeeper_var, + style="Switch", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=6) + ttk.Label( + parent, + text="Der Lauf prüft nur und führt keine Mahnungen oder Statusänderungen automatisch aus.", + style="Muted.TLabel", + wraplength=620, + ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 18)) + + ttk.Label(parent, text="Geburtstage melden").grid(row=2, column=0, sticky="w", pady=6) + birthday_window = ttk.Frame(parent) + birthday_window.grid(row=2, column=1, sticky="w", pady=6) + ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_before_var, width=5).pack( + side="left" + ) + ttk.Label(birthday_window, text="Tage vorher / ").pack(side="left", padx=(6, 0)) + ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_after_var, width=5).pack( + side="left" + ) + ttk.Label(birthday_window, text="Tage nachher").pack(side="left", padx=(6, 0)) + + ttk.Label(parent, text="Jubiläen melden").grid(row=3, column=0, sticky="w", pady=6) + anniversary_window = ttk.Frame(parent) + anniversary_window.grid(row=3, column=1, sticky="w", pady=6) + ttk.Spinbox( + anniversary_window, + from_=0, + to=365, + textvariable=self.anniversary_before_var, + width=5, + ).pack(side="left") + ttk.Label(anniversary_window, text="Tage vorher / ").pack(side="left", padx=(6, 0)) + ttk.Spinbox( + anniversary_window, + from_=0, + to=365, + textvariable=self.anniversary_after_var, + width=5, + ).pack(side="left") + ttk.Label(anniversary_window, text="Tage nachher").pack(side="left", padx=(6, 0)) + + ttk.Label(parent, text="Jubiläumsintervalle").grid(row=4, column=0, sticky="w", pady=6) + ttk.Entry(parent, textvariable=self.anniversary_intervals_var).grid( + row=4, column=1, sticky="ew", pady=6 + ) + ttk.Label( + parent, + text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.", + style="Muted.TLabel", + ).grid(row=5, column=1, sticky="w") + + def _build_member_numbers(self, parent: ttk.Frame) -> None: + parent.columnconfigure(1, weight=1) + ttk.Checkbutton( + parent, + text="Mitgliedsnummern manuell angeben", + variable=self.manual_numbers_var, + style="Switch", + command=self._update_member_number_controls, + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 16)) + ttk.Label(parent, text="Automatisches Pattern").grid( + row=1, column=0, sticky="w", padx=(0, 12), pady=6 + ) + self.pattern_entry = ttk.Entry(parent, textvariable=self.number_pattern_var, width=36) + self.pattern_entry.grid(row=1, column=1, sticky="ew", pady=6) + ttk.Label( + parent, + text="Platzhalter: {number}, {number:04d} und optional {year}", + style="Muted.TLabel", + ).grid(row=2, column=1, sticky="w", pady=(0, 12)) + ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6) + ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid( + row=3, column=1, sticky="w", pady=6 + ) + ttk.Label( + parent, + text=( + "Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern " + "werden niemals doppelt erzeugt." + ), + style="Muted.TLabel", + wraplength=620, + ).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0)) + self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview()) + self._update_member_number_controls() + + def _update_member_number_controls(self) -> None: + if self.manual_numbers_var.get(): + self.pattern_entry.state(["disabled"]) + self.number_preview_var.set("MANUELLE EINGABE") + else: + self.pattern_entry.state(["!disabled"]) + self._update_number_preview() + + def _update_number_preview(self) -> None: + if self.manual_numbers_var.get(): + return + pattern = self.number_pattern_var.get() + try: + preview = self.repository.preview_member_number(pattern) + except RepositoryError as exc: + preview = f"UNGÜLTIG: {exc}" + self.number_preview_var.set(preview) + + def _choose_store(self) -> None: + current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home() + initial = current if current.is_dir() else current.parent + selected = filedialog.askdirectory( + parent=self, + title="Mitglieder-Store auswählen oder anlegen", + initialdir=str(initial), + mustexist=False, + ) + if selected: + self.store_var.set(selected) + + def _choose_gnucash(self) -> None: + current = Path(self.gnucash_var.get()).expanduser() if self.gnucash_var.get().strip() else Path.home() + initial = current.parent if current.suffix else current + selected = filedialog.askopenfilename( + parent=self, + title="GnuCash-Datei auswählen", + initialdir=str(initial), + filetypes=( + ("GnuCash-Dateien", "*.gnucash *.xac"), + ("Alle Dateien", "*"), + ), + ) + if selected: + self.gnucash_var.set(selected) + + def _save(self) -> None: + number_mode = "manual" if self.manual_numbers_var.get() else "automatic" + number_pattern = self.number_pattern_var.get().strip() + try: + validate_member_number_pattern(number_pattern) + except RepositoryError as exc: + messagebox.showerror("Ungültiges Mitgliedsnummern-Pattern", str(exc), parent=self) + return + try: + birthday_before = _parse_day_window(self.birthday_before_var.get(), "Geburtstage vorher") + birthday_after = _parse_day_window(self.birthday_after_var.get(), "Geburtstage nachher") + anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher") + anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher") + anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get()) + except (ValueError, IntervalValidationError) as exc: + messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self) + return + store_text = self.store_var.get().strip() + if not store_text: + messagebox.showerror( + "Ungültige Einstellung", "Ein Mitglieder-Store ist erforderlich.", parent=self + ) + return + store = Path(store_text).expanduser().resolve() + if store.exists() and not store.is_dir(): + messagebox.showerror( + "Ungültiger Store", "Der Mitglieder-Store ist kein Verzeichnis.", parent=self + ) + return + if not store.exists(): + create = messagebox.askyesno( + "Store anlegen", + f"Das Verzeichnis existiert noch nicht:\n\n{store}\n\nJetzt anlegen?", + parent=self, + ) + if not create: + return + try: + store.mkdir(parents=True) + except OSError as exc: + messagebox.showerror("Store konnte nicht angelegt werden", str(exc), parent=self) + return + + gnucash_text = self.gnucash_var.get().strip() + gnucash = Path(gnucash_text).expanduser().resolve() if gnucash_text else None + if gnucash and not gnucash.is_file(): + messagebox.showerror( + "Ungültige GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self + ) + return + + old_store = Path(self.config_obj.store_path).expanduser().resolve() + store_changed = old_store != store + self.config_obj.store_path = str(store) + self.config_obj.gnucash_path = str(gnucash) if gnucash else "" + self.config_obj.theme_mode = self.theme_var.get() + self.config_obj.run_housekeeper_on_startup = self.housekeeper_var.get() + self.config_obj.birthday_days_before = birthday_before + self.config_obj.birthday_days_after = birthday_after + self.config_obj.anniversary_days_before = anniversary_before + self.config_obj.anniversary_days_after = anniversary_after + self.config_obj.anniversary_intervals = anniversary_intervals + try: + self.config_obj.save() + self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern) + except (OSError, RepositoryError) as exc: + messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self) + return + if self.on_saved: + self.on_saved(store_changed) + self.destroy() + + def _center_on_parent(self) -> None: + self.update_idletasks() + parent = self.master.winfo_toplevel() + x = parent.winfo_rootx() + max(0, (parent.winfo_width() - self.winfo_width()) // 2) + y = parent.winfo_rooty() + max(0, (parent.winfo_height() - self.winfo_height()) // 2) + self.geometry(f"+{x}+{y}") + + +def _parse_day_window(value: str, label: str) -> int: + try: + parsed = int(value.strip()) + except ValueError as exc: + raise ValueError(f"{label} muss eine ganze Zahl sein.") from exc + if not 0 <= parsed <= 365: + raise ValueError(f"{label} muss zwischen 0 und 365 Tagen liegen.") + return parsed diff --git a/src/ccma/ui/splash.py b/src/ccma/ui/splash.py new file mode 100644 index 0000000..052ce76 --- /dev/null +++ b/src/ccma/ui/splash.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import threading +import tkinter as tk +from collections.abc import Callable +from dataclasses import dataclass +from queue import Empty, Queue +from tkinter import ttk + +from ccma import __version__ +from ccma.domain.models import HousekeeperFinding +from ccma.services.housekeeper import Housekeeper, HousekeeperSettings +from ccma.storage.repository import MemberRepository +from ccma.ui.monitors import MonitorBounds, preferred_monitor + + +@dataclass(slots=True) +class StartupResult: + repository: MemberRepository + validation_errors: list[str] + findings: list[HousekeeperFinding] + + +class SplashScreen(tk.Toplevel): + def __init__( + self, + master: tk.Tk, + repository: MemberRepository, + on_complete: Callable[[StartupResult], None], + on_error: Callable[[Exception], None], + *, + run_housekeeper: bool = True, + monitor: MonitorBounds | None = None, + housekeeper_settings: HousekeeperSettings | None = None, + ): + super().__init__(master) + self.repository = repository + self.on_complete = on_complete + self.on_error = on_error + self.run_housekeeper = run_housekeeper + self.monitor = monitor + self.housekeeper_settings = housekeeper_settings + self._messages: Queue[tuple[str, object]] = Queue() + self.overrideredirect(True) + self.resizable(False, False) + self.configure(background="#0b1117") + self.canvas: tk.Canvas + self.status_item: int + self._build_ui() + self._center() + self.after(120, self._start) + + def _build_ui(self) -> None: + width, height = 620, 330 + self.geometry(f"{width}x{height}") + self.canvas = tk.Canvas( + self, + width=width, + height=height, + highlightthickness=0, + background="#0b1117", + ) + self.canvas.pack(fill="both", expand=True) + self.canvas.create_rectangle(0, 0, width, 6, fill="#00d084", outline="") + self.canvas.create_text( + 34, + 58, + anchor="w", + text="CCMA", + fill="#00d084", + font=("TkFixedFont", 30, "bold"), + ) + self.canvas.create_text( + 34, + 101, + anchor="w", + text="CHAOTIC CREATURE MEMBER ADMINISTRATION", + fill="#d7e0e8", + font=("TkFixedFont", 12, "bold"), + ) + self.canvas.create_text( + 34, + 139, + anchor="w", + text="Chaos Computer Club Mannheim e.V.", + fill="#7f8e9b", + font=("TkDefaultFont", 10), + ) + self.canvas.create_text( + width - 34, 58, anchor="e", text=f"v{__version__}", fill="#7f8e9b", font=("TkFixedFont", 10) + ) + self.status_item = self.canvas.create_text( + 34, + 245, + anchor="w", + text="Initialisiere …", + fill="#d7e0e8", + font=("TkFixedFont", 10), + ) + self.progress = ttk.Progressbar(self, mode="indeterminate") + self.canvas.create_window(34, 280, anchor="w", width=552, window=self.progress) + + def _center(self) -> None: + self.update_idletasks() + width, height = self.winfo_width(), self.winfo_height() + monitor = self.monitor or preferred_monitor(self) + pointer_x, pointer_y = monitor.center + x, y = centered_position( + width=width, + height=height, + pointer_x=pointer_x, + pointer_y=pointer_y, + screen_x=monitor.x, + screen_y=monitor.y, + screen_width=monitor.width, + screen_height=monitor.height, + ) + self.geometry(f"{width}x{height}+{x}+{y}") + + def _start(self) -> None: + self.progress.start(10) + + def worker() -> None: + try: + self._messages.put(("status", "Öffne Mitglieder-Store …")) + self.repository.initialize() + self._messages.put(("status", "Validiere Mitgliederakten …")) + errors = self.repository.validate() + self._messages.put(("status", "Baue Suchindex …")) + self.repository.list_members() + findings = [] + if self.run_housekeeper: + self._messages.put(("status", "Starte Hausmeister …")) + findings = Housekeeper(self.repository, self.housekeeper_settings).run() + result = StartupResult(self.repository, errors, findings) + self._messages.put(("result", result)) + except Exception as exc: + self._messages.put(("error", exc)) + + threading.Thread(target=worker, name="ccma-startup", daemon=True).start() + self.after(30, self._poll_messages) + + def _poll_messages(self) -> None: + keep_polling = True + try: + while True: + kind, payload = self._messages.get_nowait() + if kind == "status": + self._set_status(str(payload)) + elif kind == "result": + keep_polling = False + self._finish(payload) # type: ignore[arg-type] + elif kind == "error": + keep_polling = False + self._fail(payload) # type: ignore[arg-type] + except Empty: + pass + if keep_polling and self.winfo_exists(): + self.after(30, self._poll_messages) + + def _finish(self, result: StartupResult) -> None: + self.progress.stop() + self._set_status(f"Bereit · {len(result.findings)} Vorgänge benötigen Aufmerksamkeit") + self.after(350, lambda: self._complete(result)) + + def _set_status(self, text: str) -> None: + self.canvas.itemconfigure(self.status_item, text=text) + + def _complete(self, result: StartupResult) -> None: + self.destroy() + self.on_complete(result) + + def _fail(self, error: Exception) -> None: + self.progress.stop() + self.destroy() + self.on_error(error) + + +def centered_position( + *, + width: int, + height: int, + pointer_x: int, + pointer_y: int, + screen_x: int, + screen_y: int, + screen_width: int, + screen_height: int, +) -> tuple[int, int]: + """Center around the pointer and keep the complete window on the virtual desktop.""" + maximum_x = screen_x + max(0, screen_width - width) + maximum_y = screen_y + max(0, screen_height - height) + x = min(max(pointer_x - width // 2, screen_x), maximum_x) + y = min(max(pointer_y - height // 2, screen_y), maximum_y) + return x, y diff --git a/src/ccma/ui/theme.py b/src/ccma/ui/theme.py new file mode 100644 index 0000000..da3e929 --- /dev/null +++ b/src/ccma/ui/theme.py @@ -0,0 +1,77 @@ +from pathlib import Path +from tkinter import TclError, ttk + + +def load_theme(root, mode: str) -> str: + style = ttk.Style(root) + variant = "light" if mode == "light" else "dark" + theme_name = f"forest-{variant}" + theme_path = ( + Path(__file__).resolve().parent.parent / "assets" / "themes" / "forest" / f"forest-{variant}.tcl" + ) + if theme_path.exists(): + try: + if theme_name not in style.theme_names(): + root.tk.call("source", str(theme_path)) + style.theme_use(theme_name) + _configure_ccma_styles(style, variant) + return theme_name + except TclError: + pass + style.theme_use("clam") + _configure_fallback(style, variant) + _configure_ccma_styles(style, variant) + return "clam" + + +def _configure_fallback(style: ttk.Style, variant: str) -> None: + dark = variant == "dark" + background = "#14181f" if dark else "#f4f6f8" + surface = "#20262f" if dark else "#ffffff" + foreground = "#e6edf3" if dark else "#20252b" + accent = "#00a884" if dark else "#087f5b" + style.configure(".", background=background, foreground=foreground) + style.configure("TFrame", background=background) + style.configure("TLabel", background=background, foreground=foreground) + style.configure("TButton", background=surface, foreground=foreground) + style.configure("TEntry", fieldbackground=surface, foreground=foreground) + style.configure("Treeview", background=surface, fieldbackground=surface, foreground=foreground) + style.map("Treeview", background=[("selected", accent)], foreground=[("selected", "#ffffff")]) + + +def _configure_ccma_styles(style: ttk.Style, variant: str) -> None: + dark = variant == "dark" + background = "#1b1f23" if dark else "#ffffff" + foreground = "#f0f4f8" if dark else "#202124" + muted = "#9aa4ad" if dark else "#5f6368" + accent = "#00d084" if dark else "#087f5b" + warning = "#ffb454" + danger = "#ff6b6b" + style.configure("Ribbon.TFrame", padding=(12, 9)) + style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold")) + style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold")) + style.configure("Mono.TLabel", font=("TkFixedFont", 10), foreground=muted) + style.configure("Muted.TLabel", foreground=muted) + style.configure("Status.TLabel", font=("TkFixedFont", 9), foreground=muted) + style.configure("TimelineHeader.TLabel", font=("TkFixedFont", 11, "bold"), foreground=accent) + style.configure("Error.TLabel", foreground=danger) + style.configure("Warning.TLabel", foreground=warning) + style.configure("Accent.TButton", font=("TkDefaultFont", 10, "bold")) + style.configure("Card.TFrame", background=background, relief="solid", borderwidth=1) + style.configure("CardTitle.TLabel", background=background, foreground=muted, font=("TkFixedFont", 10)) + style.configure( + "CardValue.TLabel", background=background, foreground=foreground, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "CardError.TLabel", background=background, foreground=danger, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "CardWarning.TLabel", background=background, foreground=warning, font=("TkDefaultFont", 15, "bold") + ) + style.configure( + "Timeline.Treeview", + rowheight=42, + background=background, + fieldbackground=background, + foreground=foreground, + ) diff --git a/src/ccma/ui/window_state.py b/src/ccma/ui/window_state.py new file mode 100644 index 0000000..d9874ea --- /dev/null +++ b/src/ccma/ui/window_state.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import tkinter as tk + + +def is_maximized(window: tk.Tk) -> bool: + try: + if window.state() == "zoomed": + return True + except tk.TclError: + pass + try: + return bool(int(window.attributes("-zoomed"))) + except (tk.TclError, TypeError, ValueError): + return False + + +def maximize(window: tk.Tk) -> bool: + try: + window.state("zoomed") + if is_maximized(window): + return True + except tk.TclError: + pass + try: + window.attributes("-zoomed", True) + return bool(int(window.attributes("-zoomed"))) + except (tk.TclError, TypeError, ValueError): + return False diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py new file mode 100644 index 0000000..aeb91a8 --- /dev/null +++ b/src/ccma/ui/work_tabs.py @@ -0,0 +1,248 @@ +import tkinter as tk +from collections import Counter +from collections.abc import Callable +from tkinter import ttk + +from ccma.domain.dates import format_date_for_display +from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member + + +class DashboardTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + member_count: int, + findings: list[HousekeeperFinding], + on_housekeeper: Callable[[], None], + ): + super().__init__(master, padding=24) + self.member_count = member_count + self.findings = findings + self.on_housekeeper = on_housekeeper + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + ttk.Label(self, text="SYSTEM OVERVIEW", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + ttk.Label(self, text="Mitgliederverwaltung · lokaler File-Store", style="Mono.TLabel").grid( + row=1, column=0, sticky="w", pady=(3, 22) + ) + cards = ttk.Frame(self) + cards.grid(row=2, column=0, sticky="ew") + counts = Counter(finding.severity for finding in self.findings) + values = [ + ("MITGLIEDER", str(self.member_count), ""), + ("ACTION REQUIRED", str(counts["error"]), "CardError.TLabel"), + ("DUE SOON", str(counts["warning"] + counts["info"]), "CardWarning.TLabel"), + ("DATA INTEGRITY", "OK", ""), + ] + for column, (label, value, style) in enumerate(values): + card = ttk.Frame(cards, style="Card.TFrame", padding=18) + card.grid(row=0, column=column, sticky="nsew", padx=(0, 10)) + cards.columnconfigure(column, weight=1) + ttk.Label(card, text=label, style="CardTitle.TLabel").pack(anchor="w") + ttk.Label(card, text=value, style=style or "CardValue.TLabel").pack(anchor="w", pady=(8, 0)) + ttk.Button(self, text="Hausmeister öffnen", style="Accent.TButton", command=self.on_housekeeper).grid( + row=3, column=0, sticky="w", pady=(24, 0) + ) + + def update_data(self, member_count: int, findings: list[HousekeeperFinding]) -> None: + self.member_count = member_count + self.findings = findings + for child in self.winfo_children(): + child.destroy() + self._build_ui() + + +class SearchResultsTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + query: str, + members: list[Member], + on_open: Callable[[str], None], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.query = query + self.members = members + self.on_open = on_open + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text=f'Suche: "{self.query}"', style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid( + row=1, column=0, sticky="w" + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) + tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings") + for key, title, width in ( + ("number", "Nummer", 90), + ("name", "Name", 220), + ("email", "E-Mail-Adresse", 260), + ("birth", "Geburtsdatum", 110), + ("status", "Status", 160), + ): + tree.heading(key, text=title) + tree.column(key, width=width, anchor="w") + tree.grid(row=1, column=0, sticky="nsew") + for member in self.members: + tree.insert( + "", + "end", + iid=member.member_id, + values=( + member.member_number, + member.display_name, + member.email, + format_date_for_display(member.birth_date), + MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), + ), + ) + tree.bind("", lambda _event: self._open_selected(tree)) + tree.bind("", lambda _event: self._open_selected(tree)) + + def _open_selected(self, tree: ttk.Treeview) -> None: + selected = tree.selection() + if selected: + self.on_open(selected[0]) + + +class MembersTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + members: list[Member], + on_open: Callable[[str], None], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.members = members + self.on_open = on_open + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + self.count_var = tk.StringVar() + ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) + self.tree = ttk.Treeview( + self, columns=("number", "name", "email", "birth", "status"), show="headings" + ) + for key, title, width in ( + ("number", "Nummer", 110), + ("name", "Name", 230), + ("email", "E-Mail-Adresse", 270), + ("birth", "Geburtsdatum", 120), + ("status", "Status", 170), + ): + self.tree.heading(key, text=title) + self.tree.column(key, width=width, anchor="w") + self.tree.grid(row=1, column=0, sticky="nsew") + self.tree.bind("", lambda _event: self._open_selected()) + self.tree.bind("", lambda _event: self._open_selected()) + self.refresh(self.members) + + def refresh(self, members: list[Member]) -> None: + self.members = members + self.tree.delete(*self.tree.get_children()) + self.count_var.set(f"{len(members)} Mitglieder") + for member in members: + self.tree.insert( + "", + "end", + iid=member.member_id, + values=( + member.member_number, + member.display_name, + member.email, + format_date_for_display(member.birth_date), + MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), + ), + ) + + def _open_selected(self) -> None: + selected = self.tree.selection() + if selected: + self.on_open(selected[0]) + + +class HousekeeperTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + findings: list[HousekeeperFinding], + on_open_member: Callable[[str], None], + on_refresh: Callable[[], list[HousekeeperFinding]], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.findings = findings + self.on_open_member = on_open_member + self.on_refresh = on_refresh + self.on_close = on_close + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label( + header, text="Prüfend, keine Aktionen werden automatisch ausgeführt", style="Mono.TLabel" + ).grid(row=1, column=0, sticky="w") + ttk.Button(header, text="Neu prüfen", command=self.refresh).grid( + row=0, column=1, rowspan=2, padx=(0, 8) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2) + self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings") + for key, title, width in ( + ("severity", "Level", 90), + ("title", "Vorgang", 330), + ("detail", "Details", 390), + ("due", "Fällig", 110), + ): + self.tree.heading(key, text=title) + self.tree.column(key, width=width, anchor="w") + self.tree.grid(row=1, column=0, sticky="nsew") + self.tree.bind("", lambda _event: self._open_selected()) + self._render() + + def refresh(self) -> None: + self.findings = self.on_refresh() + self._render() + + def _render(self) -> None: + self.tree.delete(*self.tree.get_children()) + self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge") + for index, finding in enumerate(self.findings): + self.tree.insert( + "", + "end", + iid=str(index), + values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""), + ) + + def _open_selected(self) -> None: + selected = self.tree.selection() + if selected: + self.on_open_member(self.findings[int(selected[0])].member_id) diff --git a/src/ccma/version.py b/src/ccma/version.py new file mode 100644 index 0000000..b89c6d4 --- /dev/null +++ b/src/ccma/version.py @@ -0,0 +1,31 @@ +from importlib.metadata import PackageNotFoundError, version +from importlib.resources import files +from pathlib import Path + + +def _checkout_version() -> str | None: + current = Path(__file__).resolve() + for parent in current.parents: + candidate = parent / "VERSION" + if candidate.is_file(): + value = candidate.read_text(encoding="utf-8").strip() + if value: + return value + return None + + +def get_version() -> str: + """Return the checkout VERSION, or installed package metadata as fallback.""" + checkout = _checkout_version() + if checkout: + return checkout + try: + bundled = files("ccma").joinpath("VERSION").read_text(encoding="utf-8").strip() + if bundled: + return bundled + except (FileNotFoundError, ModuleNotFoundError): + pass + try: + return version("ccma") + except PackageNotFoundError: + return "0+unknown" diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..53cca0a --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,9 @@ +from ccma import __version__ +from ccma.ui.changelog_view import load_changelog + + +def test_changelog_contains_current_version() -> None: + entries = load_changelog() + assert entries + assert entries[0]["version"] == __version__ + assert entries[0]["changes"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3523b2a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,37 @@ +import json + +from ccma.config import AppConfig, load_config + + +def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config")) + expected = AppConfig( + store_path=str(tmp_path / "members"), + gnucash_path=str(tmp_path / "club.gnucash"), + theme_mode="light", + run_housekeeper_on_startup=False, + birthday_days_before=10, + birthday_days_after=3, + anniversary_days_before=21, + anniversary_days_after=5, + anniversary_intervals="30D;2M;1Y;10Y", + window_geometry="1200x800-1800+40", + window_state="maximized", + monitor_bounds=(-1920, 0, 1920, 1080), + ) + expected.save() + + loaded = load_config() + assert loaded == expected + raw = json.loads(expected.path.read_text(encoding="utf-8")) + assert raw["schema_version"] == 1 + assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080] + + +def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("CCMA_CONFIG_DIR", raising=False) + monkeypatch.delenv("CCMA_STORE", raising=False) + monkeypatch.setenv("C3MA_CONFIG_DIR", str(tmp_path / "legacy-config")) + monkeypatch.setenv("C3MA_STORE", str(tmp_path / "legacy-store")) + + assert load_config().store_path == str(tmp_path / "legacy-store") diff --git a/tests/test_dates.py b/tests/test_dates.py new file mode 100644 index 0000000..fea9d34 --- /dev/null +++ b/tests/test_dates.py @@ -0,0 +1,63 @@ +from datetime import date + +import pytest + +from ccma.domain.dates import ( + DateValidationError, + age_label, + calculate_age, + format_date_for_display, + normalize_date_input, + parse_date_input, + parse_iso_date, + validate_birth_date, + validate_member_dates, +) + + +def test_iso_dates_are_strict_and_real() -> None: + assert parse_iso_date("2024-02-29", "Datum") == date(2024, 2, 29) + for value in ("29.02.2024", "2024-2-29", "2023-02-29", "irgendwas"): + with pytest.raises(DateValidationError): + parse_iso_date(value, "Datum") + + +def test_date_input_accepts_german_and_iso_formats() -> None: + expected = date(2024, 2, 29) + assert parse_date_input("29.02.2024", "Datum") == expected + assert parse_date_input("2024-02-29", "Datum") == expected + assert normalize_date_input("29.02.2024", "Datum") == "2024-02-29" + + +def test_date_display_uses_system_pattern(monkeypatch) -> None: + monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y") + assert format_date_for_display("2024-02-29") == "29.02.2024" + monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d") + assert format_date_for_display("2024-02-29") == "2024-02-29" + + +def test_birth_date_checks_future_and_plausibility() -> None: + today = date(2026, 6, 21) + assert validate_birth_date("2000-06-22", today=today) == date(2000, 6, 22) + with pytest.raises(DateValidationError, match="Zukunft"): + validate_birth_date("2026-06-22", today=today) + with pytest.raises(DateValidationError, match="120"): + validate_birth_date("1900-01-01", today=today) + + +def test_member_dates_must_be_chronological() -> None: + with pytest.raises(DateValidationError, match="Aufnahmebeschluss"): + validate_member_dates( + birth_date="2000-01-01", + accepted_at="2020-01-02", + membership_started_at="2020-01-01", + today=date(2026, 6, 21), + ) + + +def test_age_calculation_and_label() -> None: + today = date(2026, 6, 21) + assert calculate_age(date(2000, 6, 21), today) == 26 + assert calculate_age(date(2000, 6, 22), today) == 25 + assert age_label("2000-06-21", today=today) == "Alter: 26 Jahre" + assert age_label("nein", today=today) == "UNGÜLTIGES DATUM" diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py new file mode 100644 index 0000000..e6b548b --- /dev/null +++ b/tests/test_housekeeper.py @@ -0,0 +1,93 @@ +from datetime import date + +from ccma.domain.models import ContributionData +from ccma.services.housekeeper import Housekeeper, HousekeeperSettings +from ccma.storage.repository import MemberRepository + + +def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Test", last_name="Person") + member.status = "accepted_pending_payment" + member.accepted_at = "2026-01-01" + repository.save_member(member) + repository.save_contributions( + member.member_id, + ContributionData( + claims=[ + { + "claim_id": "claim-1", + "title": "Mitgliedsbeitrag 2026", + "amount": "150.00", + "due_date": "2026-01-31", + "status": "open", + } + ] + ), + ) + + findings = Housekeeper(repository).run(today=date(2026, 2, 10)) + assert {finding.code for finding in findings} == {"initial_payment_overdue", "claim_overdue"} + + +def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + dates = ("1990-06-20", "1990-06-21", "1990-06-22") + for index, birth_date in enumerate(dates): + member = repository.create_member( + first_name=f"Birthday{index}", + last_name="Member", + birth_date=birth_date, + ) + member.status = "active" + repository.save_member(member) + settings = HousekeeperSettings.from_values( + birthday_days_before=2, + birthday_days_after=2, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + ) + + findings = [ + finding + for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + if finding.code == "birthday" + ] + assert {finding.title for finding in findings} == { + "Birthday0 Member hatte vor 1 Tag Geburtstag", + "Birthday1 Member hat heute Geburtstag", + "Birthday2 Member hat in 1 Tag Geburtstag", + } + + +def test_housekeeper_reports_day_month_and_year_anniversaries(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + starts = ("2026-05-22", "2026-04-21", "2025-06-21", "2016-06-22") + for index, started_at in enumerate(starts): + member = repository.create_member(first_name=f"Anniversary{index}", last_name="Member") + member.status = "active" + member.membership_started_at = started_at + repository.save_member(member) + settings = HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=2, + anniversary_days_after=2, + anniversary_intervals="30D;2M;1Y;10Y", + ) + + findings = [ + finding + for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + if finding.code == "membership_anniversary" + ] + assert {finding.title for finding in findings} == { + "Anniversary0 Member hat heute 30-Tage-Mitgliedsjubiläum", + "Anniversary1 Member hat heute 2-Monats-Mitgliedsjubiläum", + "Anniversary2 Member hat heute 1-jähriges Mitgliedsjubiläum", + "Anniversary3 Member hat in 1 Tag 10-jähriges Mitgliedsjubiläum", + } diff --git a/tests/test_intervals.py b/tests/test_intervals.py new file mode 100644 index 0000000..4d3fa01 --- /dev/null +++ b/tests/test_intervals.py @@ -0,0 +1,27 @@ +from datetime import date + +import pytest + +from ccma.services.intervals import ( + IntervalValidationError, + normalize_anniversary_intervals, + parse_anniversary_intervals, +) + + +def test_intervals_accept_commas_semicolons_and_units() -> None: + intervals = parse_anniversary_intervals("30d, 2M;1;10Y;10y") + assert [interval.token for interval in intervals] == ["30D", "2M", "1Y", "10Y"] + assert normalize_anniversary_intervals("30d, 2M;1Y") == "30D;2M;1Y" + + +def test_month_and_year_intervals_use_calendar_arithmetic() -> None: + intervals = parse_anniversary_intervals("1M;1Y") + assert intervals[0].target_date(date(2024, 1, 31)) == date(2024, 2, 29) + assert intervals[1].target_date(date(2024, 2, 29)) == date(2025, 2, 28) + + +@pytest.mark.parametrize("value", ["", "D", "0D", "-1Y", "101Y"]) +def test_invalid_intervals_are_rejected(value) -> None: + with pytest.raises(IntervalValidationError): + parse_anniversary_intervals(value) diff --git a/tests/test_monitors.py b/tests/test_monitors.py new file mode 100644 index 0000000..ec6cf39 --- /dev/null +++ b/tests/test_monitors.py @@ -0,0 +1,13 @@ +from ccma.ui.monitors import MonitorBounds, centered_geometry, ensure_visible_geometry, parse_geometry + + +def test_centered_geometry_supports_monitor_left_of_primary() -> None: + monitor = MonitorBounds(-1920, 0, 1920, 1080) + geometry = centered_geometry(620, 330, monitor) + assert parse_geometry(geometry) == (620, 330, -1270, 375) + + +def test_saved_geometry_is_clamped_to_selected_monitor() -> None: + monitor = MonitorBounds(1920, 0, 1920, 1080) + geometry = ensure_visible_geometry("1500x860-1600+100", monitor) + assert parse_geometry(geometry) == (1500, 860, 1920, 100) diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..4f4a94a --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,143 @@ +import json + +import pytest + +from ccma.storage.repository import ( + MemberRepository, + RepositoryError, + format_member_number, + validate_member_number_pattern, +) + + +def test_repository_creates_transparent_member_record(tmp_path) -> None: + repository = MemberRepository(tmp_path / "store") + repository.initialize() + + member = repository.create_member( + first_name="Ada", + last_name="Lovelace", + email="ada@example.org", + birth_date="1990-12-10", + member_number="0042", + ) + + member_dir = repository.members_root / member.member_id + assert (member_dir / "member.json").is_file() + assert (member_dir / "contributions.json").is_file() + assert (member_dir / "events.jsonl").is_file() + assert (member_dir / "files").is_dir() + assert repository.validate() == [] + + raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8")) + assert raw["person"]["first_name"] == "Ada" + assert raw["schema_version"] == 1 + + +def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member( + first_name="Jörg", + last_name="Müller", + email="joerg.mueller@example.org", + birth_date="1990-04-23", + member_number="C3-007", + ) + + for query in ("Jorg Muller", "mueller@example.org", "C3-007", "23.04.1990"): + assert [result.member_id for result in repository.search(query)] == [member.member_id] + + +def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Alice", last_name="Example", email="old@example.org") + member.email = "new@example.org" + repository.save_member(member) + repository.append_event( + member.member_id, + event_type="board_comment", + summary="Telefonisch erreicht", + actor_type="user", + actor_name="Vorstand", + ) + + events = repository.get_events(member.member_id) + assert [event.event_type for event in events] == [ + "member_created", + "member_data_changed", + "board_comment", + ] + assert "E-Mail-Adresse" in events[1].summary + assert "old@example.org" not in events[1].summary + assert "new@example.org" not in events[1].summary + + +def test_status_change_audit_contains_old_and_new_status(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Status", last_name="Test") + member.status = "active" + repository.save_member(member) + + event = repository.get_events(member.member_id)[-1] + assert event.summary == "Mitgliedsdaten geändert: Status von ANTRAG zu AKTIV" + + +def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Local", last_name="Date", birth_date="31.12.2000") + assert member.birth_date == "2000-12-31" + assert repository.get_member(member.member_id).birth_date == "2000-12-31" + with pytest.raises(RepositoryError, match="gültiges Datum"): + repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000") + + +def test_member_path_rejects_traversal(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + with pytest.raises(RepositoryError): + repository.get_member("../outside") + + +def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + + assert repository.preview_member_number() == "CCMA-0001" + assert repository.preview_member_number() == "CCMA-0001" + first = repository.create_member(first_name="First", last_name="Member") + second = repository.create_member(first_name="Second", last_name="Member") + + assert first.member_number == "CCMA-0001" + assert second.member_number == "CCMA-0002" + assert repository.preview_member_number() == "CCMA-0003" + + +def test_custom_pattern_and_manual_mode(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + repository.save_member_number_policy(mode="automatic", pattern="MA-{year}-{number:03d}") + automatic = repository.create_member(first_name="Auto", last_name="Member") + assert automatic.member_number.startswith("MA-") + assert automatic.member_number.endswith("-001") + + repository.save_member_number_policy(mode="manual", pattern="MA-{number:03d}") + with pytest.raises(RepositoryError, match="erforderlich"): + repository.create_member(first_name="Missing", last_name="Number") + manual = repository.create_member(first_name="Manual", last_name="Member", member_number="SPECIAL-7") + assert manual.member_number == "SPECIAL-7" + with pytest.raises(RepositoryError, match="bereits vergeben"): + repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7") + + +@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"]) +def test_invalid_member_number_patterns_are_rejected(pattern) -> None: + with pytest.raises(RepositoryError): + validate_member_number_pattern(pattern) + + +def test_member_number_formatter_supports_padding_and_year() -> None: + assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042" diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py new file mode 100644 index 0000000..e08d5f8 --- /dev/null +++ b/tests/test_ui_imports.py @@ -0,0 +1,40 @@ +def test_ui_modules_import_without_creating_root_window() -> None: + import ccma.app # noqa: F401 + import ccma.ui.main_window # noqa: F401 + import ccma.ui.member_tab # noqa: F401 + import ccma.ui.splash # noqa: F401 + + +def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None: + from ccma.ui.splash import centered_position + + assert centered_position( + width=620, + height=330, + pointer_x=2500, + pointer_y=600, + screen_x=0, + screen_y=0, + screen_width=3840, + screen_height=1080, + ) == (2190, 435) + assert centered_position( + width=620, + height=330, + pointer_x=10, + pointer_y=10, + screen_x=0, + screen_y=0, + screen_width=1920, + screen_height=1080, + ) == (0, 0) + + +def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None: + from ccma.domain.models import Event + from ccma.ui.member_tab import _event_label + + user_event = Event("1", "2026-01-01T00:00:00+01:00", "comment", "Kommentar", "user", "Vorstand") + system_event = Event("2", "2026-01-01T00:00:00+01:00", "automatic", "Automatisch") + assert _event_label(user_event) == "Kommentar" + assert _event_label(system_event) == "[AUTO] Automatisch" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..f7f28e7 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,8 @@ +from pathlib import Path + +from ccma import __version__ + + +def test_ui_version_matches_version_file() -> None: + expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() + assert __version__ == expected == "0.0.1-dev0"