├── .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 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 | [](https://pypi.org/project/tkrouter/)
8 | [](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 | 
6 | 
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
--------------------------------------------------------------------------------