├── .gitignore ├── .idea ├── .gitignore ├── TkRouter.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── modules.xml └── vcs.xml ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── api.md └── index.md ├── mkdocs.yml ├── pyproject.toml ├── requirements-dev.txt ├── src └── tkrouter │ ├── __init__.py │ ├── create_app.py │ ├── examples │ ├── __init__.py │ ├── admin_console.py │ ├── guarded_routes.py │ ├── minimal_app.py │ └── unified_routing.py │ ├── exceptions.py │ ├── history.py │ ├── router.py │ ├── router_outlet.py │ ├── transitions.py │ ├── types.py │ ├── utils.py │ ├── views.py │ └── widgets.py └── tests ├── __init__.py ├── integration ├── test_exceptions.py ├── test_router.py └── test_widgets.py └── unit ├── test_format_path.py └── test_history.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / cache files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Virtual environment 7 | venv/ 8 | .env/ 9 | .venv/ 10 | 11 | # Distribution / packaging 12 | build/ 13 | dist/ 14 | *.egg-info/ 15 | *.egg 16 | 17 | # Installer logs 18 | pip-log.txt 19 | pip-delete-this-directory.txt 20 | 21 | # Unit test / coverage 22 | htmlcov/ 23 | .tox/ 24 | .nox/ 25 | .coverage 26 | .coverage.* 27 | .cache 28 | nosetests.xml 29 | coverage.xml 30 | *.cover 31 | .pytest_cache/ 32 | 33 | # PyInstaller 34 | *.manifest 35 | *.spec 36 | 37 | # Jupyter Notebook 38 | .ipynb_checkpoints 39 | 40 | # PyCharm & VS Code 41 | .idea/ 42 | .vscode/ 43 | 44 | # MyPy, Pyright, Ruff 45 | .mypy_cache/ 46 | .pyright/ 47 | ruff_cache/ 48 | 49 | # System 50 | .DS_Store 51 | Thumbs.db 52 | 53 | # TkRouter-specific 54 | tkrouter.egg-info/ 55 | 56 | publish.bat -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/TkRouter.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | 13 | # Build documentation with Mkdocs 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | 17 | # Optionally, but recommended, 18 | # declare the Python requirements required to build your documentation 19 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | # python: 21 | # install: 22 | # - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Israel Dryer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include src/tkrouter * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TkRouter 2 | 3 | A declarative routing system for building multi-page **Tkinter** applications with transitions, parameters, guards, and navigation history. 4 | 5 | [Full documentation](https://tkrouter.readthedocs.io) 6 | 7 | [![PyPI](https://img.shields.io/pypi/v/tkrouter)](https://pypi.org/project/tkrouter/) 8 | [![License](https://img.shields.io/github/license/israel-dryer/tkrouter)](https://github.com/israel-dryer/tkrouter/blob/main/LICENSE) 9 | --- 10 | 11 | ## ✨ Features 12 | 13 | * 🔀 Route matching with parameters (e.g., `/users/`) 14 | * ❓ Query string parsing (e.g., `/logs?level=info`) 15 | * 🔄 Animated transitions (slide, fade) 16 | * 🔒 Route guards with optional redirects 17 | * 🧱 Singleton router via `create_router()` / `get_router()` 18 | * 🧭 Navigation history: `.back()`, `.forward()`, `.go()` 19 | * 📢 Route observers with `on_change()` 20 | * 🧩 Routed widgets: `RouteLinkButton`, `RouteLinkLabel` 21 | * 🎨 Works with `tk.Frame` or `ttk.Frame` 22 | 23 | --- 24 | 25 | ## 📦 Installation 26 | 27 | ```bash 28 | pip install tkrouter 29 | ``` 30 | 31 | --- 32 | 33 | ## 🚀 CLI Utilities 34 | 35 | After installation, these command-line scripts become available: 36 | 37 | ```bash 38 | tkrouter-create # Generate a minimal main.py scaffold 39 | tkrouter-demo-minimal # Basic home/about demo 40 | tkrouter-demo-admin # Sidebar layout with query parameters 41 | tkrouter-demo-unified # Flat nested routes with transitions 42 | tkrouter-demo-guarded # Route guards with simulated login 43 | ``` 44 | 45 | --- 46 | 47 | ## Quickstart 48 | 49 | You can start a new Tkinter router-based app in two ways: 50 | 51 | ### 🚀 Option 1: Use the CLI 52 | 53 | ```bash 54 | tkrouter-create 55 | ``` 56 | 57 | This generates a ready-to-run `main.py` file with a minimal working app. 58 | 59 | --- 60 | 61 | ### 🧪 Option 2: Use this snippet 62 | 63 | ```python 64 | from tkinter import Tk 65 | from tkrouter import create_router, get_router, RouterOutlet 66 | from tkrouter.views import RoutedView 67 | from tkrouter.widgets import RouteLinkButton 68 | 69 | class Home(RoutedView): 70 | def __init__(self, master): 71 | super().__init__(master) 72 | RouteLinkButton(self, "/about", text="Go to About").pack() 73 | 74 | class About(RoutedView): 75 | def __init__(self, master): 76 | super().__init__(master) 77 | RouteLinkButton(self, "/", text="Back to Home").pack() 78 | 79 | ROUTES = { 80 | "/": Home, 81 | "/about": About, 82 | } 83 | 84 | root = Tk() 85 | outlet = RouterOutlet(root) 86 | outlet.pack(fill="both", expand=True) 87 | create_router(ROUTES, outlet).navigate("/") 88 | root.mainloop() 89 | ``` 90 | 91 | --- 92 | 93 | ## 🧪 Example Demos 94 | 95 | ```bash 96 | python -m tkrouter.examples.minimal_app 97 | python -m tkrouter.examples.admin_console 98 | python -m tkrouter.examples.unified_routing 99 | python -m tkrouter.examples.guarded_routes 100 | ``` 101 | 102 | | Example | Description | 103 | | ----------------- | -------------------------------------------------------------------- | 104 | | `minimal_app` | Basic Home/About router demo | 105 | | `admin_console` | Sidebar UI with dynamic **routes** and **query parameters** | 106 | | `unified_routing` | Flat-style routing (e.g., `/dashboard/stats`) with slide transitions | 107 | | `guarded_routes` | Route guard demo with simulated login and redirect fallback | 108 | 109 | --- 110 | 111 | ## 📚 API Overview 112 | 113 | ### Router Lifecycle 114 | 115 | ```python 116 | create_router(routes: dict, outlet: RouterOutlet, transition_handler=None) 117 | get_router() -> Router 118 | ``` 119 | 120 | ### Route Config Format 121 | 122 | ```python 123 | { 124 | "/users/": { 125 | "view": UserDetailPage, 126 | "guard": is_logged_in, 127 | "redirect": "/login", 128 | "transition": slide_transition 129 | } 130 | } 131 | ``` 132 | 133 | * Supports **dynamic route parameters** using angle brackets (e.g., ``) 134 | * Supports **query parameters** appended to URLs (e.g., `?tab=settings`) 135 | 136 | ### Transitions 137 | 138 | ```python 139 | from tkrouter.transitions import slide_transition, simple_fade_transition 140 | ``` 141 | 142 | Set globally or per route config. 143 | 144 | ### Routed Widgets 145 | 146 | * `RouteLinkButton(master, to, params=None, **kwargs)` 147 | * `RouteLinkLabel(master, to, params=None, **kwargs)` 148 | * `bind_route(widget, path, params=None)` 149 | * `@with_route(path, params)` — for command binding 150 | 151 | ### Observing Route Changes 152 | 153 | ```python 154 | get_router().on_change(lambda path, params: print("Route changed:", path, params)) 155 | ``` 156 | 157 | --- 158 | 159 | ## ⚠️ Exceptions 160 | 161 | * `RouteNotFoundError` – Raised when no matching route is found 162 | * `NavigationGuardError` – Raised when guard blocks navigation 163 | 164 | --- 165 | 166 | ## ✅ Compatibility 167 | 168 | * Python 3.8 and newer 169 | 170 | --- 171 | 172 | ## 📄 License 173 | 174 | MIT License © [Israel Dryer](https://github.com/israel-dryer/tkrouter) 175 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## 🔧 Router Lifecycle 4 | 5 | ### create_router 6 | 7 | ```python 8 | create_router(routes: dict, outlet: RouterOutlet, transition_handler: Optional[Callable] = None) -> Router 9 | ``` 10 | Creates and registers the global singleton Router instance. 11 | 12 | - `routes`: A dictionary mapping paths to view classes or route configs 13 | - `outlet`: The RouterOutlet container where views render 14 | - `transition_handler`: Optional fallback transition function 15 | 16 | #### Example 17 | 18 | ```python 19 | create_router(ROUTES, outlet) 20 | ``` 21 | 22 | Initializes the global router with your route map and main outlet container. 23 | 24 | --- 25 | 26 | ### get_router 27 | 28 | ```python 29 | get_router() -> Router 30 | ``` 31 | Returns the globally registered Router. 32 | Raises `RuntimeError` if `create_router()` hasn’t been called. 33 | 34 | #### Example 35 | 36 | ```python 37 | router = get_router() 38 | ``` 39 | 40 | Retrieves the global router instance. Useful for navigating or registering listeners. 41 | 42 | --- 43 | 44 | ## 📦 Router Methods 45 | 46 | ### navigate 47 | 48 | ```python 49 | navigate(path: str, transition: Optional[Callable] = None) 50 | ``` 51 | Navigates to a route. Handles parameters, query strings, guards, transitions, and history. 52 | 53 | #### Example 54 | 55 | ```python 56 | get_router().navigate("/users/42?tab=profile") 57 | ``` 58 | 59 | --- 60 | 61 | ### back 62 | 63 | ```python 64 | back() 65 | ``` 66 | Navigates to the previous route in the history stack. 67 | 68 | --- 69 | 70 | ### forward 71 | 72 | ```python 73 | forward() 74 | ``` 75 | Navigates forward in the history stack. 76 | 77 | --- 78 | 79 | ### go 80 | 81 | ```python 82 | go(delta: int) 83 | ``` 84 | Navigates by a relative offset (e.g., -1 to go back, 1 to go forward). 85 | 86 | --- 87 | 88 | ### on_change 89 | 90 | ```python 91 | on_change(callback: Callable[[str, dict], None]) 92 | ``` 93 | Registers a listener to respond to route changes. 94 | 95 | #### Example 96 | 97 | ```python 98 | get_router().on_change(lambda path, params: print("Route changed:", path)) 99 | ``` 100 | 101 | --- 102 | 103 | ## 🗺️ Route Config Format 104 | 105 | ```python 106 | ROUTES = { 107 | "/": HomePage, 108 | "/about": AboutPage, 109 | "/users/": { 110 | "view": UserDetailPage, 111 | "guard": is_logged_in, 112 | "redirect": "/login", 113 | "transition": slide_transition 114 | } 115 | } 116 | ``` 117 | Supports dynamic segments (``), route guards, custom transitions, and redirects. 118 | 119 | --- 120 | 121 | ## 🎛️ Routed Widgets 122 | 123 | ### RouteLinkButton 124 | 125 | ```python 126 | RouteLinkButton(master, to: str, params: dict = None, **kwargs) 127 | ``` 128 | A button that navigates to a route when clicked. 129 | 130 | #### Example 131 | 132 | ```python 133 | RouteLinkButton(self, "/about", text="Go to About") 134 | ``` 135 | 136 | --- 137 | 138 | ### RouteLinkLabel 139 | 140 | ```python 141 | RouteLinkLabel(master, to: str, params: dict = None, **kwargs) 142 | ``` 143 | A label that acts like a hyperlink and navigates on click. 144 | 145 | #### Example 146 | 147 | ```python 148 | RouteLinkLabel(self, "/users/", params={"id": 12}, text="User 12") 149 | ``` 150 | 151 | --- 152 | 153 | ### bind_route 154 | 155 | ```python 156 | bind_route(widget, path: str, params: dict = None) 157 | ``` 158 | Binds navigation logic to any widget with a `command` option. 159 | 160 | #### Example 161 | 162 | ```python 163 | bind_route(my_button, "/settings", params={"tab": "advanced"}) 164 | ``` 165 | 166 | --- 167 | 168 | ### with_route 169 | 170 | ```python 171 | @with_route(path, params) 172 | def handler(): ... 173 | ``` 174 | Decorator that adds route metadata to a function. 175 | 176 | #### Example 177 | 178 | ```python 179 | @with_route("/help") 180 | def open_help(): 181 | ... 182 | ``` 183 | 184 | --- 185 | 186 | ## 🎥 Transitions 187 | 188 | ### slide_transition 189 | 190 | ```python 191 | slide_transition(outlet, view_class, params, duration=300) 192 | ``` 193 | Animates a slide-in transition from the right. 194 | 195 | #### Example 196 | 197 | ```python 198 | from tkrouter.transitions import slide_transition 199 | create_router(ROUTES, outlet, transition_handler=slide_transition) 200 | ``` 201 | 202 | --- 203 | 204 | ### simple_fade_transition 205 | 206 | ```python 207 | simple_fade_transition(outlet, view_class, params, duration=300) 208 | ``` 209 | Fades between views using an overlay. 210 | 211 | #### Example 212 | 213 | ```python 214 | from tkrouter.transitions import simple_fade_transition 215 | ROUTES = { 216 | "/fade": { 217 | "view": FadeView, 218 | "transition": simple_fade_transition 219 | } 220 | } 221 | ``` 222 | 223 | --- 224 | 225 | ## ⚠️ Exceptions 226 | 227 | ### RouteNotFoundError 228 | 229 | Raised when no route matches the requested path. 230 | 231 | ### NavigationGuardError 232 | 233 | Raised when a guard condition blocks access to a route. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # TkRouter 2 | 3 | **TkRouter** is a declarative routing system for building multi-page **Tkinter** applications. It supports parameterized routes, query strings, animated transitions, history navigation, and more. 4 | 5 | ![PyPI](https://img.shields.io/pypi/v/tkrouter) 6 | ![License](https://img.shields.io/github/license/israel-dryer/tkrouter) 7 | 8 | --- 9 | 10 | ## ✨ Features 11 | 12 | - 🔀 Route matching with **parameters** (`/users/`) 13 | - ❓ **Query string** parsing (`/logs?level=debug`) 14 | - 🔄 Animated **transitions** between views (`slide`, `fade`) 15 | - 🧭 Singleton **router instance** (`create_router()`, `get_router()`) 16 | - 🔐 Route **guards** with redirect support 17 | - ⏪ History **navigation** (`.back()`, `.forward()`, `.go()`) 18 | - 🧩 Built-in **widgets**: `RouteLinkButton`, `RouteLinkLabel` 19 | - 🎨 Compatible with both `tk.Frame` and `ttk.Frame` 20 | 21 | --- 22 | 23 | ## 📦 Installation 24 | 25 | ```bash 26 | pip install tkrouter 27 | ``` 28 | 29 | --- 30 | 31 | ## Quickstart 32 | 33 | You can start a new Tkinter router-based app in two ways: 34 | 35 | ### 🚀 Option 1: Use the CLI 36 | 37 | ```bash 38 | tkrouter-create 39 | ``` 40 | 41 | This generates a ready-to-run `main.py` file with a minimal working app. 42 | 43 | --- 44 | 45 | ### 🧪 Option 2: Use this snippet 46 | 47 | ```python 48 | from tkinter import Tk 49 | from tkrouter import create_router, get_router, RouterOutlet 50 | from tkrouter.views import RoutedView 51 | from tkrouter.widgets import RouteLinkButton 52 | 53 | class Home(RoutedView): 54 | def __init__(self, master): 55 | super().__init__(master) 56 | RouteLinkButton(self, "/about", text="Go to About").pack() 57 | 58 | class About(RoutedView): 59 | def __init__(self, master): 60 | super().__init__(master) 61 | RouteLinkButton(self, "/", text="Back to Home").pack() 62 | 63 | ROUTES = { 64 | "/": Home, 65 | "/about": About, 66 | } 67 | 68 | root = Tk() 69 | outlet = RouterOutlet(root) 70 | outlet.pack(fill="both", expand=True) 71 | create_router(ROUTES, outlet).navigate("/") 72 | root.mainloop() 73 | ``` 74 | 75 | --- 76 | 77 | ## 🧪 Examples 78 | 79 | Run these from the terminal (installed via `pip`) or with `python -m tkrouter.examples.NAME`. 80 | 81 | | Script | Description | 82 | |--------------------------|-----------------------------------------------------------------------------| 83 | | `tkrouter-demo-minimal` | Basic two-page example | 84 | | `tkrouter-demo-admin` | Sidebar layout with query param routing (`/logs?level=error`) | 85 | | `tkrouter-demo-unified` | Flat URL routes (`/dashboard/stats`) with transitions | 86 | | `tkrouter-demo-guarded` | Simulated login with protected route and redirect (`/secret → /login`) | 87 | 88 | --- 89 | 90 | ## 🧭 Route Config 91 | 92 | ```python 93 | ROUTES = { 94 | "/": HomePage, 95 | "/users/": { 96 | "view": UserDetailPage, 97 | "transition": slide_transition, 98 | "guard": is_logged_in, 99 | "redirect": "/login" 100 | } 101 | } 102 | ``` 103 | 104 | ✅ Supports: 105 | 106 | - `` dynamic parameters 107 | - `?key=value` query parameters 108 | - Route guards and redirects 109 | - Per-route transitions 110 | 111 | --- 112 | 113 | ## 🧱 Router API 114 | 115 | ```python 116 | from tkrouter import create_router, get_router 117 | 118 | router = create_router(routes, outlet) 119 | router.navigate("/users/123?tab=details") 120 | router.back() 121 | router.on_change(lambda path, params: print(path, params)) 122 | ``` 123 | 124 | --- 125 | 126 | ## 🧩 Routed Widgets 127 | 128 | ```python 129 | from tkrouter.widgets import RouteLinkButton, RouteLinkLabel 130 | 131 | RouteLinkButton(parent, "/dashboard") 132 | RouteLinkLabel(parent, "/users/", params={"id": 3}) 133 | ``` 134 | 135 | Also available: 136 | 137 | - `bind_route(widget, path, params)` 138 | - `@with_route(path, params)` decorator 139 | 140 | --- 141 | 142 | ## 🔄 Transitions 143 | 144 | ```python 145 | from tkrouter.transitions import slide_transition, simple_fade_transition 146 | ``` 147 | 148 | Custom transitions supported — just pass a function like: 149 | 150 | ```python 151 | def my_transition(outlet, view_class, params, duration=300): ... 152 | ``` 153 | 154 | --- 155 | 156 | ## ⚠️ Exceptions 157 | 158 | ```python 159 | from tkrouter.exceptions import RouteNotFoundError, NavigationGuardError 160 | ``` 161 | 162 | --- 163 | 164 | ## ✅ Compatibility 165 | 166 | - Python 3.8+ 167 | 168 | --- 169 | 170 | ## 📄 License 171 | 172 | MIT © [Israel Dryer](https://github.com/israel-dryer/tkrouter) 173 | 174 | --- -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: TkRouter 2 | repo_url: https://github.com/israel-dryer/tkrouter 3 | theme: 4 | name: readthedocs 5 | palette: 6 | primary: blue 7 | accent: indigo 8 | nav: 9 | - Home: index.md 10 | - API Reference: api.md 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "tkrouter" 7 | version = "0.2.0" 8 | description = "Declarative routing and animated navigation for Tkinter." 9 | authors = [{ name = "Israel Dryer", email = "israel.dryer@gmail.com" }] 10 | license = "MIT" 11 | readme = "README.md" 12 | requires-python = ">=3.8" 13 | dependencies = [] 14 | 15 | [tool.setuptools] 16 | package-dir = {"" = "src"} 17 | include-package-data = true 18 | 19 | [tool.setuptools.packages.find] 20 | where = ["src"] 21 | 22 | [project.urls] 23 | "Homepage" = "https://github.com/israel-dryer/tkrouter" 24 | "Source" = "https://github.com/israel-dryer/tkrouter" 25 | "Bug Tracker" = "https://github.com/israel-dryer/tkrouter/issues" 26 | 27 | [project.scripts] 28 | tkrouter-demo-minimal = "tkrouter.examples.minimal_app:run" 29 | tkrouter-demo-admin = "tkrouter.examples.admin_console:run" 30 | tkrouter-demo-unified = "tkrouter.examples.unified_routing:run" 31 | tkrouter-demo-guarded = "tkrouter.examples.guarded_routes:run" 32 | tkrouter-create = "tkrouter.create_app:create_main_py" -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | build 2 | twine 3 | pytest -------------------------------------------------------------------------------- /src/tkrouter/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | tkrouter: A minimal routing system for Tkinter GUI applications. 3 | 4 | This package provides declarative routing for Tkinter apps, including: 5 | 6 | - Route pattern matching with path parameters (``) 7 | - Query parameter support (`/users?tab=info`) 8 | - Optional route guards and redirects 9 | - Animated view transitions 10 | - Routed widgets (`RouteLinkButton`, `RouteLinkLabel`) 11 | - History management (back/forward/go) 12 | - Routed base views (`RoutedView`, `StyledRoutedView`) 13 | 14 | Example: 15 | 16 | ```python 17 | from tkinter import Tk 18 | from tkrouter import ( 19 | create_router, RouterOutlet, RoutedView, 20 | RouteLinkButton, slide_transition 21 | ) 22 | 23 | class Home(RoutedView): 24 | def __init__(self, master): 25 | super().__init__(master) 26 | RouteLinkButton(self, "/about", text="Go to About").pack() 27 | 28 | class About(RoutedView): 29 | def __init__(self, master): 30 | super().__init__(master) 31 | RouteLinkButton(self, "/", text="Back to Home").pack() 32 | 33 | ROUTES = { 34 | "/": Home, 35 | "/about": { 36 | "view": About, 37 | "transition": slide_transition 38 | } 39 | } 40 | 41 | root = Tk() 42 | outlet = RouterOutlet(root) 43 | outlet.pack(fill="both", expand=True) 44 | create_router(ROUTES, outlet).navigate("/") 45 | root.mainloop() 46 | """ 47 | 48 | from .router import Router, create_router, get_router 49 | from .exceptions import RouteNotFoundError, NavigationGuardError 50 | from .router_outlet import RouterOutlet 51 | from .views import RoutedView, StyledRoutedView 52 | from .widgets import RouteLinkButton, RouteLinkLabel, bind_route, with_route 53 | from .transitions import slide_transition, simple_fade_transition 54 | 55 | __all__ = [ 56 | "Router", 57 | "create_router", 58 | "get_router", 59 | "RouteNotFoundError", 60 | "NavigationGuardError", 61 | "RouterOutlet", 62 | "RoutedView", 63 | "StyledRoutedView", 64 | "RouteLinkButton", 65 | "RouteLinkLabel", 66 | "bind_route", 67 | "with_route", 68 | "slide_transition", 69 | "simple_fade_transition", 70 | ] 71 | -------------------------------------------------------------------------------- /src/tkrouter/create_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | MINIMAL_APP_CODE = """import tkinter as tk 4 | from tkinter import ttk 5 | from tkrouter import create_router, get_router 6 | from tkrouter.router_outlet import RouterOutlet 7 | from tkrouter.views import StyledRoutedView 8 | from tkrouter.widgets import RouteLinkButton 9 | 10 | class HomePage(StyledRoutedView): 11 | def __init__(self, master): 12 | super().__init__(master) 13 | ttk.Label(self, text="🏠 Home", font=("Segoe UI", 18)).pack(pady=10) 14 | ttk.Label(self, text="Welcome to your TkRouter app!").pack() 15 | RouteLinkButton(self, "/about", text="Go to About").pack(pady=10) 16 | 17 | class AboutPage(StyledRoutedView): 18 | def __init__(self, master): 19 | super().__init__(master) 20 | ttk.Label(self, text="ℹ️ About", font=("Segoe UI", 18)).pack(pady=10) 21 | ttk.Label(self, text="This is a minimal routed app.").pack() 22 | RouteLinkButton(self, "/", text="Back to Home").pack(pady=10) 23 | 24 | ROUTES = { 25 | "/": HomePage, 26 | "/about": AboutPage, 27 | } 28 | 29 | def main(): 30 | root = tk.Tk() 31 | root.title("My TkRouter App") 32 | root.geometry("400x250") 33 | 34 | style = ttk.Style() 35 | style.theme_use("clam") 36 | 37 | outlet = RouterOutlet(root) 38 | outlet.pack(fill="both", expand=True) 39 | 40 | create_router(ROUTES, outlet) 41 | get_router().navigate("/") 42 | 43 | root.mainloop() 44 | 45 | if __name__ == "__main__": 46 | main() 47 | """ 48 | 49 | def create_main_py(): 50 | if os.path.exists("main.py"): 51 | print("main.py already exists. Aborting.") 52 | return 53 | 54 | with open("main.py", "w", encoding="utf-8") as f: 55 | f.write(MINIMAL_APP_CODE) 56 | print("✅ Created main.py with a minimal TkRouter app.") 57 | 58 | if __name__ == "__main__": 59 | create_main_py() -------------------------------------------------------------------------------- /src/tkrouter/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/israel-dryer/TkRouter/2dc3f4a26cd5048fba69967585ce7dff5c5e9f9c/src/tkrouter/examples/__init__.py -------------------------------------------------------------------------------- /src/tkrouter/examples/admin_console.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from tkrouter import create_router, get_router 4 | from tkrouter.router_outlet import RouterOutlet 5 | from tkrouter.transitions import slide_transition 6 | from tkrouter.widgets import RouteLinkButton 7 | from tkrouter.views import StyledRoutedView 8 | 9 | 10 | class Sidebar(tk.Frame): 11 | def __init__(self, master): 12 | super().__init__(master, bg="#2d2d30", width=140) 13 | self.pack_propagate(False) 14 | ttk.Label(self, text="Admin", background="#2d2d30", foreground="white", font=("Segoe UI", 14)).pack(pady=(20, 10)) 15 | 16 | RouteLinkButton(self, "/", text="Dashboard").pack(fill="x", padx=10, pady=2) 17 | RouteLinkButton(self, "/users", text="Users").pack(fill="x", padx=10, pady=2) 18 | RouteLinkButton(self, "/reports", text="Reports").pack(fill="x", padx=10, pady=2) 19 | 20 | ttk.Label(self, text="Logs", background="#2d2d30", foreground="#ccc").pack(pady=(15, 0)) 21 | RouteLinkButton(self, "/logs", params={"level": "info"}, text="Info Logs").pack(fill="x", padx=10, pady=1) 22 | RouteLinkButton(self, "/logs", params={"level": "error"}, text="Error Logs").pack(fill="x", padx=10, pady=1) 23 | 24 | 25 | class DashboardPage(StyledRoutedView): 26 | def __init__(self, master): 27 | super().__init__(master) 28 | ttk.Label(self, text="Dashboard", font=("Segoe UI", 18)).pack(pady=(20, 10)) 29 | ttk.Label(self, text="Welcome to the admin dashboard.", font=("Segoe UI", 12)).pack() 30 | 31 | 32 | class UsersPage(StyledRoutedView): 33 | def __init__(self, master=None): 34 | super().__init__(master) 35 | ttk.Label(self, text="Users", font=("Segoe UI", 18)).pack(pady=(20, 10)) 36 | for user_id in range(1, 4): 37 | RouteLinkButton(self, "/users/", params={"id": user_id}, text=f"User {user_id}").pack(pady=2) 38 | 39 | 40 | class UserDetailsPage(StyledRoutedView): 41 | def __init__(self, master=None): 42 | super().__init__(master) 43 | self.label = ttk.Label(self, font=("Segoe UI", 14)) 44 | self.label.pack(pady=20) 45 | 46 | def on_navigate(self, params: dict): 47 | self.label.config(text=f"User Details for ID: {params.get('id')}") 48 | 49 | 50 | class ReportsPage(StyledRoutedView): 51 | def __init__(self, master=None): 52 | super().__init__(master) 53 | ttk.Label(self, text="Reports", font=("Segoe UI", 18)).pack(pady=20) 54 | 55 | 56 | class LogsPage(StyledRoutedView): 57 | def __init__(self, master=None): 58 | super().__init__(master) 59 | ttk.Label(self, text="Logs", font=("Segoe UI", 18)).pack(pady=(20, 10)) 60 | self.label = ttk.Label(self, font=("Segoe UI", 14)) 61 | self.label.pack(pady=10) 62 | 63 | def on_navigate(self, params: dict): 64 | level = params.get("level", "all") 65 | self.label.config(text=f"Showing logs with level: {level}") 66 | 67 | 68 | ROUTES = { 69 | "/": DashboardPage, 70 | "/users": UsersPage, 71 | "/users/": UserDetailsPage, 72 | "/reports": { 73 | "view": ReportsPage, 74 | "transition": slide_transition 75 | }, 76 | "/logs": LogsPage, 77 | "*": lambda master=None: ttk.Label(master, text="404 - Page Not Found", font=("Segoe UI", 16)) 78 | } 79 | 80 | 81 | def run(): 82 | root = tk.Tk() 83 | root.title("Admin Console Example") 84 | root.geometry("600x400") 85 | 86 | style = ttk.Style() 87 | style.theme_use("clam") 88 | 89 | container = ttk.Frame(root) 90 | container.pack(fill="both", expand=True) 91 | 92 | outlet = RouterOutlet(container) 93 | outlet.pack(side="right", fill="both", expand=True) 94 | 95 | sidebar = Sidebar(container) 96 | sidebar.pack(side="left", fill="y") 97 | 98 | create_router(ROUTES, outlet) 99 | get_router().navigate("/") 100 | 101 | root.mainloop() 102 | 103 | 104 | if __name__ == "__main__": 105 | run() 106 | -------------------------------------------------------------------------------- /src/tkrouter/examples/guarded_routes.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from tkrouter import create_router, get_router 4 | from tkrouter.router_outlet import RouterOutlet 5 | from tkrouter.widgets import RouteLinkButton 6 | from tkrouter.views import StyledRoutedView 7 | from tkrouter.exceptions import NavigationGuardError 8 | 9 | # --- Guard condition --- 10 | logged_in = False 11 | 12 | 13 | def is_authenticated(): 14 | return logged_in 15 | 16 | 17 | # --- Views --- 18 | 19 | class HomePage(StyledRoutedView): 20 | def __init__(self, master): 21 | super().__init__(master) 22 | ttk.Label(self, text="🏠 Home", font=("Segoe UI", 18)).pack(pady=10) 23 | ttk.Label(self, text="Try accessing the secret page below.").pack() 24 | RouteLinkButton(self, "/secret", text="Go to Secret").pack(pady=5) 25 | RouteLinkButton(self, "/login", text="Login").pack(pady=5) 26 | 27 | 28 | class LoginPage(StyledRoutedView): 29 | def __init__(self, master): 30 | super().__init__(master) 31 | ttk.Label(self, text="🔐 Login", font=("Segoe UI", 18)).pack(pady=10) 32 | ttk.Button(self, text="Simulate Login", command=self.simulate_login).pack(pady=10) 33 | 34 | def simulate_login(self): 35 | global logged_in 36 | logged_in = True 37 | # Go to home, then navigate to secret to trigger guard check 38 | get_router().navigate("/") 39 | self.after(100, lambda: get_router().navigate("/secret")) 40 | 41 | 42 | class SecretPage(StyledRoutedView): 43 | def __init__(self, master): 44 | super().__init__(master) 45 | ttk.Label(self, text="🎉 Secret Page", font=("Segoe UI", 18)).pack(pady=10) 46 | ttk.Label(self, text="You will not see this page unless logged in").pack() 47 | RouteLinkButton(self, "/", text="Back to Home").pack(pady=10) 48 | 49 | 50 | class AccessDeniedPage(StyledRoutedView): 51 | def __init__(self, master): 52 | super().__init__(master) 53 | ttk.Label(self, text="🚫 Access Denied", font=("Segoe UI", 18), foreground="red").pack(pady=10) 54 | RouteLinkButton(self, "/", text="Back to Home").pack(pady=10) 55 | 56 | 57 | # --- Route configuration with guard --- 58 | 59 | ROUTES = { 60 | "/": HomePage, 61 | "/login": LoginPage, 62 | "/secret": { 63 | "view": SecretPage, 64 | "guard": is_authenticated, 65 | "redirect": "/access-denied" 66 | }, 67 | "/access-denied": AccessDeniedPage, 68 | } 69 | 70 | 71 | def run(): 72 | root = tk.Tk() 73 | root.title("TkRouter – Route Guard Demo") 74 | root.geometry("480x300") 75 | ttk.Style().theme_use("clam") 76 | 77 | outlet = RouterOutlet(root) 78 | outlet.pack(fill="both", expand=True) 79 | 80 | create_router(ROUTES, outlet) 81 | get_router().navigate("/") 82 | 83 | root.mainloop() 84 | 85 | 86 | if __name__ == "__main__": 87 | run() 88 | -------------------------------------------------------------------------------- /src/tkrouter/examples/minimal_app.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from tkrouter import create_router, get_router 4 | from tkrouter.router_outlet import RouterOutlet 5 | from tkrouter.views import StyledRoutedView 6 | from tkrouter.widgets import RouteLinkButton 7 | 8 | 9 | class HomePage(StyledRoutedView): 10 | def __init__(self, master): 11 | super().__init__(master) 12 | ttk.Label(self, text="🏠 Home", font=("Segoe UI", 18)).pack(pady=10) 13 | ttk.Label(self, text="Welcome to the home page.").pack(pady=5) 14 | RouteLinkButton(self, "/about", text="Go to About").pack(pady=10) 15 | 16 | 17 | class AboutPage(StyledRoutedView): 18 | def __init__(self, master): 19 | super().__init__(master) 20 | ttk.Label(self, text="ℹ️ About", font=("Segoe UI", 18)).pack(pady=10) 21 | ttk.Label(self, text="This is the about page.").pack(pady=5) 22 | RouteLinkButton(self, "/", text="Back to Home").pack(pady=10) 23 | 24 | 25 | ROUTES = { 26 | "/": HomePage, 27 | "/about": AboutPage, 28 | } 29 | 30 | 31 | def run(): 32 | root = tk.Tk() 33 | root.title("TkRouter Example") 34 | root.geometry("400x250") 35 | 36 | style = ttk.Style() 37 | style.theme_use("clam") 38 | 39 | outlet = RouterOutlet(root) 40 | outlet.pack(fill="both", expand=True) 41 | 42 | create_router(ROUTES, outlet) 43 | get_router().navigate("/") 44 | 45 | root.mainloop() 46 | 47 | 48 | if __name__ == "__main__": 49 | run() 50 | -------------------------------------------------------------------------------- /src/tkrouter/examples/unified_routing.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from tkrouter import create_router, get_router 4 | from tkrouter.router_outlet import RouterOutlet 5 | from tkrouter.widgets import RouteLinkButton 6 | from tkrouter.transitions import slide_transition 7 | from tkrouter.views import StyledRoutedView 8 | 9 | 10 | class HomePage(StyledRoutedView): 11 | def __init__(self, master): 12 | super().__init__(master) 13 | ttk.Label(self, text="🏠 Home", font=("Segoe UI", 18)).pack(pady=10) 14 | ttk.Label(self, text="Welcome to the unified router demo!").pack() 15 | RouteLinkButton(self, "/dashboard", text="Go to Dashboard").pack(pady=10) 16 | 17 | 18 | class DashboardPage(StyledRoutedView): 19 | def __init__(self, master): 20 | super().__init__(master) 21 | ttk.Label(self, text="📊 Dashboard", font=("Segoe UI", 18)).pack(pady=10) 22 | RouteLinkButton(self, "/dashboard/stats", text="Stats").pack(pady=5) 23 | RouteLinkButton(self, "/dashboard/settings", text="Settings").pack(pady=5) 24 | RouteLinkButton(self, "/", text="← Back to Home").pack(pady=20) 25 | 26 | 27 | class StatsPage(StyledRoutedView): 28 | def __init__(self, master): 29 | super().__init__(master) 30 | ttk.Label(self, text="📈 Stats View", font=("Segoe UI", 16)).pack(pady=10) 31 | ttk.Label(self, text="Some stats go here...").pack() 32 | RouteLinkButton(self, "/dashboard", text="← Back to Dashboard").pack(pady=10) 33 | 34 | 35 | class SettingsPage(StyledRoutedView): 36 | def __init__(self, master): 37 | super().__init__(master) 38 | ttk.Label(self, text="⚙️ Settings View", font=("Segoe UI", 16)).pack(pady=10) 39 | ttk.Label(self, text="Settings options appear here.").pack() 40 | RouteLinkButton(self, "/dashboard", text="← Back to Dashboard").pack(pady=10) 41 | 42 | 43 | ROUTES = { 44 | "/": HomePage, 45 | "/dashboard": DashboardPage, 46 | "/dashboard/stats": {"view": StatsPage, "transition": slide_transition}, 47 | "/dashboard/settings": SettingsPage, 48 | } 49 | 50 | if __name__ == "__main__": 51 | root = tk.Tk() 52 | root.title("TkRouter – Unified Routing Example") 53 | root.geometry("500x350") 54 | 55 | ttk.Style().theme_use("clam") 56 | 57 | outlet = RouterOutlet(root) 58 | outlet.pack(fill="both", expand=True) 59 | 60 | create_router(ROUTES, outlet) 61 | get_router().navigate("/") 62 | 63 | root.mainloop() 64 | -------------------------------------------------------------------------------- /src/tkrouter/exceptions.py: -------------------------------------------------------------------------------- 1 | class RouterError(Exception): 2 | """Base exception for all routing-related errors.""" 3 | pass 4 | 5 | 6 | class RouteNotFoundError(RouterError): 7 | """Raised when no route matches the requested path.""" 8 | def __init__(self, message="No route matched the requested path."): 9 | super().__init__(message) 10 | 11 | 12 | class NavigationGuardError(RouterError): 13 | """Raised when navigation is blocked by a route guard.""" 14 | def __init__(self, message="Navigation blocked by route guard."): 15 | super().__init__(message) 16 | 17 | 18 | class InvalidRouteConfigError(RouterError): 19 | """Raised when a route configuration is invalid or incomplete.""" 20 | def __init__(self, message="Invalid or incomplete route configuration."): 21 | super().__init__(message) 22 | 23 | class HistoryNavigationError(RouterError): 24 | """Raised when navigating beyond the bounds of the history stack.""" 25 | def __init__(self, message="History navigation out of bounds."): 26 | super().__init__(message) 27 | -------------------------------------------------------------------------------- /src/tkrouter/history.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Callable 2 | from .exceptions import HistoryNavigationError 3 | 4 | 5 | class History: 6 | """ 7 | Manages a stack-based history mechanism with a navigable index. 8 | 9 | This class provides a forward/backward navigation model similar to browser history. 10 | It is used internally by the Router to manage path transitions and enable features 11 | like 'back', 'forward', and 'go(delta)' navigation. 12 | 13 | Attributes: 14 | _stack (List[Any]): Internal list storing the navigation history. 15 | _index (int): Index pointing to the current location in the stack. 16 | """ 17 | 18 | def __init__(self) -> None: 19 | """ 20 | Initializes an empty history stack and sets the index to -1 (no entries yet). 21 | """ 22 | self._stack: List[Any] = [] 23 | self._index: int = -1 24 | 25 | def push(self, path: Any) -> Any: 26 | """ 27 | Adds a new path to the top of the history stack. 28 | 29 | If the current index is not at the top of the stack, the stack is truncated 30 | before the new path is pushed. Updates the current index to point to the new path. 31 | 32 | Args: 33 | path (Any): The new path to push onto the history stack. 34 | 35 | Returns: 36 | Any: The path that was added. 37 | 38 | Raises: 39 | ValueError: If the path is None or an empty value. 40 | """ 41 | if not path: 42 | raise ValueError("Path cannot be None or an empty value.") 43 | self._stack = self._stack[:self._index + 1] 44 | self._stack.append(path) 45 | self._index += 1 46 | return path 47 | 48 | def replace(self, path: Any) -> None: 49 | """ 50 | Replaces the current path in the history stack with a new value. 51 | 52 | Args: 53 | path (Any): The new path to replace the current entry. 54 | 55 | Raises: 56 | ValueError: If the path is None or an empty value. 57 | HistoryNavigationError: If there is no valid item to replace. 58 | """ 59 | if not path: 60 | raise ValueError("Path cannot be None or an empty value.") 61 | if self._index < 0 or self._index >= len(self._stack): 62 | raise HistoryNavigationError("Cannot replace path in an invalid history state.") 63 | self._stack[self._index] = path 64 | 65 | def back(self) -> Optional[Any]: 66 | """ 67 | Moves one step backward in the history stack. 68 | 69 | Returns: 70 | Optional[Any]: The new current path after moving backward. 71 | 72 | Raises: 73 | HistoryNavigationError: If already at the beginning of the stack. 74 | """ 75 | if not self.can_go_back: 76 | raise HistoryNavigationError("Cannot go back. Already at the beginning of the stack.") 77 | self._index -= 1 78 | return self._stack[self._index] 79 | 80 | def forward(self) -> Optional[Any]: 81 | """ 82 | Moves one step forward in the history stack. 83 | 84 | Returns: 85 | Optional[Any]: The new current path after moving forward. 86 | 87 | Raises: 88 | HistoryNavigationError: If already at the end of the stack. 89 | """ 90 | if not self.can_go_forward: 91 | raise HistoryNavigationError("Cannot go forward. Already at the end of the stack.") 92 | self._index += 1 93 | return self._stack[self._index] 94 | 95 | def go(self, delta: int) -> Optional[Any]: 96 | """ 97 | Jumps forward or backward in the stack by a relative offset. 98 | 99 | Args: 100 | delta (int): Number of steps to move. Negative = backward, positive = forward. 101 | 102 | Returns: 103 | Optional[Any]: The path at the new index, or None if the jump is out of bounds. 104 | """ 105 | target_index = self._index + delta 106 | if 0 <= target_index < len(self._stack): 107 | self._index = target_index 108 | return self._stack[self._index] 109 | return None 110 | 111 | def current(self) -> Optional[Any]: 112 | """ 113 | Retrieves the current path in the history stack. 114 | 115 | Returns: 116 | Optional[Any]: The current path, or None if history is empty. 117 | """ 118 | return self._stack[self._index] if self._index >= 0 else None 119 | 120 | def clear(self, callback: Optional[Callable[[], None]] = None) -> None: 121 | """ 122 | Clears the entire history stack and resets the index. 123 | 124 | Args: 125 | callback (Optional[Callable[[], None]]): Optional function to call after clearing. 126 | """ 127 | self._stack = [] 128 | self._index = -1 129 | if callback: 130 | callback() 131 | 132 | @property 133 | def size(self) -> int: 134 | """ 135 | Returns: 136 | int: The number of items currently in the stack. 137 | """ 138 | return len(self._stack) 139 | 140 | @property 141 | def can_go_back(self) -> bool: 142 | """ 143 | Returns: 144 | bool: True if you can go backward in history. 145 | """ 146 | return self._index > 0 147 | 148 | @property 149 | def can_go_forward(self) -> bool: 150 | """ 151 | Returns: 152 | bool: True if you can go forward in history. 153 | """ 154 | return self._index + 1 < len(self._stack) 155 | 156 | def __repr__(self) -> str: 157 | """ 158 | Returns: 159 | str: Debug string representation of the history stack and current index. 160 | """ 161 | return f"History(stack={self._stack}, index={self._index})" 162 | -------------------------------------------------------------------------------- /src/tkrouter/router.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .utils import strip_query, extract_query_params, normalize_route_config 3 | from .history import History 4 | from .exceptions import ( 5 | RouteNotFoundError, 6 | NavigationGuardError, 7 | RouterError # For catch-all internal use if needed 8 | ) 9 | 10 | # --- Singleton instance --- 11 | _router_instance = None 12 | 13 | 14 | def create_router(routes, outlet, transition_handler=None): 15 | """ 16 | Creates the singleton Router instance. Can only be created once. 17 | 18 | Args: 19 | routes (dict): The routing configuration. 20 | outlet (RouterOutlet): The outlet where views are rendered. 21 | transition_handler (Callable): Optional global transition function. 22 | 23 | Returns: 24 | Router: The singleton router instance. 25 | 26 | Raises: 27 | RuntimeError: If the router is created more than once. 28 | """ 29 | global _router_instance 30 | if _router_instance is not None: 31 | raise RuntimeError("Router has already been created.") 32 | _router_instance = Router(routes, outlet, transition_handler) 33 | return _router_instance 34 | 35 | 36 | def get_router(): 37 | """ 38 | Retrieves the singleton Router instance. 39 | 40 | Returns: 41 | Router: The existing router instance. 42 | 43 | Raises: 44 | RuntimeError: If the router has not been created yet. 45 | """ 46 | if _router_instance is None: 47 | raise RuntimeError("Router has not been created.") 48 | return _router_instance 49 | 50 | 51 | class Router: 52 | """ 53 | Router controls view rendering, navigation, and transition logic. 54 | 55 | Attributes: 56 | routes (dict): The routing configuration. 57 | outlet (RouterOutlet): The widget responsible for rendering views. 58 | transition_handler (Callable): Default transition function. 59 | history (History): Stack-based navigation history. 60 | _listeners (list): List of route change listeners. 61 | """ 62 | 63 | def __init__(self, routes, outlet, transition_handler=None): 64 | self.routes = routes 65 | self.outlet = outlet 66 | self.transition_handler = transition_handler 67 | self.history = History() 68 | self._listeners = [] 69 | 70 | def navigate(self, path, transition=None): 71 | """ 72 | Navigates to a new path, resolving the route and rendering the view. 73 | 74 | Args: 75 | path (str): Path to navigate to (can include query parameters). 76 | transition (Callable): Optional transition override. 77 | 78 | Raises: 79 | RouteNotFoundError: If no route matches the path. 80 | NavigationGuardError: If a route's guard blocks access. 81 | """ 82 | query_params = extract_query_params(path) 83 | path = strip_query(path) 84 | 85 | match, params, view_class, route_config = self._resolve_route(path) 86 | if view_class is None: 87 | raise RouteNotFoundError(f"Route not found for path: {path}") 88 | 89 | # Run navigation guard if applicable 90 | if isinstance(route_config, dict): 91 | guard = route_config.get("guard") 92 | redirect_path = route_config.get("redirect") 93 | if guard and not guard(): 94 | if redirect_path: 95 | return self.navigate(redirect_path) 96 | raise NavigationGuardError(f"Access denied to path: {path}") 97 | 98 | handler = transition or route_config.get("transition") or self.transition_handler 99 | params.update(query_params) 100 | 101 | try: 102 | if handler: 103 | handler(self.outlet, view_class, params) 104 | else: 105 | self.set_view(view_class, params) 106 | 107 | query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) 108 | full_path = path + ("?" + query_string if query_string else "") 109 | self.history.push(full_path) 110 | self._notify_listeners(path, params) 111 | 112 | except Exception as e: 113 | print(f"[TkRouter] Error navigating to '{path}': {e}") 114 | 115 | def back(self): 116 | """Navigate to the previous route in history.""" 117 | try: 118 | path = self.history.back() 119 | if path: 120 | self.navigate(path) 121 | except Exception as e: 122 | print(f"[TkRouter] Back navigation failed: {e}") 123 | 124 | def forward(self): 125 | """Navigate to the next route in history.""" 126 | try: 127 | path = self.history.forward() 128 | if path: 129 | self.navigate(path) 130 | except Exception as e: 131 | print(f"[TkRouter] Forward navigation failed: {e}") 132 | 133 | def go(self, delta): 134 | """Navigate to a relative path in the history stack.""" 135 | try: 136 | path = self.history.go(delta) 137 | if path: 138 | self.navigate(path) 139 | except Exception as e: 140 | print(f"[TkRouter] Go({delta}) navigation failed: {e}") 141 | 142 | def set_view(self, view_class, params=None): 143 | """ 144 | Directly sets a view in the outlet. 145 | 146 | Args: 147 | view_class (type): A subclass of `tk.Frame` to instantiate. 148 | params (dict): Optional navigation parameters. 149 | """ 150 | self.outlet.set_view(view_class, params) 151 | if hasattr(self.outlet.current_view, "router"): 152 | self.outlet.current_view.router = self 153 | 154 | def _resolve_route(self, path): 155 | """ 156 | Finds the route matching the given path. 157 | 158 | Args: 159 | path (str): The path to match (no query string). 160 | 161 | Returns: 162 | Tuple[str, dict, class, dict]: (pattern, params, view_class, route_config) 163 | """ 164 | 165 | def search(route_tree, base=""): 166 | fallback = None 167 | for pattern, config in route_tree.items(): 168 | if pattern == "*": 169 | fallback = config 170 | continue 171 | 172 | full_path = base + pattern 173 | param_names = re.findall(r"<([^>]+)>", full_path) 174 | regex_pattern = re.sub(r"<[^>]+>", r"([^/]+)", full_path) 175 | match = re.fullmatch(regex_pattern, path) 176 | if match: 177 | params = dict(zip(param_names, match.groups())) 178 | if isinstance(config, dict): 179 | view_class = config.get("view") 180 | return pattern, params, view_class, config 181 | else: 182 | return pattern, params, config, normalize_route_config(config) 183 | 184 | if isinstance(config, dict) and "children" in config: 185 | result = search(config["children"], base + pattern) 186 | if result[2]: # view_class is not None 187 | return result 188 | 189 | if fallback: 190 | return "*", {}, fallback, normalize_route_config(fallback) 191 | return None, {}, None, None 192 | 193 | return search(self.routes) 194 | 195 | def on_change(self, callback): 196 | """ 197 | Subscribes a callback to be triggered on every successful route change. 198 | 199 | Args: 200 | callback (Callable): Function called with (path, params). 201 | """ 202 | self._listeners.append(callback) 203 | 204 | def _notify_listeners(self, path, params): 205 | """ 206 | Notifies all registered listeners of a route change. 207 | 208 | Args: 209 | path (str): The path navigated to. 210 | params (dict): The resolved route parameters. 211 | """ 212 | for callback in self._listeners: 213 | try: 214 | callback(path, params) 215 | except Exception as e: 216 | print(f"[TkRouter] Route observer error: {e}") 217 | -------------------------------------------------------------------------------- /src/tkrouter/router_outlet.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from typing import Optional, Type 3 | 4 | 5 | class RouterOutlet(tk.Frame): 6 | """ 7 | A dynamic container for routing views. 8 | 9 | The RouterOutlet acts as the placeholder for views rendered by the Router. 10 | It dynamically replaces its contents with the appropriate view class whenever 11 | navigation occurs. 12 | 13 | Attributes: 14 | current_view (tk.Widget): The currently displayed view, or None if unset. 15 | router (Any): A reference to the router that controls this outlet. 16 | """ 17 | 18 | def __init__(self, master: tk.Misc): 19 | """ 20 | Initializes the outlet with no current view. 21 | 22 | Args: 23 | master (tk.Misc): The parent widget (typically a root or container frame). 24 | """ 25 | super().__init__(master) 26 | self.current_view: Optional[tk.Widget] = None 27 | self.router = None 28 | self.pack(fill="both", expand=True) 29 | 30 | def set_view(self, view_class: Type[tk.Widget], params: Optional[dict] = None): 31 | """ 32 | Renders the specified view in the outlet, replacing any existing view. 33 | 34 | If the view defines an `on_navigate(params)` method, it will be invoked 35 | after rendering. 36 | 37 | Args: 38 | view_class (type): A subclass of tk.Widget to instantiate. 39 | params (dict, optional): Optional parameters passed to the new view. 40 | """ 41 | if self.current_view and self.current_view.winfo_exists(): 42 | self.current_view.destroy() 43 | 44 | self.current_view = view_class(self) 45 | self.current_view.pack(fill="both", expand=True) 46 | 47 | if hasattr(self.current_view, "on_navigate"): 48 | self.current_view.on_navigate(params or {}) 49 | -------------------------------------------------------------------------------- /src/tkrouter/transitions.py: -------------------------------------------------------------------------------- 1 | import time 2 | import tkinter as tk 3 | 4 | 5 | def slide_transition(outlet, view_class, params, duration=300): 6 | """ 7 | Performs a horizontal slide transition for view changes. 8 | 9 | The new view slides in from the right over the current view. If `on_enter` 10 | is defined on the view class, it will be called with the provided `params`. 11 | 12 | Args: 13 | outlet (tk.Frame): The container managing the current and new views. 14 | view_class (type): The class of the new view to be shown. 15 | params (dict): Parameters to pass to the view's `on_enter()` method. 16 | duration (int): Duration of the animation in milliseconds (default: 300). 17 | """ 18 | if outlet.current_view: 19 | outlet.current_view.place_forget() 20 | outlet.current_view.destroy() 21 | 22 | width = outlet.winfo_width() 23 | new_view = view_class(outlet) 24 | new_view.place(x=width, y=0, relheight=1, width=width) 25 | outlet.current_view = new_view 26 | 27 | if hasattr(new_view, 'on_enter'): 28 | new_view.on_enter(params) 29 | 30 | def animate(): 31 | start = time.time() 32 | 33 | def step(): 34 | elapsed = time.time() - start 35 | progress = min(1, elapsed * 1000 / duration) 36 | x = int(width * (1 - progress)) 37 | new_view.place(x=x, y=0) 38 | if progress < 1: 39 | outlet.after(16, step) 40 | else: 41 | new_view.place_forget() 42 | new_view.pack(fill="both", expand=True) 43 | 44 | step() 45 | 46 | animate() 47 | 48 | 49 | def simple_fade_transition(outlet, view_class, params, duration=300): 50 | """ 51 | Fades from the current view to a new view using an overlay. 52 | 53 | A full-frame overlay is created and gradually faded out, revealing the new view. 54 | If the new view has `on_enter(params)`, it will be called. 55 | 56 | Args: 57 | outlet (tk.Frame): The container managing the view stack. 58 | view_class (type): Class of the new view to be instantiated. 59 | params (dict): Parameters to pass to `on_enter()`, if defined. 60 | duration (int): Duration of the fade in milliseconds (default: 300). 61 | """ 62 | steps = 20 63 | delay = int(duration / steps) 64 | 65 | if outlet.current_view: 66 | outlet.current_view.destroy() 67 | 68 | new_view = view_class(outlet) 69 | new_view.place(x=0, y=0, relwidth=1, relheight=1) 70 | outlet.current_view = new_view 71 | 72 | if hasattr(new_view, 'on_enter'): 73 | new_view.on_enter(params) 74 | 75 | bg_color = get_background_color(outlet) 76 | fade_color = "#000000" if is_dark(outlet, bg_color) else "#ffffff" 77 | 78 | overlay = tk.Frame(outlet, bg=fade_color) 79 | overlay.place(x=0, y=0, relwidth=1, relheight=1) 80 | overlay.lift() 81 | 82 | def fade_out(step=steps): 83 | if step > 0: 84 | overlay.after(delay, lambda: fade_out(step - 1)) 85 | else: 86 | overlay.destroy() 87 | new_view.pack(fill="both", expand=True) 88 | 89 | fade_out() 90 | 91 | 92 | def get_background_color(widget): 93 | """ 94 | Attempts to retrieve the background color of a widget. 95 | 96 | Args: 97 | widget (tk.Widget): The widget to inspect. 98 | 99 | Returns: 100 | str: The background color, or '#ffffff' as a fallback. 101 | """ 102 | try: 103 | return widget.cget("background") 104 | except Exception: 105 | return "#ffffff" 106 | 107 | 108 | def is_dark(widget, color): 109 | """ 110 | Heuristic to determine if a color is dark based on brightness. 111 | 112 | Args: 113 | widget (tk.Widget): Any Tkinter widget for color resolution. 114 | color (str): A color name or hex string (e.g. '#333333'). 115 | 116 | Returns: 117 | bool: True if the color is dark; False otherwise. 118 | """ 119 | try: 120 | r, g, b = widget.winfo_rgb(color) 121 | brightness = (r + g + b) / (65535 * 3) 122 | return brightness < 0.5 123 | except Exception: 124 | return False 125 | -------------------------------------------------------------------------------- /src/tkrouter/types.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, Callable, TypeAlias, Optional 2 | 3 | RouteParams: TypeAlias = dict[str, str] 4 | 5 | class CommandWidget(Protocol): 6 | def configure(self, command: Callable) -> None: ... 7 | -------------------------------------------------------------------------------- /src/tkrouter/utils.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs, urlparse 2 | from typing import Dict, Any 3 | 4 | 5 | def format_path(path: str, params: Dict[str, Any]) -> str: 6 | """ 7 | Formats a route path by substituting dynamic segments and appending query parameters. 8 | 9 | Args: 10 | path (str): The base route, e.g. "/users/". 11 | params (dict): Parameters to insert into path or append as query parameters. 12 | 13 | Returns: 14 | str: The formatted path with placeholders replaced and query parameters appended. 15 | 16 | Example: 17 | format_path("/users/", {"id": 5, "tab": "profile"}) 18 | → "/users/5?tab=profile" 19 | """ 20 | if not params: 21 | return path 22 | 23 | path_filled = path 24 | query_items = {} 25 | 26 | for key, val in params.items(): 27 | placeholder = f"<{key}>" 28 | if placeholder in path_filled: 29 | path_filled = path_filled.replace(placeholder, str(val)) 30 | else: 31 | query_items[key] = val 32 | 33 | if query_items: 34 | query_string = "&".join(f"{k}={v}" for k, v in query_items.items()) 35 | path_filled += "?" + query_string 36 | 37 | return path_filled 38 | 39 | 40 | def strip_query(path: str) -> str: 41 | """ 42 | Removes the query string from a path. 43 | 44 | Args: 45 | path (str): A full path that may include a query string. 46 | 47 | Returns: 48 | str: The base path without query parameters. 49 | 50 | Example: 51 | "/users/5?tab=profile" → "/users/5" 52 | """ 53 | return path.split("?")[0] 54 | 55 | 56 | def extract_query_params(path: str) -> Dict[str, str]: 57 | """ 58 | Extracts query parameters from a path string. 59 | 60 | Args: 61 | path (str): A full path with optional query string. 62 | 63 | Returns: 64 | dict: A dictionary of query parameters. Values are decoded strings. 65 | 66 | Example: 67 | "/users?id=5&tab=info" → {"id": "5", "tab": "info"} 68 | """ 69 | return {k: v[0] for k, v in parse_qs(urlparse(path).query).items()} 70 | 71 | 72 | def normalize_route_config(config: Any) -> Dict[str, Any]: 73 | """ 74 | Normalizes a route config to always return a dictionary with a 'view' key. 75 | 76 | Args: 77 | config (dict or object): The route config, which may be a plain view class. 78 | 79 | Returns: 80 | dict: A config dictionary with at least a 'view' key. 81 | 82 | Example: 83 | normalize_route_config(MyViewClass) → {"view": MyViewClass} 84 | """ 85 | if isinstance(config, dict): 86 | return config 87 | return {"view": config} 88 | -------------------------------------------------------------------------------- /src/tkrouter/views.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from typing import Optional, Dict, Any 4 | from tkrouter import get_router 5 | 6 | 7 | class RoutedView(tk.Frame): 8 | """ 9 | Base class for views rendered by the router using tk.Frame. 10 | 11 | Automatically sets `self.router` and `self.params`, and provides 12 | an overridable `on_navigate()` method for handling route params. 13 | """ 14 | 15 | def __init__(self, master: Optional[tk.Misc] = None, **kwargs): 16 | """ 17 | Args: 18 | master (tk.Misc): Parent widget. 19 | kwargs: Accepts 'params' from the router. 20 | """ 21 | super().__init__(master) 22 | self.router = get_router() 23 | self.params: Dict[str, Any] = kwargs.get("params", {}) 24 | 25 | def on_navigate(self, params: Dict[str, Any]) -> None: 26 | """ 27 | Called by the router when this view is navigated to. 28 | 29 | Override this in subclasses to respond to parameters (e.g., query strings or path params). 30 | """ 31 | pass 32 | 33 | 34 | class StyledRoutedView(ttk.Frame): 35 | """ 36 | Base class for ttk.Frame-based views rendered by the router. 37 | 38 | Like RoutedView, but uses themed ttk styling. Subclass this when building 39 | views with ttk widgets. 40 | """ 41 | 42 | def __init__(self, master: Optional[tk.Misc] = None, **kwargs): 43 | """ 44 | Args: 45 | master (tk.Misc): Parent widget. 46 | kwargs: Accepts 'params' from the router. 47 | """ 48 | super().__init__(master) 49 | self.router = get_router() 50 | self.params: Dict[str, Any] = kwargs.get("params", {}) 51 | 52 | def on_navigate(self, params: Dict[str, Any]) -> None: 53 | """ 54 | Called by the router when this view is navigated to. 55 | 56 | Override this in subclasses to react to navigation parameters. 57 | """ 58 | pass 59 | -------------------------------------------------------------------------------- /src/tkrouter/widgets.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from typing import Callable, Optional, Dict 4 | from .utils import format_path 5 | from .types import CommandWidget 6 | from . import get_router 7 | 8 | 9 | class RouteLinkButton(ttk.Button): 10 | def __init__(self, master: tk.Widget, to: str, params: Optional[dict] = None, **kwargs): 11 | self.to = to 12 | self.params = params 13 | super().__init__(master, command=self.navigate, **kwargs) 14 | 15 | def navigate(self): 16 | path = format_path(self.to, self.params) 17 | get_router().navigate(path) 18 | 19 | 20 | class RouteLinkLabel(ttk.Label): 21 | """ 22 | A label that acts as a clickable navigation link. 23 | """ 24 | 25 | def __init__(self, master: tk.Widget, to: str, params: Optional[Dict] = None, **kwargs): 26 | self.to = to 27 | self.params = params 28 | super().__init__(master, **kwargs) 29 | 30 | self.bind("", self._on_click) 31 | self.bind("", lambda e: self.configure(font=(self._get_font(), "underline"))) 32 | self.bind("", lambda e: self.configure(font=(self._get_font(), "normal"))) 33 | 34 | self.configure(cursor="hand2", foreground="blue") 35 | 36 | def _get_font(self): 37 | try: 38 | return self.cget("font") 39 | except Exception: 40 | return "TkDefaultFont" 41 | 42 | def _on_click(self, event=None): 43 | path = format_path(self.to, self.params) 44 | get_router().navigate(path) 45 | 46 | 47 | def bind_route(widget: CommandWidget, path: str, params: Optional[dict] = None) -> None: 48 | formatted = format_path(path, params) 49 | widget.configure(command=lambda: get_router().navigate(formatted)) 50 | 51 | 52 | def with_route(path: str, params: Optional[dict] = None) -> Callable: 53 | formatted = format_path(path, params) 54 | 55 | def decorator(func: Callable) -> Callable: 56 | def wrapper(*args, **kwargs): 57 | return func(*args, **kwargs) 58 | wrapper._router_path = formatted 59 | wrapper._router = get_router() 60 | return wrapper 61 | 62 | return decorator 63 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/israel-dryer/TkRouter/2dc3f4a26cd5048fba69967585ce7dff5c5e9f9c/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | from tkrouter.router import Router 4 | from tkrouter.router_outlet import RouterOutlet 5 | from tkrouter.exceptions import RouteNotFoundError, NavigationGuardError 6 | 7 | class DummyPage(tk.Frame): 8 | pass 9 | 10 | def test_route_not_found_exception(): 11 | root = tk.Tk() 12 | outlet = RouterOutlet(root) 13 | router = Router(routes={"/": DummyPage}, outlet=outlet) 14 | 15 | with pytest.raises(RouteNotFoundError): 16 | router.navigate("/invalid") 17 | root.destroy() 18 | 19 | def test_navigation_guard_exception(): 20 | root = tk.Tk() 21 | outlet = RouterOutlet(root) 22 | routes = { 23 | "/secure": { 24 | "view": DummyPage, 25 | "guard": lambda: False 26 | } 27 | } 28 | router = Router(routes=routes, outlet=outlet) 29 | 30 | with pytest.raises(NavigationGuardError): 31 | router.navigate("/secure") 32 | root.destroy() 33 | -------------------------------------------------------------------------------- /tests/integration/test_router.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | 4 | from tkrouter.exceptions import RouteNotFoundError 5 | 6 | try: 7 | root_test = tk.Tk() 8 | root_test.destroy() 9 | except tk.TclError: 10 | pytest.skip("Tkinter not supported in this environment", allow_module_level=True) 11 | 12 | 13 | import pytest 14 | from unittest.mock import Mock 15 | from tkrouter.router import Router 16 | from tkrouter.router_outlet import RouterOutlet 17 | import tkinter as tk 18 | 19 | class DummyPage(tk.Frame): 20 | def on_navigate(self, params): 21 | self.params = params 22 | 23 | def test_navigate_sets_view_and_history(): 24 | root = tk.Tk() 25 | outlet = RouterOutlet(root) 26 | routes = {"/": DummyPage} 27 | router = Router(routes=routes, outlet=outlet) 28 | router.navigate("/") 29 | assert isinstance(outlet.winfo_children()[0], DummyPage) 30 | assert router.history.current() == "/" 31 | root.destroy() 32 | 33 | def test_guard_redirect(): 34 | root = tk.Tk() 35 | outlet = RouterOutlet(root) 36 | routes = { 37 | "/secure": {"view": DummyPage, "guard": lambda: False, "redirect": "/"}, 38 | "/": DummyPage 39 | } 40 | router = Router(routes=routes, outlet=outlet) 41 | router.navigate("/secure") 42 | assert router.history.current() == "/" 43 | root.destroy() 44 | 45 | def test_route_not_found(): 46 | root = tk.Tk() 47 | outlet = RouterOutlet(root) 48 | routes = {"/": DummyPage} 49 | router = Router(routes=routes, outlet=outlet) 50 | with pytest.raises(RouteNotFoundError): 51 | router.navigate("/missing") 52 | root.destroy() -------------------------------------------------------------------------------- /tests/integration/test_widgets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tkinter as tk 3 | 4 | try: 5 | root_test = tk.Tk() 6 | root_test.destroy() 7 | except tk.TclError: 8 | pytest.skip("Tkinter not supported in this environment", allow_module_level=True) 9 | 10 | import tkinter as tk 11 | import pytest 12 | from unittest.mock import Mock 13 | from tkrouter.widgets import RouteLinkButton, bind_route, with_route 14 | 15 | @pytest.fixture 16 | def mock_router(): 17 | """ 18 | Fixture to create a mock router for testing. 19 | 20 | Returns: 21 | Mock: A mock object simulating a router. 22 | """ 23 | return Mock() 24 | 25 | def test_route_link_button_navigates_with_params(mock_router): 26 | """ 27 | Test that the RouteLinkButton correctly navigates to the specified route with parameters. 28 | 29 | Args: 30 | mock_router (Mock): A mock router object for navigation. 31 | 32 | Raises: 33 | AssertionError: If navigation is not called with the expected route. 34 | """ 35 | root = tk.Tk() 36 | button = RouteLinkButton(root, mock_router, "/test/", params={"x": 1}) 37 | button.invoke() 38 | mock_router.navigate.assert_called_with("/test/1") 39 | root.destroy() 40 | 41 | def test_bind_route_navigates_with_query(mock_router): 42 | """ 43 | Test that the bind_route function attaches a navigation command that includes query parameters. 44 | 45 | Args: 46 | mock_router (Mock): A mock router object for navigation. 47 | 48 | Raises: 49 | AssertionError: If the navigation command is not called with the expected route. 50 | """ 51 | root = tk.Tk() 52 | btn = tk.Button(root) 53 | bind_route(btn, mock_router, "/search", params={"q": "book"}) 54 | btn.invoke() 55 | mock_router.navigate.assert_called_with("/search?q=book") 56 | root.destroy() 57 | 58 | def test_with_route_decorator_sets_metadata(mock_router): 59 | """ 60 | Test that the with_route decorator correctly attaches routing metadata to functions. 61 | 62 | Args: 63 | mock_router (Mock): A mock router object for navigation. 64 | 65 | Raises: 66 | AssertionError: If the decorated function does not produce the expected output or metadata. 67 | """ 68 | @with_route(mock_router, "/hello/", params={"lang": "en"}) 69 | def test_func(): 70 | """ 71 | A test function decorated with routing metadata. 72 | 73 | Returns: 74 | str: A sample return value for testing. 75 | """ 76 | return "Hello" 77 | 78 | result = test_func() 79 | assert result == "Hello" 80 | assert hasattr(test_func, "_router_path") 81 | assert test_func._router_path == "/hello/en" 82 | assert test_func._router == mock_router -------------------------------------------------------------------------------- /tests/unit/test_format_path.py: -------------------------------------------------------------------------------- 1 | from tkrouter.widgets import format_path 2 | 3 | def test_format_path_replaces_placeholders(): 4 | path = "/user/" 5 | params = {"id": 42} 6 | assert format_path(path, params) == "/user/42" 7 | 8 | def test_format_path_appends_query_string(): 9 | path = "/search" 10 | params = {"q": "python"} 11 | assert format_path(path, params) == "/search?q=python" 12 | 13 | def test_format_path_combines_placeholders_and_query(): 14 | path = "/user/" 15 | params = {"id": 5, "view": "full"} 16 | assert format_path(path, params) == "/user/5?view=full" 17 | 18 | def test_format_path_no_params_returns_original(): 19 | path = "/static/page" 20 | assert format_path(path, None) == "/static/page" 21 | 22 | def test_format_path_ignores_query_for_filled_placeholders(): 23 | path = "/doc/" 24 | params = {"lang": "en", "mode": "dark"} 25 | assert format_path(path, params) == "/doc/en?mode=dark" 26 | -------------------------------------------------------------------------------- /tests/unit/test_history.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tkrouter.history import History 3 | 4 | 5 | def test_push(): 6 | history = History() 7 | history.push("/home") 8 | assert history.size == 1 9 | assert history.current() == "/home" 10 | 11 | 12 | def test_back(): 13 | history = History() 14 | history.push("/home") 15 | history.push("/about") 16 | assert history.back() == "/home" 17 | assert history.current() == "/home" 18 | 19 | 20 | def test_forward(): 21 | history = History() 22 | history.push("/home") 23 | history.push("/about") 24 | history.back() 25 | assert history.forward() == "/about" 26 | assert history.current() == "/about" 27 | 28 | 29 | def test_go(): 30 | history = History() 31 | history.push("/home") 32 | history.push("/about") 33 | history.push("/contact") 34 | 35 | assert history.go(-1) == "/about" # Navigate back one step 36 | assert history.current() == "/about" 37 | 38 | assert history.go(-2) is None # Go out of bounds (too far back), returns None 39 | assert history.current() == "/about" # Current position remains unchanged 40 | 41 | assert history.go(2) is None # Go out of bounds (too far forward), returns None 42 | assert history.current() == "/about" 43 | 44 | 45 | def test_go_out_of_bounds(): 46 | history = History() 47 | history.push("/home") 48 | history.push("/about") 49 | history.push("/contact") 50 | 51 | # Going out of bounds backward 52 | assert history.go(-10) is None # Too far back 53 | assert history.current() == "/contact" # Current state remains unchanged 54 | 55 | # Going out of bounds forward 56 | assert history.go(10) is None # Too far forward 57 | assert history.current() == "/contact" # Current state remains unchanged 58 | 59 | 60 | def test_replace(): 61 | history = History() 62 | history.push("/home") 63 | history.replace("/new_home") 64 | assert history.current() == "/new_home" 65 | 66 | 67 | def test_replace_on_empty_stack_raises_error(): 68 | history = History() 69 | with pytest.raises(IndexError): 70 | history.replace("/new_home") # No item to replace 71 | 72 | 73 | def test_clear(): 74 | history = History() 75 | history.push("/home") 76 | history.push("/about") 77 | history.clear() 78 | assert history.size == 0 79 | assert history.current() is None 80 | 81 | 82 | def test_can_go_back_and_forward(): 83 | history = History() 84 | assert not history.can_go_back 85 | assert not history.can_go_forward 86 | 87 | history.push("/home") 88 | assert not history.can_go_back 89 | assert not history.can_go_forward 90 | 91 | history.push("/about") 92 | assert history.can_go_back 93 | assert not history.can_go_forward 94 | 95 | history.back() 96 | assert not history.can_go_back 97 | assert history.can_go_forward 98 | 99 | history.forward() 100 | assert history.can_go_back 101 | assert not history.can_go_forward 102 | 103 | 104 | def test_push_resets_forward_history(): 105 | history = History() 106 | history.push("/home") 107 | history.push("/about") 108 | history.back() 109 | history.push("/contact") # Should reset forward history 110 | assert history.size == 2 # "/contact" replaces everything after the current index 111 | assert history.current() == "/contact" 112 | assert not history.can_go_forward --------------------------------------------------------------------------------