Compare commits
11 Commits
3de9aff15c
...
WorkingOnC
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d9b106fb | |||
| d2cf51d7c3 | |||
| 53253278b2 | |||
| 52b4e6a8b6 | |||
| a6ab4b2a68 | |||
| 84c63f0c5a | |||
| bb8752233e | |||
| dd9aefa143 | |||
| b1be747101 | |||
| 97247f824c | |||
| 4199427c71 |
425
CLAUDE.md
Normal file
425
CLAUDE.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MyFastHtml is a Python utility library that simplifies FastHTML application development by providing:
|
||||||
|
- Command management system for client-server interactions
|
||||||
|
- Bidirectional data binding system
|
||||||
|
- Predefined authentication pages and routes
|
||||||
|
- Interactive control helpers
|
||||||
|
- Session-based instance management
|
||||||
|
|
||||||
|
**Tech Stack**: Python 3.12+, FastHTML, HTMX, DaisyUI 5, Tailwind CSS 4
|
||||||
|
|
||||||
|
## Development Workflow and Guidelines
|
||||||
|
|
||||||
|
### Development Process
|
||||||
|
|
||||||
|
**Code must always be testable**. Before writing any code:
|
||||||
|
|
||||||
|
1. **Explain available options first** - Present different approaches to solve the problem
|
||||||
|
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
|
||||||
|
3. **No code without approval** - Only proceed after explicit validation
|
||||||
|
|
||||||
|
### Collaboration Style
|
||||||
|
|
||||||
|
**Ask questions to clarify understanding or suggest alternative approaches:**
|
||||||
|
- Ask questions **one at a time**
|
||||||
|
- Wait for complete answer before asking the next question
|
||||||
|
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||||
|
- Never assume - always clarify ambiguities
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
|
**Conversations**: French or English
|
||||||
|
**Code, documentation, comments**: English only
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
**Follow PEP 8** conventions strictly:
|
||||||
|
- Variable and function names: `snake_case`
|
||||||
|
- Explicit, descriptive naming
|
||||||
|
- **No emojis in code**
|
||||||
|
|
||||||
|
**Documentation**:
|
||||||
|
- Use Google or NumPy docstring format
|
||||||
|
- Document all public functions and classes
|
||||||
|
- Include type hints where applicable
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
**When introducing new dependencies:**
|
||||||
|
- List all external dependencies explicitly
|
||||||
|
- Propose alternatives using Python standard library when possible
|
||||||
|
- Explain why each dependency is needed
|
||||||
|
|
||||||
|
### Unit Testing with pytest
|
||||||
|
|
||||||
|
**Test naming patterns:**
|
||||||
|
- Passing tests: `test_i_can_xxx` - Tests that should succeed
|
||||||
|
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||||
|
|
||||||
|
**Test structure:**
|
||||||
|
- Use **functions**, not classes (unless inheritance is required)
|
||||||
|
- Before writing tests, **list all planned tests with explanations**
|
||||||
|
- Wait for validation before implementing tests
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
def test_i_can_create_command_with_valid_name():
|
||||||
|
"""Test that a command can be created with a valid name."""
|
||||||
|
cmd = Command("valid_name", "description", lambda: None)
|
||||||
|
assert cmd.name == "valid_name"
|
||||||
|
|
||||||
|
def test_i_cannot_create_command_with_empty_name():
|
||||||
|
"""Test that creating a command with empty name raises ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Command("", "description", lambda: None)
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Management
|
||||||
|
|
||||||
|
**Always specify the full file path** when adding or modifying files:
|
||||||
|
```
|
||||||
|
✅ Modifying: src/myfasthtml/core/commands.py
|
||||||
|
✅ Creating: tests/core/test_new_feature.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
**When errors occur:**
|
||||||
|
1. **Explain the problem clearly first**
|
||||||
|
2. **Do not propose a fix immediately**
|
||||||
|
3. **Wait for validation** that the diagnosis is correct
|
||||||
|
4. Only then propose solutions
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/core/test_bindings.py
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
pytest tests/core/test_bindings.py::test_function_name
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleaning
|
||||||
|
```bash
|
||||||
|
# Clean build artifacts and cache
|
||||||
|
make clean
|
||||||
|
|
||||||
|
# Clean package distribution files
|
||||||
|
make clean-package
|
||||||
|
|
||||||
|
# Clean test artifacts (.sesskey, test databases)
|
||||||
|
make clean-tests
|
||||||
|
|
||||||
|
# Clean everything including source artifacts
|
||||||
|
make clean-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package Building
|
||||||
|
```bash
|
||||||
|
# Build distribution
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
# Install in development mode
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core System: Commands
|
||||||
|
|
||||||
|
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||||
|
|
||||||
|
**Key classes:**
|
||||||
|
- `BaseCommand`: Base class for all commands with HTMX integration
|
||||||
|
- `Command`: Standard command that executes a Python callable
|
||||||
|
- `LambdaCommand`: Inline command for simple operations
|
||||||
|
- `CommandsManager`: Global registry for command execution
|
||||||
|
|
||||||
|
**How commands work:**
|
||||||
|
1. Create command with action: `cmd = Command("name", "description", callable)`
|
||||||
|
2. Command auto-registers with `CommandsManager`
|
||||||
|
3. `cmd.get_htmx_params()` generates HTMX attributes (`hx-post`, `hx-vals`)
|
||||||
|
4. HTMX posts to `/myfasthtml/commands` route with `c_id`
|
||||||
|
5. `CommandsManager` routes to correct command's `execute()` method
|
||||||
|
|
||||||
|
**Command customization:**
|
||||||
|
```python
|
||||||
|
# Change HTMX target and swap
|
||||||
|
cmd.htmx(target="#result", swap="innerHTML")
|
||||||
|
|
||||||
|
# Bind to observable data (disables swap by default)
|
||||||
|
cmd.bind(data_object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core System: Bindings
|
||||||
|
|
||||||
|
Bidirectional data binding system connects UI components with Python data objects. Located in `src/myfasthtml/core/bindings.py`.
|
||||||
|
|
||||||
|
**Key concepts:**
|
||||||
|
- **Observable objects**: Use `make_observable()` from myutils to enable change detection
|
||||||
|
- **Three-phase lifecycle**: Create → Activate (bind_ft) → Deactivate
|
||||||
|
- **Detection modes**: How changes are detected (ValueChange, AttributePresence, SelectValueChange)
|
||||||
|
- **Update modes**: How UI updates (ValueChange, AttributePresence, SelectValueChange)
|
||||||
|
- **Data converters**: Transform data between UI and Python representations
|
||||||
|
|
||||||
|
**Binding flow:**
|
||||||
|
1. User changes input → HTMX posts to `/myfasthtml/bindings`
|
||||||
|
2. `Binding.update()` receives form data, updates observable object
|
||||||
|
3. Observable triggers change event → `Binding.notify()`
|
||||||
|
4. All bound UI elements update via HTMX swap-oob
|
||||||
|
|
||||||
|
**Helper usage:**
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
# Bind input and label to same data
|
||||||
|
input_elt = Input(name="field")
|
||||||
|
label_elt = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(input_elt, Binding(data, "attr"))
|
||||||
|
mk.manage_binding(label_elt, Binding(data, "attr"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important binding notes:**
|
||||||
|
- Elements MUST have a `name` attribute to trigger updates
|
||||||
|
- Multiple elements can bind to same data attribute
|
||||||
|
- First binding call uses `init_binding=True` to set initial value
|
||||||
|
- Bindings route through `/myfasthtml/bindings` endpoint
|
||||||
|
|
||||||
|
### Core System: Instances
|
||||||
|
|
||||||
|
Session-scoped instance management system. Located in `src/myfasthtml/core/instances.py`.
|
||||||
|
|
||||||
|
**Key classes:**
|
||||||
|
- `BaseInstance`: Base for all managed instances
|
||||||
|
- `SingleInstance`: One instance per parent per session
|
||||||
|
- `UniqueInstance`: One instance ever per session (singleton-like)
|
||||||
|
- `RootInstance`: Top-level singleton for application
|
||||||
|
- `InstancesManager`: Global registry with session-based isolation
|
||||||
|
|
||||||
|
**Instance creation pattern:**
|
||||||
|
```python
|
||||||
|
# __new__ checks registry before creating
|
||||||
|
# If instance exists with same (session_id, _id), returns existing
|
||||||
|
instance = MyInstance(parent, session, _id="optional")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic ID generation:**
|
||||||
|
- SingleInstance: `snake_case_class_name`
|
||||||
|
- UniqueInstance: `snake_case_class_name`
|
||||||
|
- Regular BaseInstance: `parent_prefix-uuid`
|
||||||
|
|
||||||
|
### Application Setup
|
||||||
|
|
||||||
|
**Main entry point**: `create_app()` in `src/myfasthtml/myfastapp.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
|
app, rt = create_app(
|
||||||
|
daisyui=True, # Include DaisyUI CSS
|
||||||
|
vis=True, # Include vis-network.js
|
||||||
|
protect_routes=True, # Enable auth beforeware
|
||||||
|
mount_auth_app=False, # Mount auth routes
|
||||||
|
base_url=None # Base URL for auth
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What create_app does:**
|
||||||
|
1. Adds MyFastHtml CSS/JS assets via custom static route
|
||||||
|
2. Optionally adds DaisyUI 5 + Tailwind CSS 4
|
||||||
|
3. Optionally adds vis-network.js
|
||||||
|
4. Mounts `/myfasthtml` app for commands and bindings routes
|
||||||
|
5. Optionally sets up auth routes and beforeware
|
||||||
|
6. Creates AuthProxy instance if auth enabled
|
||||||
|
|
||||||
|
### Authentication System
|
||||||
|
|
||||||
|
Located in `src/myfasthtml/auth/`. Integrates with FastAPI backend (myauth package).
|
||||||
|
|
||||||
|
**Key components:**
|
||||||
|
- `auth/utils.py`: JWT helpers, beforeware for route protection
|
||||||
|
- `auth/routes.py`: Login, register, logout routes
|
||||||
|
- `auth/pages/`: LoginPage, RegisterPage, WelcomePage components
|
||||||
|
|
||||||
|
**How auth works:**
|
||||||
|
1. Beforeware checks `access_token` in session before each route
|
||||||
|
2. Auto-refreshes token if < 5 minutes to expiry
|
||||||
|
3. Redirects to `/login` if token invalid/missing
|
||||||
|
4. Protected routes receive `auth` parameter with user info
|
||||||
|
|
||||||
|
**Session structure:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'access_token': 'jwt_token',
|
||||||
|
'refresh_token': 'refresh_token',
|
||||||
|
'user_info': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'username': 'user',
|
||||||
|
'roles': ['admin'],
|
||||||
|
'id': 'uuid',
|
||||||
|
'created_at': 'timestamp',
|
||||||
|
'updated_at': 'timestamp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/myfasthtml/
|
||||||
|
├── myfastapp.py # Main app factory (create_app)
|
||||||
|
├── core/
|
||||||
|
│ ├── commands.py # Command system
|
||||||
|
│ ├── bindings.py # Binding system
|
||||||
|
│ ├── instances.py # Instance management
|
||||||
|
│ ├── utils.py # Utilities, routes app
|
||||||
|
│ ├── constants.py # Routes, constants
|
||||||
|
│ ├── dbmanager.py # Database helpers
|
||||||
|
│ ├── AuthProxy.py # Auth proxy instance
|
||||||
|
│ └── network_utils.py # Network utilities
|
||||||
|
├── controls/
|
||||||
|
│ ├── helpers.py # mk class with UI helpers
|
||||||
|
│ ├── BaseCommands.py # Base command implementations
|
||||||
|
│ ├── Search.py # Search control
|
||||||
|
│ └── Keyboard.py # Keyboard shortcuts
|
||||||
|
├── auth/
|
||||||
|
│ ├── utils.py # JWT, beforeware
|
||||||
|
│ ├── routes.py # Auth routes
|
||||||
|
│ └── pages/ # Login, Register, Welcome pages
|
||||||
|
├── icons/ # Icon libraries (fluent, material, etc.)
|
||||||
|
├── assets/ # CSS/JS files
|
||||||
|
└── test/ # Test utilities
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── core/ # Core system tests
|
||||||
|
├── testclient/ # TestClient and TestableElement tests
|
||||||
|
├── auth/ # Authentication tests
|
||||||
|
├── controls/ # Control tests
|
||||||
|
└── html/ # HTML component tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing System
|
||||||
|
|
||||||
|
**Custom test client**: `myfasthtml.test.TestClient` extends FastHTML test client
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- `user.open(path)`: Navigate to route
|
||||||
|
- `user.find_element(selector)`: Find element by CSS selector
|
||||||
|
- `user.should_see(text)`: Assert text in response
|
||||||
|
- Returns `TestableElement` objects with component-specific methods
|
||||||
|
|
||||||
|
**Testable element types:**
|
||||||
|
- `TestableInput`: `.send(value)`
|
||||||
|
- `TestableCheckbox`: `.check()`, `.uncheck()`, `.toggle()`
|
||||||
|
- `TestableTextarea`: `.send(value)`, `.append(text)`, `.clear()`
|
||||||
|
- `TestableSelect`: `.select(value)`, `.select_by_text(text)`, `.deselect(value)`
|
||||||
|
- `TestableRange`: `.set(value)`, `.increase()`, `.decrease()`
|
||||||
|
- `TestableRadio`: `.select()`
|
||||||
|
- `TestableButton`: `.click()`
|
||||||
|
- `TestableDatalist`: `.send(value)`, `.select_suggestion(value)`
|
||||||
|
|
||||||
|
**Test pattern:**
|
||||||
|
```python
|
||||||
|
def test_component(user, rt):
|
||||||
|
@rt("/")
|
||||||
|
def index():
|
||||||
|
data = Data("initial")
|
||||||
|
component = Component(name="field")
|
||||||
|
label = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(component, Binding(data))
|
||||||
|
mk.manage_binding(label, Binding(data))
|
||||||
|
|
||||||
|
return component, label
|
||||||
|
|
||||||
|
user.open("/")
|
||||||
|
user.should_see("initial")
|
||||||
|
|
||||||
|
elem = user.find_element("selector")
|
||||||
|
elem.method("new_value")
|
||||||
|
user.should_see("new_value")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### Creating interactive buttons with commands
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
def action():
|
||||||
|
return "Result"
|
||||||
|
|
||||||
|
cmd = Command("action", "Description", action)
|
||||||
|
button = mk.button("Click", command=cmd)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bidirectional binding
|
||||||
|
```python
|
||||||
|
from myutils.observable import make_observable
|
||||||
|
from myfasthtml.core.bindings import Binding
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
data = make_observable(Data("value"))
|
||||||
|
input_elt = Input(name="field")
|
||||||
|
label_elt = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(input_elt, Binding(data, "value"))
|
||||||
|
mk.manage_binding(label_elt, Binding(data, "value"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using helpers (mk class)
|
||||||
|
```python
|
||||||
|
# Button with command
|
||||||
|
mk.button("Text", command=cmd, cls="btn-primary")
|
||||||
|
|
||||||
|
# Icon with command
|
||||||
|
mk.icon(icon_svg, size=20, command=cmd)
|
||||||
|
|
||||||
|
# Label with icon
|
||||||
|
mk.label("Text", icon=icon_svg, size="sm")
|
||||||
|
|
||||||
|
# Generic wrapper
|
||||||
|
mk.mk(element, command=cmd, binding=binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Bindings require `name` attribute**: Without it, form data won't include the field
|
||||||
|
2. **Commands auto-register**: Don't manually register with CommandsManager
|
||||||
|
3. **Instances use __new__ caching**: Same (session, id) returns existing instance
|
||||||
|
4. **First binding needs init**: Use `init_binding=True` to set initial value
|
||||||
|
5. **Observable required for bindings**: Use `make_observable()` from myutils
|
||||||
|
6. **Auth routes need base_url**: Pass `base_url` to `create_app()` for proper auth API calls
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- python-fasthtml: Web framework
|
||||||
|
- myauth: Authentication backend
|
||||||
|
- mydbengine: Database abstraction
|
||||||
|
- myutils: Observable pattern, utilities
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- DaisyUI 5: Component library
|
||||||
|
- Tailwind CSS 4: Styling
|
||||||
|
- vis-network: Network visualization
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- pytest: Testing framework
|
||||||
|
- httpx: HTTP client for tests
|
||||||
|
- python-dotenv: Environment variables
|
||||||
345
docs/Keyboard Support.md
Normal file
345
docs/Keyboard Support.md
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
# Keyboard Support - Test Instructions
|
||||||
|
|
||||||
|
## ⚠️ Breaking Change
|
||||||
|
|
||||||
|
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
|
||||||
|
|
||||||
|
**Old format (no longer supported)**:
|
||||||
|
```javascript
|
||||||
|
{"A": "/url"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New format (required)**:
|
||||||
|
```javascript
|
||||||
|
{"A": {"hx-post": "/url"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
|
||||||
|
- `test_keyboard_support.html` - Test page to verify functionality
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Multiple Simultaneous Triggers
|
||||||
|
|
||||||
|
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
add_keyboard_support('modal', '{"esc": "/close-modal"}');
|
||||||
|
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
|
||||||
|
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
|
||||||
|
|
||||||
|
// Pressing ESC will trigger all 3 URLs simultaneously
|
||||||
|
```
|
||||||
|
|
||||||
|
This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.).
|
||||||
|
|
||||||
|
### Smart Timeout Logic (Longest Match)
|
||||||
|
|
||||||
|
The library uses **a single global timeout** based on the sequence state, not on individual elements:
|
||||||
|
|
||||||
|
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
**Example 1**: Three elements, same combination
|
||||||
|
```javascript
|
||||||
|
add_keyboard_support('elem1', '{"esc": "/url1"}');
|
||||||
|
add_keyboard_support('elem2', '{"esc": "/url2"}');
|
||||||
|
add_keyboard_support('elem3', '{"esc": "/url3"}');
|
||||||
|
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2**: Mixed - one has longer sequence
|
||||||
|
```javascript
|
||||||
|
add_keyboard_support('elem1', '{"A": "/url1"}');
|
||||||
|
add_keyboard_support('elem2', '{"A": "/url2"}');
|
||||||
|
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
|
||||||
|
// Press A:
|
||||||
|
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
|
||||||
|
// - If B arrives: only elem3 triggers with "A B"
|
||||||
|
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 3**: Different combinations
|
||||||
|
```javascript
|
||||||
|
add_keyboard_support('elem1', '{"A B": "/url1"}');
|
||||||
|
add_keyboard_support('elem2', '{"C D": "/url2"}');
|
||||||
|
// Press A: elem1 waits for B, elem2 not affected
|
||||||
|
// Press C: elem2 waits for D, elem1 not affected
|
||||||
|
```
|
||||||
|
|
||||||
|
The timeout is tied to the **sequence being typed**, not to individual elements.
|
||||||
|
|
||||||
|
### Smart Timeout Logic (Longest Match)
|
||||||
|
|
||||||
|
Keyboard shortcuts are **disabled** when typing in input fields:
|
||||||
|
- `<input>` elements
|
||||||
|
- `<textarea>` elements
|
||||||
|
- Any `contenteditable` element
|
||||||
|
|
||||||
|
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
|
||||||
|
|
||||||
|
## How to Test
|
||||||
|
|
||||||
|
1. **Download both files** to the same directory
|
||||||
|
2. **Open `test_keyboard_support.html`** in a web browser
|
||||||
|
3. **Try the configured shortcuts**:
|
||||||
|
- `a` - Simple key (waits if "A B" might follow)
|
||||||
|
- `Ctrl+S` - Save combination (immediate)
|
||||||
|
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
|
||||||
|
- `A B` - Sequence (waits because "A B C" exists)
|
||||||
|
- `A B C` - Triple sequence (triggers immediately)
|
||||||
|
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
|
||||||
|
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
|
||||||
|
- `shift shift` - Press Shift twice in sequence
|
||||||
|
- `esc` - Escape key (immediate)
|
||||||
|
|
||||||
|
4. **Test focus behavior**:
|
||||||
|
- Click on the test element to focus it (turns blue)
|
||||||
|
- Try shortcuts with focus
|
||||||
|
- Click outside to remove focus
|
||||||
|
- Try shortcuts without focus
|
||||||
|
- The log shows whether the element had focus when triggered
|
||||||
|
|
||||||
|
5. **Test input protection**:
|
||||||
|
- Try typing in the input field
|
||||||
|
- Use Ctrl+C, Ctrl+A, etc. - should work normally
|
||||||
|
- Shortcuts should NOT trigger while typing in input
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
### Smart Timeout Examples
|
||||||
|
|
||||||
|
**Scenario 1**: Only "A" is configured (no element has "A B")
|
||||||
|
- Press A → Triggers **immediately**
|
||||||
|
|
||||||
|
**Scenario 2**: At least one element has "A B"
|
||||||
|
- Press A → **ALL elements with "A" wait 500ms**
|
||||||
|
- If B pressed within 500ms → Only elements with "A B" trigger
|
||||||
|
- If timeout expires → ALL elements with "A" trigger
|
||||||
|
|
||||||
|
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
|
||||||
|
- Press A → Waits (because "A B" exists)
|
||||||
|
- Press B → Waits (because "A B C" exists)
|
||||||
|
- Press C → Triggers "A B C" **immediately**
|
||||||
|
|
||||||
|
**Scenario 4**: Multiple elements, ESC on all
|
||||||
|
```javascript
|
||||||
|
add_keyboard_support('modal', '{"esc": "/close"}');
|
||||||
|
add_keyboard_support('editor', '{"esc": "/cancel"}');
|
||||||
|
```
|
||||||
|
- Press ESC → **Both trigger simultaneously** (no longer sequences)
|
||||||
|
|
||||||
|
## Integration in Your Project
|
||||||
|
|
||||||
|
## Integration in Your Project
|
||||||
|
|
||||||
|
### Configuration Format
|
||||||
|
|
||||||
|
The library now uses **HTMX configuration objects** instead of simple URL strings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# New format with HTMX configuration
|
||||||
|
combinations = {
|
||||||
|
"Ctrl+S": {
|
||||||
|
"hx-post": "/save-url",
|
||||||
|
"hx-target": "#result",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"A B": {
|
||||||
|
"hx-post": "/sequence-url",
|
||||||
|
"hx-vals": {"extra": "data"}
|
||||||
|
},
|
||||||
|
"esc": {
|
||||||
|
"hx-get": "/cancel-url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# This will generate the JavaScript call
|
||||||
|
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported HTMX Attributes
|
||||||
|
|
||||||
|
You can use any HTMX attribute in the configuration object:
|
||||||
|
|
||||||
|
**HTTP Methods** (one required):
|
||||||
|
- `hx-post` - POST request
|
||||||
|
- `hx-get` - GET request
|
||||||
|
- `hx-put` - PUT request
|
||||||
|
- `hx-delete` - DELETE request
|
||||||
|
- `hx-patch` - PATCH request
|
||||||
|
|
||||||
|
**Common Options**:
|
||||||
|
- `hx-target` - Target element selector
|
||||||
|
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
|
||||||
|
- `hx-vals` - Additional values to send (object)
|
||||||
|
- `hx-headers` - Custom headers (object)
|
||||||
|
- `hx-select` - Select specific content from response
|
||||||
|
- `hx-confirm` - Confirmation message
|
||||||
|
|
||||||
|
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
|
||||||
|
|
||||||
|
### Automatic Parameters
|
||||||
|
|
||||||
|
The library automatically adds these parameters to every request:
|
||||||
|
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus
|
||||||
|
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
|
||||||
|
|
||||||
|
Example final request:
|
||||||
|
```javascript
|
||||||
|
htmx.ajax('POST', '/save-url', {
|
||||||
|
target: '#result',
|
||||||
|
swap: 'innerHTML',
|
||||||
|
values: {
|
||||||
|
extra: "data", // from hx-vals
|
||||||
|
combination: "Ctrl+S", // automatic
|
||||||
|
has_focus: true, // automatic
|
||||||
|
is_inside: true // automatic
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"Ctrl+S": {
|
||||||
|
"hx-post": "/save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Target and Swap
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"Ctrl+D": {
|
||||||
|
"hx-delete": "/item",
|
||||||
|
"hx-target": "#item-list",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Extra Values
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"Ctrl+N": {
|
||||||
|
"hx-post": "/create",
|
||||||
|
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Elements Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Modal close
|
||||||
|
modal_combinations = {
|
||||||
|
"esc": {
|
||||||
|
"hx-post": "/modal/close",
|
||||||
|
"hx-target": "#modal",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Editor cancel
|
||||||
|
editor_combinations = {
|
||||||
|
"esc": {
|
||||||
|
"hx-post": "/editor/cancel",
|
||||||
|
"hx-target": "#editor",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both will trigger when ESC is pressed
|
||||||
|
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
||||||
|
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Keyboard Support
|
||||||
|
|
||||||
|
When you no longer need keyboard support for an element:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Remove keyboard support
|
||||||
|
f"remove_keyboard_support('{element_id}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Removes the element from the keyboard registry
|
||||||
|
- If this was the last element, automatically detaches global event listeners
|
||||||
|
- Cleans up all associated state (timeouts, snapshots, etc.)
|
||||||
|
- Other elements continue to work normally
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
// Add support
|
||||||
|
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
|
||||||
|
|
||||||
|
// Later, remove support
|
||||||
|
remove_keyboard_support('modal');
|
||||||
|
// If no other elements remain, keyboard listeners are completely removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_keyboard_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds keyboard support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
### remove_keyboard_support(elementId)
|
||||||
|
|
||||||
|
Removes keyboard support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Trie-based Matching
|
||||||
|
|
||||||
|
The library uses a prefix tree (trie) data structure:
|
||||||
|
- Each node represents a keyboard snapshot (set of pressed keys)
|
||||||
|
- Leaf nodes contain the HTMX configuration object
|
||||||
|
- Intermediate nodes indicate longer sequences exist
|
||||||
|
- Enables efficient O(n) matching where n is sequence length
|
||||||
|
|
||||||
|
### HTMX Integration
|
||||||
|
|
||||||
|
Configuration objects are mapped to htmx.ajax() calls:
|
||||||
|
- `hx-*` attributes are converted to camelCase parameters
|
||||||
|
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||||
|
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||||
|
- All standard HTMX options are supported
|
||||||
|
|
||||||
|
### Key Normalization
|
||||||
|
|
||||||
|
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
|
||||||
|
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
|
||||||
|
- Simultaneous keys represented as sorted sets
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The test page mocks `htmx.ajax` to display results in the console
|
||||||
|
- In production, real AJAX calls will be made to your backend
|
||||||
|
- Sequence timeout is 500ms between keys
|
||||||
|
- Maximum 10 snapshots kept in history to prevent memory issues
|
||||||
439
docs/Mouse Support.md
Normal file
439
docs/Mouse Support.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# Mouse Support - Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Supported Mouse Actions
|
||||||
|
|
||||||
|
**Basic Actions**:
|
||||||
|
- `click` - Left click
|
||||||
|
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||||
|
|
||||||
|
**Modified Actions**:
|
||||||
|
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||||
|
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||||
|
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||||
|
- `ctrl+shift+click` - Multiple modifiers
|
||||||
|
- Any combination of modifiers
|
||||||
|
|
||||||
|
**Sequences**:
|
||||||
|
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||||
|
- `click click` - Double click sequence
|
||||||
|
- `ctrl+click click` - Ctrl+click then normal click
|
||||||
|
- Any sequence of actions
|
||||||
|
|
||||||
|
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||||
|
|
||||||
|
### Smart Timeout Logic
|
||||||
|
|
||||||
|
Same as keyboard support:
|
||||||
|
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||||
|
- Timeout is 500ms between actions
|
||||||
|
- Immediate trigger if no longer sequences exist
|
||||||
|
|
||||||
|
### Multiple Element Support
|
||||||
|
|
||||||
|
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||||
|
|
||||||
|
## Configuration Format
|
||||||
|
|
||||||
|
Uses HTMX configuration objects (same as keyboard support):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/handle-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/handle-ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"rclick": { // Alias for right_click
|
||||||
|
"hx-post": "/context-menu"
|
||||||
|
},
|
||||||
|
"click rclick": { // Can use rclick in sequences too
|
||||||
|
"hx-post": "/sequence-action",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_mouse_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds mouse support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
add_mouse_support('button1', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/click"},
|
||||||
|
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### remove_mouse_support(elementId)
|
||||||
|
|
||||||
|
Removes mouse support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
remove_mouse_support('button1');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Parameters
|
||||||
|
|
||||||
|
The library automatically adds these parameters to every HTMX request:
|
||||||
|
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||||
|
- `is_inside` - Boolean indicating if the click was inside the element
|
||||||
|
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||||
|
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||||
|
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||||
|
|
||||||
|
### Parameter Details
|
||||||
|
|
||||||
|
**`has_focus`**:
|
||||||
|
- `true` if the registered element currently has focus
|
||||||
|
- `false` otherwise
|
||||||
|
- Useful for knowing if the element was the active element
|
||||||
|
|
||||||
|
**`clicked_inside`**:
|
||||||
|
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||||
|
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||||
|
- Useful for "click outside to close" logic
|
||||||
|
|
||||||
|
**Example values sent**:
|
||||||
|
```javascript
|
||||||
|
// User clicks inside a modal
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: true,
|
||||||
|
clicked_inside: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: false // Perfect for closing the modal!
|
||||||
|
}
|
||||||
|
|
||||||
|
// User right-clicks on an item
|
||||||
|
{
|
||||||
|
combination: "right_click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: true // Always true for right_click
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Integration
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/item/select"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/item/select-multiple",
|
||||||
|
"hx-vals": json.dumps({"mode": "multi"})
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/item/context-menu",
|
||||||
|
"hx-target": "#context-menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequences
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/single-click"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/double-click-sequence"
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/click-then-right-click"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Elements
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Item 1
|
||||||
|
item1_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/1/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/1/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
|
||||||
|
|
||||||
|
# Item 2
|
||||||
|
item2_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/2/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/2/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior Details
|
||||||
|
|
||||||
|
### Click vs Right-Click Behavior
|
||||||
|
|
||||||
|
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||||
|
|
||||||
|
**`click` (global detection)**:
|
||||||
|
- Triggers for ALL registered elements, regardless of where you click
|
||||||
|
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||||
|
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||||
|
|
||||||
|
**`right_click` (element-specific detection)**:
|
||||||
|
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||||
|
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||||
|
- This preserves normal browser behavior while adding custom actions on your elements
|
||||||
|
|
||||||
|
**Example use case**:
|
||||||
|
```javascript
|
||||||
|
// Modal that closes when clicking anywhere
|
||||||
|
add_mouse_support('modal', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Context menu that only appears on element
|
||||||
|
add_mouse_support('item', JSON.stringify({
|
||||||
|
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifier Keys (Cross-Platform)
|
||||||
|
|
||||||
|
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||||
|
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||||
|
- This follows standard web conventions for cross-platform compatibility
|
||||||
|
|
||||||
|
### Input Context Protection
|
||||||
|
|
||||||
|
Mouse actions are **disabled** when clicking in input fields:
|
||||||
|
- `<input>` elements
|
||||||
|
- `<textarea>` elements
|
||||||
|
- Any `contenteditable` element
|
||||||
|
|
||||||
|
This ensures normal text selection and interaction works in forms.
|
||||||
|
|
||||||
|
### Right-Click Menu
|
||||||
|
|
||||||
|
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||||
|
- A `right_click` action is configured for the element
|
||||||
|
- The element is NOT an input/textarea/contenteditable
|
||||||
|
|
||||||
|
### Event Bubbling
|
||||||
|
|
||||||
|
The library checks if the click target OR any parent element is registered:
|
||||||
|
```html
|
||||||
|
<div id="container">
|
||||||
|
<button>Click me</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
If `container` is registered, clicking the button will trigger the container's actions.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Using Aliases
|
||||||
|
|
||||||
|
You can use `rclick` instead of `right_click` anywhere:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// These are equivalent
|
||||||
|
const config1 = {
|
||||||
|
"right_click": {"hx-post": "/menu"}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config2 = {
|
||||||
|
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works in sequences too
|
||||||
|
const config3 = {
|
||||||
|
"click rclick": {"hx-post": "/sequence"}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works with modifiers
|
||||||
|
const config4 = {
|
||||||
|
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Menu
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/show-context-menu",
|
||||||
|
"hx-target": "#menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Close Modal/Popup on Click Outside
|
||||||
|
|
||||||
|
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modal element
|
||||||
|
const modalCombinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/close-modal",
|
||||||
|
"hx-target": "#modal",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||||
|
|
||||||
|
// Now clicking ANYWHERE on the page will trigger the handler
|
||||||
|
// Your backend receives:
|
||||||
|
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||||
|
// - clicked_inside: false (if clicked outside) → close it!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend example (Python/Flask)**:
|
||||||
|
```python
|
||||||
|
@app.route('/close-modal', methods=['POST'])
|
||||||
|
def close_modal():
|
||||||
|
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||||
|
|
||||||
|
if clicked_inside:
|
||||||
|
# User clicked inside the modal - keep it open
|
||||||
|
return render_template('modal_content.html')
|
||||||
|
else:
|
||||||
|
# User clicked outside - close the modal
|
||||||
|
return '' # Empty response removes the modal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||||
|
|
||||||
|
### Multi-Select List
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "single"}
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "toggle"}
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/select-range",
|
||||||
|
"hx-vals": {"mode": "range"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Canvas/Drawing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/draw-point"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/draw-special"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/confirm-action"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag-and-Drop Alternative
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-source"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/set-destination"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Clicks not detected
|
||||||
|
|
||||||
|
- Verify the element exists and has the correct ID
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure HTMX is loaded before mouse_support.js
|
||||||
|
|
||||||
|
### Right-click menu still appears
|
||||||
|
|
||||||
|
- Check if element is in input context (input/textarea)
|
||||||
|
- Verify the combination is configured correctly
|
||||||
|
- Check browser console for configuration errors
|
||||||
|
|
||||||
|
### Sequences not working
|
||||||
|
|
||||||
|
- Ensure clicks happen within 500ms timeout
|
||||||
|
- Check if longer sequences exist (causes waiting)
|
||||||
|
- Verify the combination string format (space-separated)
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||||
|
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||||
|
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||||
|
- **Independent from keyboard support** (separate registry and timeouts)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Single event listener regardless of number of elements
|
||||||
|
- O(n) matching where n is sequence length
|
||||||
|
- Efficient memory usage with automatic cleanup
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers (ES6+ required)
|
||||||
|
- Chrome, Firefox, Safari, Edge
|
||||||
|
- Requires HTMX library
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||||
|
- Can be used independently or alongside keyboard support
|
||||||
|
- Does not interfere with normal mouse behavior in inputs
|
||||||
|
- Element must exist in DOM when `add_mouse_support()` is called
|
||||||
|
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||||
@@ -16,6 +16,7 @@ dnspython==2.8.0
|
|||||||
docutils==0.22.2
|
docutils==0.22.2
|
||||||
ecdsa==0.19.1
|
ecdsa==0.19.1
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
|
et_xmlfile==2.0.0
|
||||||
fastapi==0.120.0
|
fastapi==0.120.0
|
||||||
fastcore==1.8.13
|
fastcore==1.8.13
|
||||||
fastlite==0.2.1
|
fastlite==0.2.1
|
||||||
@@ -35,12 +36,15 @@ keyring==25.6.0
|
|||||||
markdown-it-py==4.0.0
|
markdown-it-py==4.0.0
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
more-itertools==10.8.0
|
more-itertools==10.8.0
|
||||||
myauth==0.2.0
|
myauth==0.2.1
|
||||||
mydbengine==0.1.0
|
mydbengine==0.1.0
|
||||||
myutils==0.4.0
|
myutils==0.4.0
|
||||||
nh3==0.3.1
|
nh3==0.3.1
|
||||||
|
numpy==2.3.5
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
|
openpyxl==3.1.5
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
|
pandas==2.3.3
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pipdeptree==2.29.0
|
pipdeptree==2.29.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
@@ -57,6 +61,7 @@ python-dotenv==1.1.1
|
|||||||
python-fasthtml==0.12.30
|
python-fasthtml==0.12.30
|
||||||
python-jose==3.5.0
|
python-jose==3.5.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
pytz==2025.2
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
readme_renderer==44.0
|
readme_renderer==44.0
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
@@ -74,6 +79,7 @@ twine==6.2.0
|
|||||||
typer==0.20.0
|
typer==0.20.0
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
|
tzdata==2025.2
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
uvicorn==0.38.0
|
uvicorn==0.38.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
|
|||||||
39
src/app.py
39
src/app.py
@@ -4,12 +4,16 @@ import yaml
|
|||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||||
|
from myfasthtml.controls.Dropdown import Dropdown
|
||||||
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||||
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.instances import InstancesManager, RootInstance
|
from myfasthtml.core.instances import UniqueInstance
|
||||||
from myfasthtml.icons.carbon import volume_object_storage
|
from myfasthtml.icons.carbon import volume_object_storage
|
||||||
|
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
with open('logging.yaml', 'r') as f:
|
with open('logging.yaml', 'r') as f:
|
||||||
@@ -29,32 +33,47 @@ app, rt = create_app(protect_routes=True,
|
|||||||
|
|
||||||
@rt("/")
|
@rt("/")
|
||||||
def index(session):
|
def index(session):
|
||||||
layout = InstancesManager.get(session, Ids.Layout, Layout, RootInstance, "Testing Layout")
|
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||||
|
layout = Layout(session_instance, "Testing Layout")
|
||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
|
|
||||||
tabs_manager = TabsManager(layout, _id=f"{Ids.TabsManager}-main")
|
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||||
|
add_tab = tabs_manager.commands.add_tab
|
||||||
btn_show_right_drawer = mk.button("show",
|
btn_show_right_drawer = mk.button("show",
|
||||||
command=layout.commands.toggle_drawer("right"),
|
command=layout.commands.toggle_drawer("right"),
|
||||||
id="btn_show_right_drawer_id")
|
id="btn_show_right_drawer_id")
|
||||||
|
|
||||||
instances_debugger = InstancesManager.get(session, Ids.InstancesDebugger, InstancesDebugger, layout)
|
instances_debugger = InstancesDebugger(layout)
|
||||||
btn_show_instances_debugger = mk.label("Instances",
|
btn_show_instances_debugger = mk.label("Instances",
|
||||||
icon=volume_object_storage,
|
icon=volume_object_storage,
|
||||||
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
|
command=add_tab("Instances", instances_debugger),
|
||||||
id=instances_debugger.get_id())
|
id=instances_debugger.get_id())
|
||||||
|
|
||||||
commands_debugger = InstancesManager.get(session, Ids.CommandsDebugger, CommandsDebugger, layout)
|
commands_debugger = CommandsDebugger(layout)
|
||||||
btn_show_commands_debugger = mk.label("Commands",
|
btn_show_commands_debugger = mk.label("Commands",
|
||||||
icon=None,
|
icon=None,
|
||||||
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
|
command=add_tab("Commands", commands_debugger),
|
||||||
id=commands_debugger.get_id())
|
id=commands_debugger.get_id())
|
||||||
|
|
||||||
|
btn_file_upload = mk.label("Upload",
|
||||||
|
icon=folder_open20_regular,
|
||||||
|
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||||
|
id="file_upload_id")
|
||||||
|
|
||||||
|
btn_popup = mk.label("Popup",
|
||||||
|
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
|
||||||
|
|
||||||
layout.header_left.add(tabs_manager.add_tab_btn())
|
layout.header_left.add(tabs_manager.add_tab_btn())
|
||||||
layout.header_right.add(btn_show_right_drawer)
|
layout.header_right.add(btn_show_right_drawer)
|
||||||
layout.left_drawer.add(btn_show_instances_debugger)
|
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_show_commands_debugger)
|
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||||
|
layout.left_drawer.add(btn_file_upload, "Test")
|
||||||
|
layout.left_drawer.add(btn_popup, "Test")
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
return layout
|
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||||
|
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
|
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
|
return layout, keyboard
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||||
|
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
--spacing: 0.25rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--default-font-family: var(--font-sans);
|
||||||
|
--default-mono-font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -219,6 +230,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base resizer styles */
|
/* Base resizer styles */
|
||||||
@@ -299,6 +311,16 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mf-layout-group {
|
||||||
|
font-weight: bold;
|
||||||
|
/*font-size: var(--text-sm);*/
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
/* *********** Tabs Manager Component ************ */
|
/* *********** Tabs Manager Component ************ */
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
@@ -418,3 +440,29 @@
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mf-dropdown-wrapper {
|
||||||
|
position: relative; /* CRUCIAL for the anchor */
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mf-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 200px;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: auto;
|
||||||
|
/*opacity: 0;*/
|
||||||
|
/*transition: opacity 0.2s ease-in-out;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-dropdown.is-visible {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ from ..auth.utils import (
|
|||||||
logout_user,
|
logout_user,
|
||||||
get_user_info
|
get_user_info
|
||||||
)
|
)
|
||||||
|
from ..core.instances import InstancesManager
|
||||||
|
|
||||||
|
|
||||||
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
||||||
@@ -181,6 +182,9 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b
|
|||||||
if refresh_token:
|
if refresh_token:
|
||||||
logout_user(refresh_token)
|
logout_user(refresh_token)
|
||||||
|
|
||||||
|
# release memory
|
||||||
|
InstancesManager.clear_session(session)
|
||||||
|
|
||||||
# Clear session
|
# Clear session
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fasthtml.common import RedirectResponse, Beforeware
|
|||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
API_BASE_URL = "http://localhost:5001" # Base URL for FastAPI backend
|
API_BASE_URL = "http://localhost:5003" # Base URL for FastAPI backend
|
||||||
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
|
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes
|
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class Boundaries(SingleInstance):
|
|||||||
Keep the boundaries updated
|
Keep the boundaries updated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, owner, container_id: str = None, on_resize=None):
|
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||||
super().__init__(session, Ids.Boundaries, owner)
|
super().__init__(owner, _id=_id)
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._container_id = container_id or owner.get_id()
|
self._container_id = container_id or owner.get_id()
|
||||||
self._on_resize = on_resize
|
self._on_resize = on_resize
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.commands import CommandsManager
|
from myfasthtml.core.commands import CommandsManager
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class CommandsDebugger(SingleInstance):
|
class CommandsDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(session, Ids.CommandsDebugger, parent)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
commands = self._get_commands()
|
commands = self._get_commands()
|
||||||
|
|||||||
94
src/myfasthtml/controls/Dropdown.py
Normal file
94
src/myfasthtml/controls/Dropdown.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from fastcore.xml import FT
|
||||||
|
from fasthtml.components import Div
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
|
from myfasthtml.controls.Mouse import Mouse
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def close(self):
|
||||||
|
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||||
|
|
||||||
|
def click(self):
|
||||||
|
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||||
|
|
||||||
|
|
||||||
|
class DropdownState:
|
||||||
|
def __init__(self):
|
||||||
|
self.opened = False
|
||||||
|
|
||||||
|
|
||||||
|
class Dropdown(MultipleInstance):
|
||||||
|
def __init__(self, parent, content=None, button=None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.button = Div(button) if not isinstance(button, FT) else button
|
||||||
|
self.content = content
|
||||||
|
self.commands = Commands(self)
|
||||||
|
self._state = DropdownState()
|
||||||
|
self._toggle_command = self.commands.toggle()
|
||||||
|
|
||||||
|
def toggle(self):
|
||||||
|
self._state.opened = not self._state.opened
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._state.opened = False
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def on_click(self, combination, is_inside: bool):
|
||||||
|
if combination == "click":
|
||||||
|
self._state.opened = is_inside
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def _mk_content(self):
|
||||||
|
return Div(self.content,
|
||||||
|
cls=f"mf-dropdown {'is-visible' if self._state.opened else ''}",
|
||||||
|
id=f"{self._id}-content"),
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
Div(self.button) if self.button else Div("None"),
|
||||||
|
self._mk_content(),
|
||||||
|
cls="mf-dropdown-wrapper"
|
||||||
|
),
|
||||||
|
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
|
||||||
|
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||||
|
id=self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
|
|
||||||
|
# document.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
|
||||||
|
#
|
||||||
|
# // Vérifie si c'est bien notre popup
|
||||||
|
# if (targetElement.classList.contains('mf-popup-container')) {
|
||||||
|
#
|
||||||
|
# // Trouver l'élément déclencheur HTMX (le bouton existant)
|
||||||
|
# // HTMX stocke l'élément déclencheur dans event.detail.elt
|
||||||
|
# const trigger = document.querySelector('#mon-bouton-existant');
|
||||||
|
#
|
||||||
|
# if (trigger) {
|
||||||
|
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
|
||||||
|
# const rect = trigger.getBoundingClientRect();
|
||||||
|
#
|
||||||
|
# // L'élément du popup à positionner
|
||||||
|
# const popup = targetElement;
|
||||||
|
#
|
||||||
|
# // Appliquer la position au conteneur du popup
|
||||||
|
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
|
||||||
|
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
|
||||||
|
#
|
||||||
|
# // Top: Juste en dessous de l'élément déclencheur
|
||||||
|
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
|
||||||
|
#
|
||||||
|
# // Left: Aligner avec le côté gauche de l'élément déclencheur
|
||||||
|
# popup.style.left = (rect.left + window.scrollX) + 'px';
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# });
|
||||||
98
src/myfasthtml/controls/FileUpload.py
Normal file
98
src/myfasthtml/controls/FileUpload.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import logging
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger("FileUpload")
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadState(DbObject):
|
||||||
|
def __init__(self, owner):
|
||||||
|
super().__init__(owner)
|
||||||
|
with self.initializing():
|
||||||
|
# persisted in DB
|
||||||
|
|
||||||
|
# must not be persisted in DB (prefix ns_ = no_saving_)
|
||||||
|
self.ns_file_name: str | None = None
|
||||||
|
self.ns_sheets_names: list | None = None
|
||||||
|
self.ns_selected_sheet_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def __init__(self, owner):
|
||||||
|
super().__init__(owner)
|
||||||
|
|
||||||
|
def upload_file(self):
|
||||||
|
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpload(MultipleInstance):
|
||||||
|
|
||||||
|
def __init__(self, parent, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.commands = Commands(self)
|
||||||
|
self._state = FileUploadState(self)
|
||||||
|
|
||||||
|
def upload_file(self, file: UploadFile):
|
||||||
|
logger.debug(f"upload_file: {file=}")
|
||||||
|
if file:
|
||||||
|
file_content = file.file.read()
|
||||||
|
self._state.ns_sheets_names = self.get_sheets_names(file_content)
|
||||||
|
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
||||||
|
|
||||||
|
return self.mk_sheet_selector()
|
||||||
|
|
||||||
|
def mk_sheet_selector(self):
|
||||||
|
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
|
||||||
|
[Option(
|
||||||
|
name,
|
||||||
|
selected=True if name == self._state.ns_selected_sheet_name else None,
|
||||||
|
) for name in self._state.ns_sheets_names]
|
||||||
|
|
||||||
|
return Select(
|
||||||
|
*options,
|
||||||
|
name="sheet_name",
|
||||||
|
id=f"sn_{self._id}", # sn stands for 'sheet name'
|
||||||
|
cls="select select-bordered select-sm w-full ml-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_sheets_names(file_content):
|
||||||
|
try:
|
||||||
|
excel_file = pd.ExcelFile(BytesIO(file_content))
|
||||||
|
sheet_names = excel_file.sheet_names
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"get_sheets_names: {ex=}")
|
||||||
|
sheet_names = []
|
||||||
|
|
||||||
|
return sheet_names
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
mk.mk(Input(type='file',
|
||||||
|
name='file',
|
||||||
|
id=f"fi_{self._id}", # fn stands for 'file name'
|
||||||
|
value=self._state.ns_file_name,
|
||||||
|
hx_preserve="true",
|
||||||
|
hx_encoding='multipart/form-data',
|
||||||
|
cls="file-input file-input-bordered file-input-sm w-full",
|
||||||
|
),
|
||||||
|
command=self.commands.upload_file()
|
||||||
|
),
|
||||||
|
self.mk_sheet_selector(),
|
||||||
|
cls="flex"
|
||||||
|
),
|
||||||
|
mk.dialog_buttons(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class InstancesDebugger(SingleInstance):
|
class InstancesDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(session, Ids.InstancesDebugger, parent)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
s_name = InstancesManager.get_session_user_name
|
||||||
instances = self._get_instances()
|
instances = self._get_instances()
|
||||||
nodes, edges = from_parent_child_list(instances,
|
nodes, edges = from_parent_child_list(
|
||||||
id_getter=lambda x: x.get_id(),
|
instances,
|
||||||
label_getter=lambda x: x.get_prefix(),
|
id_getter=lambda x: x.get_full_id(),
|
||||||
parent_getter=lambda x: x.get_parent().get_id() if x.get_parent() else None
|
label_getter=lambda x: f"{x.get_id()}",
|
||||||
|
parent_getter=lambda x: x.get_full_parent_id()
|
||||||
)
|
)
|
||||||
|
for edge in edges:
|
||||||
|
edge["color"] = "green"
|
||||||
|
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||||
|
|
||||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
for node in nodes:
|
||||||
|
node["shape"] = "box"
|
||||||
|
|
||||||
|
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||||
|
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
|
||||||
return vis_network
|
return vis_network
|
||||||
|
|
||||||
def _get_instances(self):
|
def _get_instances(self):
|
||||||
|
|||||||
23
src/myfasthtml/controls/Keyboard.py
Normal file
23
src/myfasthtml/controls/Keyboard.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
|
from myfasthtml.core.commands import BaseCommand
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Keyboard(MultipleInstance):
|
||||||
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
self.combinations[sequence] = command
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||||
|
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -12,10 +12,10 @@ from fasthtml.common import *
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Boundaries import Boundaries
|
from myfasthtml.controls.Boundaries import Boundaries
|
||||||
from myfasthtml.controls.UserProfile import UserProfile
|
from myfasthtml.controls.UserProfile import UserProfile
|
||||||
from myfasthtml.controls.helpers import mk, Ids
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.utils import get_id
|
from myfasthtml.core.utils import get_id
|
||||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
||||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
||||||
@@ -25,7 +25,7 @@ logger = logging.getLogger("LayoutControl")
|
|||||||
|
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
self.right_drawer_open: bool = True
|
self.right_drawer_open: bool = True
|
||||||
@@ -69,14 +69,27 @@ class Layout(SingleInstance):
|
|||||||
class Content:
|
class Content:
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._content = []
|
self._content = {}
|
||||||
|
self._groups = []
|
||||||
self._ids = set()
|
self._ids = set()
|
||||||
|
|
||||||
def add(self, content):
|
def add_group(self, group, group_ft=None):
|
||||||
|
group_ft = group_ft or Div(group, cls="mf-layout-group")
|
||||||
|
if not group:
|
||||||
|
group_ft = None
|
||||||
|
self._groups.append((group, group_ft))
|
||||||
|
self._content[group] = []
|
||||||
|
|
||||||
|
def add(self, content, group=None):
|
||||||
content_id = get_id(content)
|
content_id = get_id(content)
|
||||||
if content_id in self._ids:
|
if content_id in self._ids:
|
||||||
return
|
return
|
||||||
self._content.append(content)
|
|
||||||
|
if group not in self._content:
|
||||||
|
self.add_group(group)
|
||||||
|
self._content[group] = []
|
||||||
|
|
||||||
|
self._content[group].append(content)
|
||||||
|
|
||||||
if content_id is not None:
|
if content_id is not None:
|
||||||
self._ids.add(content_id)
|
self._ids.add(content_id)
|
||||||
@@ -84,7 +97,10 @@ class Layout(SingleInstance):
|
|||||||
def get_content(self):
|
def get_content(self):
|
||||||
return self._content
|
return self._content
|
||||||
|
|
||||||
def __init__(self, session, app_name, parent=None):
|
def get_groups(self):
|
||||||
|
return self._groups
|
||||||
|
|
||||||
|
def __init__(self, parent, app_name, _id=None):
|
||||||
"""
|
"""
|
||||||
Initialize the Layout component.
|
Initialize the Layout component.
|
||||||
|
|
||||||
@@ -93,13 +109,13 @@ class Layout(SingleInstance):
|
|||||||
left_drawer (bool): Enable left drawer. Default is True.
|
left_drawer (bool): Enable left drawer. Default is True.
|
||||||
right_drawer (bool): Enable right drawer. Default is True.
|
right_drawer (bool): Enable right drawer. Default is True.
|
||||||
"""
|
"""
|
||||||
super().__init__(session, Ids.Layout, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
# Content storage
|
# Content storage
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(self)
|
self._state = LayoutState(self)
|
||||||
self._boundaries = Boundaries(session, self)
|
self._boundaries = Boundaries(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.Content(self)
|
self.left_drawer = self.Content(self)
|
||||||
self.right_drawer = self.Content(self)
|
self.right_drawer = self.Content(self)
|
||||||
@@ -176,8 +192,8 @@ class Layout(SingleInstance):
|
|||||||
cls="flex gap-1"
|
cls="flex gap-1"
|
||||||
),
|
),
|
||||||
Div( # right
|
Div( # right
|
||||||
*self.header_right.get_content(),
|
*self.header_right.get_content()[None],
|
||||||
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
|
UserProfile(self),
|
||||||
cls="flex gap-1"
|
cls="flex gap-1"
|
||||||
),
|
),
|
||||||
cls="mf-layout-header"
|
cls="mf-layout-header"
|
||||||
@@ -224,7 +240,14 @@ class Layout(SingleInstance):
|
|||||||
|
|
||||||
# Wrap content in scrollable container
|
# Wrap content in scrollable container
|
||||||
content_wrapper = Div(
|
content_wrapper = Div(
|
||||||
*self.left_drawer.get_content(),
|
*[
|
||||||
|
(
|
||||||
|
Div(cls="divider") if index > 0 else None,
|
||||||
|
group_ft,
|
||||||
|
*[item for item in self.left_drawer.get_content()[group_name]]
|
||||||
|
)
|
||||||
|
for index, (group_name, group_ft) in enumerate(self.left_drawer.get_groups())
|
||||||
|
],
|
||||||
cls="mf-layout-drawer-content"
|
cls="mf-layout-drawer-content"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
23
src/myfasthtml/controls/Mouse.py
Normal file
23
src/myfasthtml/controls/Mouse.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
|
from myfasthtml.core.commands import BaseCommand
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Mouse(MultipleInstance):
|
||||||
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
self.combinations[sequence] = command
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||||
|
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -4,7 +4,7 @@ from typing import Callable, Any
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||||
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
||||||
@@ -35,14 +35,13 @@ class Search(MultipleInstance):
|
|||||||
a callable for extracting a string value from items, and a template callable for rendering
|
a callable for extracting a string value from items, and a template callable for rendering
|
||||||
the filtered items. It provides functionality to handle and organize item-based operations.
|
the filtered items. It provides functionality to handle and organize item-based operations.
|
||||||
|
|
||||||
:param session: The session object to maintain state or context across operations.
|
|
||||||
:param _id: Optional identifier for the component.
|
:param _id: Optional identifier for the component.
|
||||||
:param items: An optional list of names for the items to be filtered.
|
:param items: An optional list of names for the items to be filtered.
|
||||||
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
||||||
function that returns the item as is.
|
function that returns the item as is.
|
||||||
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
||||||
"""
|
"""
|
||||||
super().__init__(Ids.Search, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.items_names = items_names or ''
|
self.items_names = items_names or ''
|
||||||
self.items = items or []
|
self.items = items or []
|
||||||
self.filtered = self.items.copy()
|
self.filtered = self.items.copy()
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ from fasthtml.xtend import Script
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Search import Search
|
from myfasthtml.controls.Search import Search
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager
|
||||||
from myfasthtml.core.instances_helper import InstancesHelper
|
|
||||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ class Boundaries:
|
|||||||
|
|
||||||
class TabsManagerState(DbObject):
|
class TabsManagerState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.tabs: dict[str, Any] = {}
|
self.tabs: dict[str, Any] = {}
|
||||||
@@ -78,14 +77,15 @@ class TabsManager(MultipleInstance):
|
|||||||
_tab_count = 0
|
_tab_count = 0
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(Ids.TabsManager, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._boundaries = Boundaries()
|
self._boundaries = Boundaries()
|
||||||
self._search = Search(self,
|
self._search = Search(self,
|
||||||
items=self._get_tab_list(),
|
items=self._get_tab_list(),
|
||||||
get_attr=lambda x: x["label"],
|
get_attr=lambda x: x["label"],
|
||||||
template=self._mk_tab_button)
|
template=self._mk_tab_button,
|
||||||
|
_id="-search")
|
||||||
logger.debug(f"TabsManager created with id: {self._id}")
|
logger.debug(f"TabsManager created with id: {self._id}")
|
||||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||||
logger.debug(f" active tab : {self._state.active_tab}")
|
logger.debug(f" active tab : {self._state.active_tab}")
|
||||||
@@ -102,7 +102,7 @@ class TabsManager(MultipleInstance):
|
|||||||
tab_config = self._state.tabs[tab_id]
|
tab_config = self._state.tabs[tab_id]
|
||||||
if tab_config["component_type"] is None:
|
if tab_config["component_type"] is None:
|
||||||
return None
|
return None
|
||||||
return InstancesHelper.dynamic_get(self, tab_config["component_type"], tab_config["component_id"])
|
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, RootInstance
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
||||||
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
||||||
@@ -15,6 +16,7 @@ class UserProfileState:
|
|||||||
self._session = owner.get_session()
|
self._session = owner.get_session()
|
||||||
|
|
||||||
self.theme = "light"
|
self.theme = "light"
|
||||||
|
self.load()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
@@ -25,7 +27,7 @@ class UserProfileState:
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||||
auth_proxy = InstancesManager.get_auth_proxy()
|
auth_proxy = AuthProxy(RootInstance)
|
||||||
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||||
|
|
||||||
|
|
||||||
@@ -35,14 +37,15 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfile(SingleInstance):
|
class UserProfile(SingleInstance):
|
||||||
def __init__(self, session, parent=None):
|
def __init__(self, parent=None, _id=None):
|
||||||
super().__init__(session, Ids.UserProfile, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = UserProfileState(self)
|
self._state = UserProfileState(self)
|
||||||
self._commands = Commands(self)
|
self._commands = Commands(self)
|
||||||
|
|
||||||
def update_dark_mode(self, client_response):
|
def update_dark_mode(self, client_response):
|
||||||
self._state.theme = client_response.get("theme", "light")
|
self._state.theme = client_response.get("theme", "light")
|
||||||
self._state.save()
|
self._state.save()
|
||||||
|
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import logging
|
|||||||
|
|
||||||
from fasthtml.components import Script, Div
|
from fasthtml.components import Script, Div
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ logger = logging.getLogger("VisNetwork")
|
|||||||
|
|
||||||
class VisNetworkState(DbObject):
|
class VisNetworkState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.nodes: list = []
|
self.nodes: list = []
|
||||||
@@ -30,7 +29,7 @@ class VisNetworkState(DbObject):
|
|||||||
|
|
||||||
class VisNetwork(MultipleInstance):
|
class VisNetwork(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||||
super().__init__(Ids.VisNetwork, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||||
|
|
||||||
self._state = VisNetworkState(self)
|
self._state = VisNetworkState(self)
|
||||||
@@ -51,6 +50,12 @@ class VisNetwork(MultipleInstance):
|
|||||||
|
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
|
|
||||||
|
def add_to_options(self, **kwargs):
|
||||||
|
logger.debug(f"add_to_options: {kwargs=}")
|
||||||
|
new_options = self._state.options.copy() | kwargs
|
||||||
|
self._update_state(None, None, new_options)
|
||||||
|
return self
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
|
||||||
# Serialize nodes and edges to JSON
|
# Serialize nodes and edges to JSON
|
||||||
|
|||||||
@@ -7,17 +7,8 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
|
|
||||||
class Ids:
|
class Ids:
|
||||||
# Please keep the alphabetical order
|
# Please keep the alphabetical order
|
||||||
AuthProxy = "mf-auth-proxy"
|
|
||||||
Boundaries = "mf-boundaries"
|
|
||||||
CommandsDebugger = "mf-commands-debugger"
|
|
||||||
DbManager = "mf-dbmanager"
|
|
||||||
InstancesDebugger = "mf-instances-debugger"
|
|
||||||
Layout = "mf-layout"
|
|
||||||
Root = "mf-root"
|
Root = "mf-root"
|
||||||
Search = "mf-search"
|
UserSession = "mf-user_session"
|
||||||
TabsManager = "mf-tabs-manager"
|
|
||||||
UserProfile = "mf-user-profile"
|
|
||||||
VisNetwork = "mf-vis-network"
|
|
||||||
|
|
||||||
|
|
||||||
class mk:
|
class mk:
|
||||||
@@ -26,6 +17,21 @@ class mk:
|
|||||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||||
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dialog_buttons(ok_title: str = "OK",
|
||||||
|
cancel_title: str = "Cancel",
|
||||||
|
on_ok: Command = None,
|
||||||
|
on_cancel: Command = None,
|
||||||
|
cls=None):
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
mk.button(ok_title, cls="btn btn-primary btn-sm mr-2", command=on_ok),
|
||||||
|
mk.button(cancel_title, cls="btn btn-ghost btn-sm", command=on_cancel),
|
||||||
|
cls="flex justify-end"
|
||||||
|
),
|
||||||
|
cls=merge_classes("flex justify-end w-full mt-1", cls)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def icon(icon, size=20,
|
def icon(icon, size=20,
|
||||||
can_select=True,
|
can_select=True,
|
||||||
@@ -86,6 +92,6 @@ class mk:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||||
ft = mk.manage_command(ft, command)
|
ft = mk.manage_command(ft, command) if command else ft
|
||||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
|
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||||
return ft
|
return ft
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.instances import UniqueInstance, RootInstance
|
|
||||||
|
|
||||||
|
|
||||||
class AuthProxy(UniqueInstance):
|
class AuthProxy(SingleInstance):
|
||||||
def __init__(self, base_url: str = None):
|
def __init__(self, parent, base_url: str = None):
|
||||||
super().__init__(Ids.AuthProxy, RootInstance)
|
super().__init__(parent)
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
|
|
||||||
def login_user(self, email: str, password: str):
|
def login_user(self, email: str, password: str):
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ class DataConverter:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LambdaConverter(DataConverter):
|
||||||
|
def __init__(self, func):
|
||||||
|
self.func = func
|
||||||
|
|
||||||
|
def convert(self, data):
|
||||||
|
return self.func(data)
|
||||||
|
|
||||||
|
|
||||||
class BooleanConverter(DataConverter):
|
class BooleanConverter(DataConverter):
|
||||||
def convert(self, data):
|
def convert(self, data):
|
||||||
if data is None:
|
if data is None:
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ class BaseCommand:
|
|||||||
return {
|
return {
|
||||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
"hx-vals": {"c_id": f"{self.id}"},
|
||||||
} | self._htmx_extra
|
} | self._htmx_extra
|
||||||
|
|
||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def htmx(self, target="this", swap="outerHTML", trigger=None):
|
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||||
# Note that the default value is the same than in get_htmx_params()
|
# Note that the default value is the same than in get_htmx_params()
|
||||||
if target is None:
|
if target is None:
|
||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
@@ -180,6 +180,15 @@ class Command(BaseCommand):
|
|||||||
return [ret] + ret_from_bindings
|
return [ret] + ret_from_bindings
|
||||||
|
|
||||||
|
|
||||||
|
class LambdaCommand(Command):
|
||||||
|
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||||
|
super().__init__(name, description, delegate)
|
||||||
|
self.htmx(target=None)
|
||||||
|
|
||||||
|
def execute(self, client_response: dict = None):
|
||||||
|
return self.callback(client_response)
|
||||||
|
|
||||||
|
|
||||||
class CommandsManager:
|
class CommandsManager:
|
||||||
commands = {}
|
commands = {}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from dbengine.dbengine import DbEngine
|
from dbengine.dbengine import DbEngine
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
|
|
||||||
|
|
||||||
class DbManager(SingleInstance):
|
class DbManager(SingleInstance):
|
||||||
def __init__(self, session, parent=None, root=".myFastHtmlDb", auto_register: bool = True):
|
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||||
super().__init__(session, Ids.DbManager, parent, auto_register=auto_register)
|
super().__init__(parent, auto_register=auto_register)
|
||||||
self.db = DbEngine(root=root)
|
self.db = DbEngine(root=root)
|
||||||
|
|
||||||
def save(self, entry, obj):
|
def save(self, entry, obj):
|
||||||
@@ -35,12 +34,12 @@ class DbObject:
|
|||||||
It loads from DB at startup
|
It loads from DB at startup
|
||||||
"""
|
"""
|
||||||
_initializing = False
|
_initializing = False
|
||||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||||
|
|
||||||
def __init__(self, session, name=None, db_manager=None):
|
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||||
self._session = session
|
self._owner = owner
|
||||||
self._name = name or self.__class__.__name__
|
self._name = name or self.__class__.__name__
|
||||||
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
|
self._db_manager = db_manager or DbManager(self._owner)
|
||||||
|
|
||||||
self._finalize_initialization()
|
self._finalize_initialization()
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ class DbObject:
|
|||||||
self._initializing = old_state
|
self._initializing = old_state
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: str):
|
def __setattr__(self, name: str, value: str):
|
||||||
if name.startswith("_") or getattr(self, "_initializing", False):
|
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
|
||||||
super().__setattr__(name, value)
|
super().__setattr__(name, value)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -74,7 +73,8 @@ class DbObject:
|
|||||||
self._save_self()
|
self._save_self()
|
||||||
|
|
||||||
def _save_self(self):
|
def _save_self(self):
|
||||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
|
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
|
||||||
|
not k.startswith("_") and not k.startswith("ns")}
|
||||||
if props:
|
if props:
|
||||||
self._db_manager.save(self._name, props)
|
self._db_manager.save(self._name, props)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Self
|
from typing import Optional
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.utils import pascal_to_snake
|
||||||
|
|
||||||
|
logger = logging.getLogger("InstancesManager")
|
||||||
|
|
||||||
special_session = {
|
special_session = {
|
||||||
"user_info": {"id": "** SPECIAL SESSION **"}
|
"user_info": {"id": "** SPECIAL SESSION **"}
|
||||||
@@ -18,44 +22,119 @@ class BaseInstance:
|
|||||||
Base class for all instances (manageable by InstancesManager)
|
Base class for all instances (manageable by InstancesManager)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, _id: str, parent: Self, auto_register: bool = True):
|
def __new__(cls, *args, **kwargs):
|
||||||
self._session = session
|
# Extract arguments from both positional and keyword arguments
|
||||||
self._id = _id
|
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
|
||||||
self._prefix = prefix
|
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
|
||||||
|
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
|
||||||
|
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
|
||||||
|
|
||||||
|
# Compute _id
|
||||||
|
_id = cls.compute_id(_id, parent)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
if parent is not None:
|
||||||
|
session = parent.get_session()
|
||||||
|
else:
|
||||||
|
raise TypeError("Either session or parent must be provided")
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, _id)
|
||||||
|
|
||||||
|
if key in InstancesManager.instances:
|
||||||
|
res = InstancesManager.instances[key]
|
||||||
|
if type(res) is not cls:
|
||||||
|
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Otherwise create a new instance
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
instance._is_new_instance = True # mark as fresh
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional['BaseInstance'],
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
if not getattr(self, "_is_new_instance", False):
|
||||||
|
# Skip __init__ if instance already existed
|
||||||
|
return
|
||||||
|
elif not isinstance(self, UniqueInstance):
|
||||||
|
# No more __init__ unless it's UniqueInstance
|
||||||
|
self._is_new_instance = False
|
||||||
|
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
self._session = session or (parent.get_session() if parent else None)
|
||||||
|
self._id = self.compute_id(_id, parent)
|
||||||
|
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
|
||||||
|
|
||||||
if auto_register:
|
if auto_register:
|
||||||
InstancesManager.register(session, self)
|
InstancesManager.register(self._session, self)
|
||||||
|
|
||||||
def get_id(self):
|
def get_session(self) -> dict:
|
||||||
return self._id
|
|
||||||
|
|
||||||
def get_session(self):
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def get_prefix(self):
|
def get_id(self) -> str:
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def get_parent(self) -> Optional['BaseInstance']:
|
||||||
|
return self._parent
|
||||||
|
|
||||||
|
def get_prefix(self) -> str:
|
||||||
return self._prefix
|
return self._prefix
|
||||||
|
|
||||||
def get_parent(self):
|
def get_full_id(self) -> str:
|
||||||
return self._parent
|
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||||
|
|
||||||
|
def get_full_parent_id(self) -> Optional[str]:
|
||||||
|
parent = self.get_parent()
|
||||||
|
return parent.get_full_id() if parent else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_prefix(cls):
|
||||||
|
return f"mf-{pascal_to_snake(cls.__name__)}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
|
||||||
|
if _id is None:
|
||||||
|
prefix = cls.compute_prefix()
|
||||||
|
if issubclass(cls, SingleInstance):
|
||||||
|
_id = prefix
|
||||||
|
else:
|
||||||
|
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||||
|
return _id
|
||||||
|
|
||||||
|
if _id.startswith("-") and parent is not None:
|
||||||
|
return f"{parent.get_prefix()}{_id}"
|
||||||
|
|
||||||
|
return _id
|
||||||
|
|
||||||
|
|
||||||
class SingleInstance(BaseInstance):
|
class SingleInstance(BaseInstance):
|
||||||
"""
|
"""
|
||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, parent, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(session, prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class UniqueInstance(BaseInstance):
|
class UniqueInstance(BaseInstance):
|
||||||
"""
|
"""
|
||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
Does not throw exception if the instance already exists, it simply overwrites it.
|
But unlike SingleInstance, the __init__ is called every time it's instantiated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(parent.get_session(), prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
self._prefix = prefix
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class MultipleInstance(BaseInstance):
|
class MultipleInstance(BaseInstance):
|
||||||
@@ -63,9 +142,11 @@ class MultipleInstance(BaseInstance):
|
|||||||
Base class for instances that can have multiple instances at a time.
|
Base class for instances that can have multiple instances at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True, _id=None):
|
def __init__(self, parent: BaseInstance,
|
||||||
super().__init__(parent.get_session(), prefix, _id or f"{prefix}-{str(uuid.uuid4())}", parent, auto_register)
|
session: Optional[dict] = None,
|
||||||
self._prefix = prefix
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class InstancesManager:
|
class InstancesManager:
|
||||||
@@ -79,7 +160,7 @@ class InstancesManager:
|
|||||||
:param instance:
|
:param instance:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
key = (InstancesManager._get_session_id(session), instance.get_id())
|
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||||
|
|
||||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||||
raise DuplicateInstanceError(instance)
|
raise DuplicateInstanceError(instance)
|
||||||
@@ -88,44 +169,50 @@ class InstancesManager:
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get(session: dict, instance_id: str, instance_type: type = None, parent: BaseInstance = None, *args, **kwargs):
|
def get(session: dict, instance_id: str):
|
||||||
"""
|
"""
|
||||||
Get or create an instance of the given type (from its id)
|
Get or create an instance of the given type (from its id)
|
||||||
:param session:
|
:param session:
|
||||||
:param instance_id:
|
:param instance_id:
|
||||||
:param instance_type:
|
|
||||||
:param parent:
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
try:
|
key = (InstancesManager.get_session_id(session), instance_id)
|
||||||
key = (InstancesManager._get_session_id(session), instance_id)
|
|
||||||
|
|
||||||
return InstancesManager.instances[key]
|
return InstancesManager.instances[key]
|
||||||
except KeyError:
|
|
||||||
if instance_type:
|
|
||||||
if not issubclass(instance_type, SingleInstance):
|
|
||||||
assert parent is not None, "Parent instance must be provided if not SingleInstance"
|
|
||||||
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_session_id(session):
|
def get_session_id(session):
|
||||||
if not session:
|
if session is None:
|
||||||
return "** NOT LOGGED IN **"
|
return "** NOT LOGGED IN **"
|
||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
return "** UNKNOWN USER **"
|
return "** UNKNOWN USER **"
|
||||||
return session["user_info"].get("id", "** INVALID SESSION **")
|
return session["user_info"].get("id", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_proxy():
|
def get_session_user_name(session):
|
||||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
if session is None:
|
||||||
|
return "** NOT LOGGED IN **"
|
||||||
|
if "user_info" not in session:
|
||||||
|
return "** UNKNOWN USER **"
|
||||||
|
return session["user_info"].get("username", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset():
|
def reset():
|
||||||
return InstancesManager.instances.clear()
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_session(session):
|
||||||
|
"""
|
||||||
|
Remove all instances belonging to the given session.
|
||||||
|
:param session:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
InstancesManager.instances = {
|
||||||
|
key: instance
|
||||||
|
for key, instance in InstancesManager.instances.items()
|
||||||
|
if key[0] != session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
RootInstance = SingleInstance(special_session, Ids.Root, None)
|
RootInstance = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.instances import BaseInstance, InstancesManager
|
|
||||||
|
|
||||||
logger = logging.getLogger("InstancesHelper")
|
|
||||||
|
|
||||||
|
|
||||||
class InstancesHelper:
|
|
||||||
@staticmethod
|
|
||||||
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
|
|
||||||
if component_type == Ids.VisNetwork:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
VisNetwork, parent=parent, _id=instance_id)
|
|
||||||
elif component_type == Ids.InstancesDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
InstancesDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
elif component_type == Ids.CommandsDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
CommandsDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
|
|
||||||
logger.warning(f"Unknown component type: {component_type}")
|
|
||||||
return None
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
ROOT_COLOR = "#ff9999"
|
||||||
|
GHOST_COLOR = "#cccccc"
|
||||||
|
|
||||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
@@ -144,50 +146,34 @@ def from_tree_with_metadata(
|
|||||||
|
|
||||||
def from_parent_child_list(
|
def from_parent_child_list(
|
||||||
items: list,
|
items: list,
|
||||||
id_getter: callable = None,
|
id_getter: Callable = None,
|
||||||
label_getter: callable = None,
|
label_getter: Callable = None,
|
||||||
parent_getter: callable = None,
|
parent_getter: Callable = None,
|
||||||
ghost_color: str = "#ff9999"
|
ghost_color: str = GHOST_COLOR,
|
||||||
|
root_color: str | None = ROOT_COLOR
|
||||||
) -> tuple[list, list]:
|
) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
Convert a list of items with parent references to vis.js nodes and edges format.
|
Convert a list of items with parent references to vis.js nodes and edges format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
items: List of items (dicts or objects) with parent references
|
items: List of items (dicts or objects) with parent references
|
||||||
(e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...])
|
id_getter: callback to extract node ID
|
||||||
id_getter: Optional callback to extract node ID from item
|
label_getter: callback to extract node label
|
||||||
Default: lambda item: item.get("id")
|
parent_getter: callback to extract parent ID
|
||||||
label_getter: Optional callback to extract node label from item
|
ghost_color: color for ghost nodes (referenced parents)
|
||||||
Default: lambda item: item.get("label", "")
|
root_color: color for root nodes (nodes without parent)
|
||||||
parent_getter: Optional callback to extract parent ID from item
|
|
||||||
Default: lambda item: item.get("parent")
|
|
||||||
ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list)
|
|
||||||
Default: "#ff9999" (light red)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (nodes, edges) where:
|
tuple: (nodes, edges)
|
||||||
- nodes: list of dicts with IDs from items, ghost nodes have color property
|
|
||||||
- edges: list of dicts with 'from' and 'to' keys
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- Nodes with parent=None or parent="" are treated as root nodes
|
|
||||||
- If a parent is referenced but doesn't exist in items, a ghost node is created
|
|
||||||
with the ghost_color applied
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> items = [
|
|
||||||
... {"id": "root", "label": "Root"},
|
|
||||||
... {"id": "child1", "parent": "root", "label": "Child 1"},
|
|
||||||
... {"id": "child2", "parent": "unknown", "label": "Child 2"}
|
|
||||||
... ]
|
|
||||||
>>> nodes, edges = from_parent_child_list(items)
|
|
||||||
>>> # "unknown" will be created as a ghost node with color="#ff9999"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Default getters
|
# Default getters
|
||||||
if id_getter is None:
|
if id_getter is None:
|
||||||
id_getter = lambda item: item.get("id")
|
id_getter = lambda item: item.get("id")
|
||||||
|
|
||||||
if label_getter is None:
|
if label_getter is None:
|
||||||
label_getter = lambda item: item.get("label", "")
|
label_getter = lambda item: item.get("label", "")
|
||||||
|
|
||||||
if parent_getter is None:
|
if parent_getter is None:
|
||||||
parent_getter = lambda item: item.get("parent")
|
parent_getter = lambda item: item.get("parent")
|
||||||
|
|
||||||
@@ -205,34 +191,48 @@ def from_parent_child_list(
|
|||||||
existing_ids.add(node_id)
|
existing_ids.add(node_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": node_id,
|
"id": node_id,
|
||||||
"label": node_label
|
"label": node_label,
|
||||||
|
# root color assigned later
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track ghost nodes to avoid duplicates
|
# Track ghost nodes
|
||||||
ghost_nodes = set()
|
ghost_nodes = set()
|
||||||
|
|
||||||
# Second pass: create edges and identify ghost nodes
|
# Track which nodes have parents
|
||||||
|
nodes_with_parent = set()
|
||||||
|
|
||||||
|
# Second pass: create edges and detect ghost nodes
|
||||||
for item in items:
|
for item in items:
|
||||||
node_id = id_getter(item)
|
node_id = id_getter(item)
|
||||||
parent_id = parent_getter(item)
|
parent_id = parent_getter(item)
|
||||||
|
|
||||||
# Skip if no parent or parent is empty string or None
|
# Skip roots
|
||||||
if parent_id is None or parent_id == "":
|
if parent_id is None or parent_id == "":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create edge from parent to child
|
# Child has a parent
|
||||||
|
nodes_with_parent.add(node_id)
|
||||||
|
|
||||||
|
# Create edge parent → child
|
||||||
edges.append({
|
edges.append({
|
||||||
"from": parent_id,
|
"from": parent_id,
|
||||||
"to": node_id
|
"to": node_id
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check if parent exists, if not create ghost node
|
# Create ghost node if parent not found
|
||||||
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
||||||
ghost_nodes.add(parent_id)
|
ghost_nodes.add(parent_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": parent_id,
|
"id": parent_id,
|
||||||
"label": str(parent_id), # Use ID as label for ghost nodes
|
"label": str(parent_id),
|
||||||
"color": ghost_color
|
"color": ghost_color
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Final pass: assign color to root nodes
|
||||||
|
if root_color is not None:
|
||||||
|
for node in nodes:
|
||||||
|
if node["id"] not in nodes_with_parent and node["id"] not in ghost_nodes:
|
||||||
|
# Root node
|
||||||
|
node["color"] = root_color
|
||||||
|
|
||||||
return nodes, edges
|
return nodes, edges
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
from fastcore.xml import FT
|
from fastcore.xml import FT
|
||||||
@@ -234,6 +235,33 @@ def get_id(obj):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def pascal_to_snake(name: str) -> str:
|
||||||
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
# Insert underscore before capital letters (except the first one)
|
||||||
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||||
|
return s2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def snake_to_pascal(name: str) -> str:
|
||||||
|
"""Convert a snake_case string to PascalCase."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Split on underscores and capitalize each part
|
||||||
|
parts = name.split('_')
|
||||||
|
return ''.join(word.capitalize() for word in parts if word)
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
def post(session, c_id: str, client_response: dict = None):
|
def post(session, c_id: str, client_response: dict = None):
|
||||||
"""
|
"""
|
||||||
@@ -248,6 +276,7 @@ def post(session, c_id: str, client_response: dict = None):
|
|||||||
from myfasthtml.core.commands import CommandsManager
|
from myfasthtml.core.commands import CommandsManager
|
||||||
command = CommandsManager.get_command(c_id)
|
command = CommandsManager.get_command(c_id)
|
||||||
if command:
|
if command:
|
||||||
|
logger.debug(f"Executing command {command.name}.")
|
||||||
return command.execute(client_response)
|
return command.execute(client_response)
|
||||||
|
|
||||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||||
@@ -266,6 +295,7 @@ def post(session, b_id: str, values: dict):
|
|||||||
from myfasthtml.core.bindings import BindingsManager
|
from myfasthtml.core.bindings import BindingsManager
|
||||||
binding = BindingsManager.get_binding(b_id)
|
binding = BindingsManager.get_binding(b_id)
|
||||||
if binding:
|
if binding:
|
||||||
return binding.update(values)
|
res = binding.update(values)
|
||||||
|
return res
|
||||||
|
|
||||||
raise ValueError(f"Binding with ID '{b_id}' not found.")
|
raise ValueError(f"Binding with ID '{b_id}' not found.")
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import re
|
|||||||
|
|
||||||
def pascal_to_snake(name: str) -> str:
|
def pascal_to_snake(name: str) -> str:
|
||||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
# Insert underscore before capital letters (except the first one)
|
# Insert underscore before capital letters (except the first one)
|
||||||
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from starlette.responses import Response
|
|||||||
from myfasthtml.auth.routes import setup_auth_routes
|
from myfasthtml.auth.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
from myfasthtml.auth.utils import create_auth_beforeware
|
||||||
from myfasthtml.core.AuthProxy import AuthProxy
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
|
from myfasthtml.core.instances import RootInstance
|
||||||
from myfasthtml.core.utils import utils_app
|
from myfasthtml.core.utils import utils_app
|
||||||
|
|
||||||
logger = logging.getLogger("MyFastHtml")
|
logger = logging.getLogger("MyFastHtml")
|
||||||
@@ -104,6 +105,6 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
setup_auth_routes(app, rt, base_url=base_url)
|
setup_auth_routes(app, rt, base_url=base_url)
|
||||||
|
|
||||||
# create the AuthProxy instance
|
# create the AuthProxy instance
|
||||||
AuthProxy(base_url) # using the auto register mechanism to expose it
|
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
|
||||||
|
|
||||||
return app, rt
|
return app, rt
|
||||||
|
|||||||
@@ -410,3 +410,59 @@ def matches(actual, expected, path=""):
|
|||||||
_expected=expected)
|
_expected=expected)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find(ft, expected):
|
||||||
|
res = []
|
||||||
|
|
||||||
|
def _type(x):
|
||||||
|
return type(x)
|
||||||
|
|
||||||
|
def _same(_ft, _expected):
|
||||||
|
if _ft == _expected:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if _ft.tag != _expected.tag:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for attr in _expected.attrs:
|
||||||
|
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for expected_child in _expected.children:
|
||||||
|
for ft_child in _ft.children:
|
||||||
|
if _same(ft_child, expected_child):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _find(current, current_expected):
|
||||||
|
|
||||||
|
if _type(current) != _type(current_expected):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not hasattr(current, "tag"):
|
||||||
|
return [current] if current == current_expected else []
|
||||||
|
|
||||||
|
_found = []
|
||||||
|
if _same(current, current_expected):
|
||||||
|
_found.append(current)
|
||||||
|
|
||||||
|
# look at the children
|
||||||
|
for child in current.children:
|
||||||
|
_found.extend(_find(child, current_expected))
|
||||||
|
|
||||||
|
return _found
|
||||||
|
|
||||||
|
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
|
||||||
|
|
||||||
|
for current_ft in ft_as_list:
|
||||||
|
found = _find(current_ft, expected)
|
||||||
|
res.extend(found)
|
||||||
|
|
||||||
|
if len(res) == 0:
|
||||||
|
raise AssertionError(f"No element found for '{expected}'")
|
||||||
|
|
||||||
|
return res
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
return SingleInstance(session, "TestRoot", None)
|
return SingleInstance(None, session, "TestRoot")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
@@ -10,9 +12,11 @@ from .conftest import session
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tabs_manager(root_instance):
|
def tabs_manager(root_instance):
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
yield TabsManager(root_instance)
|
yield TabsManager(root_instance)
|
||||||
|
|
||||||
InstancesManager.reset()
|
InstancesManager.reset()
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
class TestTabsManagerBehaviour:
|
class TestTabsManagerBehaviour:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from fasthtml.components import Button, Div
|
from fasthtml.components import Button, Div
|
||||||
from myutils.observable import make_observable, bind
|
from myutils.observable import make_observable, bind
|
||||||
|
|
||||||
from myfasthtml.core.commands import Command, CommandsManager
|
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
||||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||||
from myfasthtml.test.matcher import matches
|
from myfasthtml.test.matcher import matches
|
||||||
|
|
||||||
@@ -183,3 +183,14 @@ class TestCommandExecute:
|
|||||||
assert "hx-swap-oob" not in res[0].attrs
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
assert "hx-swap-oob" not in res[1].attrs
|
assert "hx-swap-oob" not in res[1].attrs
|
||||||
assert "hx-swap-oob" not in res[3].attrs
|
assert "hx-swap-oob" not in res[3].attrs
|
||||||
|
|
||||||
|
|
||||||
|
class TestLambaCommand:
|
||||||
|
|
||||||
|
def test_i_can_create_a_command_from_lambda(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.execute() == "Hello World"
|
||||||
|
|
||||||
|
def test_by_default_target_is_none(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.dbmanager import DbManager, DbObject
|
from myfasthtml.core.dbmanager import DbManager, DbObject
|
||||||
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -19,9 +20,14 @@ def session():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_manager(session):
|
def parent(session):
|
||||||
|
return SingleInstance(session=session, _id="test_parent_id")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_manager(parent):
|
||||||
shutil.rmtree("TestDb", ignore_errors=True)
|
shutil.rmtree("TestDb", ignore_errors=True)
|
||||||
db_manager_instance = DbManager(session, root="TestDb", auto_register=False)
|
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
|
||||||
|
|
||||||
yield db_manager_instance
|
yield db_manager_instance
|
||||||
|
|
||||||
@@ -32,17 +38,17 @@ def simplify(res: dict) -> dict:
|
|||||||
return {k: v for k, v in res.items() if not k.startswith("_")}
|
return {k: v for k, v in res.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init(session, db_manager):
|
def test_i_can_init(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
props = dummy._get_properties()
|
props = dummy._get_properties()
|
||||||
|
|
||||||
@@ -52,17 +58,17 @@ def test_i_can_init(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_dataclass(session, db_manager):
|
def test_i_can_init_from_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
DummyObject(session)
|
DummyObject(parent)
|
||||||
|
|
||||||
in_db = db_manager.load("DummyObject")
|
in_db = db_manager.load("DummyObject")
|
||||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||||
@@ -70,10 +76,10 @@ def test_i_can_init_from_dataclass(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with(session, db_manager):
|
def test_i_can_init_from_db_with(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
@@ -82,17 +88,17 @@ def test_i_can_init_from_db_with(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
@@ -100,37 +106,83 @@ def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_db_is_updated_when_attribute_is_modified(session, db_manager):
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, owner: BaseInstance):
|
||||||
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
with self.initializing():
|
||||||
|
self.to_save: str = "value"
|
||||||
|
self._not_to_save: str = "value"
|
||||||
|
self.ns_not_to_save: str = "value"
|
||||||
|
|
||||||
|
to_save: str = "value"
|
||||||
|
_not_to_save: str = "value"
|
||||||
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
|
dummy = DummyObject(parent)
|
||||||
|
dummy.to_save = "other_value"
|
||||||
|
dummy.ns_not_to_save = "other_value"
|
||||||
|
dummy._not_to_save = "other_value"
|
||||||
|
|
||||||
|
in_db = db_manager.load("DummyObject")
|
||||||
|
assert in_db["to_save"] == "other_value"
|
||||||
|
assert "_not_to_save" not in in_db
|
||||||
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
to_save: str = "value"
|
||||||
|
_not_to_save: str = "value"
|
||||||
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
|
dummy = DummyObject(parent)
|
||||||
|
dummy.to_save = "other_value"
|
||||||
|
dummy.ns_not_to_save = "other_value"
|
||||||
|
dummy._not_to_save = "other_value"
|
||||||
|
|
||||||
|
in_db = db_manager.load("DummyObject")
|
||||||
|
assert in_db["to_save"] == "other_value"
|
||||||
|
assert "_not_to_save" not in in_db
|
||||||
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_is_updated_when_attribute_is_modified(parent, db_manager):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, owner: BaseInstance):
|
||||||
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
|
|
||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
in_db_1 = db_manager.load("DummyObject")
|
in_db_1 = db_manager.load("DummyObject")
|
||||||
|
|
||||||
@@ -140,16 +192,16 @@ def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
|||||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_update(session, db_manager):
|
def test_i_can_update(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
clone.number = 34
|
clone.number = 34
|
||||||
@@ -161,54 +213,52 @@ def test_i_can_update(session, db_manager):
|
|||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
dummy.update(_session="other_value")
|
dummy.update(_owner="other_value")
|
||||||
|
|
||||||
assert dummy._session == session
|
assert dummy._owner is parent
|
||||||
|
|||||||
399
tests/core/test_instances.py
Normal file
399
tests/core/test_instances.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.instances import (
|
||||||
|
BaseInstance,
|
||||||
|
SingleInstance,
|
||||||
|
MultipleInstance,
|
||||||
|
InstancesManager,
|
||||||
|
DuplicateInstanceError,
|
||||||
|
special_session,
|
||||||
|
Ids,
|
||||||
|
RootInstance
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_instances():
|
||||||
|
"""Reset instances before each test to ensure isolation."""
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
yield
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
"""Create a test session."""
|
||||||
|
return {"user_info": {"id": "test-user-123"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def another_session():
|
||||||
|
"""Create another test session."""
|
||||||
|
return {"user_info": {"id": "test-user-456"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def root_instance(session):
|
||||||
|
"""Create a root instance for testing."""
|
||||||
|
return SingleInstance(parent=None, session=session, _id="test-root")
|
||||||
|
|
||||||
|
|
||||||
|
# Example subclasses for testing
|
||||||
|
class SubSingleInstance(SingleInstance):
|
||||||
|
"""Example subclass of SingleInstance with simplified signature."""
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
|
||||||
|
|
||||||
|
class SubMultipleInstance(MultipleInstance):
|
||||||
|
"""Example subclass of MultipleInstance with custom parameter."""
|
||||||
|
|
||||||
|
def __init__(self, parent, _id=None, custom_param=None):
|
||||||
|
super().__init__(parent=parent, _id=_id)
|
||||||
|
self.custom_param = custom_param
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with positional arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session, "test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with keyword arguments."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
|
||||||
|
"""Test that creating an instance with same session and id returns the existing instance."""
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_control_instances_registration(self, session, root_instance):
|
||||||
|
"""Test that auto_register=False prevents automatic registration."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "test_id")
|
||||||
|
|
||||||
|
assert key not in InstancesManager.instances
|
||||||
|
|
||||||
|
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
|
||||||
|
"""Test that different sessions can have instances with the same id."""
|
||||||
|
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
|
||||||
|
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance2, another_session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_session() == session
|
||||||
|
assert instance2.get_session() == another_session
|
||||||
|
|
||||||
|
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
|
||||||
|
"""Test that session can be extracted from parent when not provided."""
|
||||||
|
instance = BaseInstance(parent=root_instance, _id="test_id")
|
||||||
|
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_cannot_create_instance_without_parent_or_session(self):
|
||||||
|
"""Test that creating an instance without parent or session raises TypeError."""
|
||||||
|
with pytest.raises(TypeError, match="Either session or parent must be provided"):
|
||||||
|
BaseInstance(None, _id="test_id")
|
||||||
|
|
||||||
|
def test_i_can_get_auto_generated_id(self, session, root_instance):
|
||||||
|
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() is not None
|
||||||
|
assert instance.get_id().startswith("mf-base_instance-")
|
||||||
|
|
||||||
|
def test_i_can_get_prefix_from_class_name(self, session):
|
||||||
|
"""Test that get_prefix() returns the correct snake_case prefix."""
|
||||||
|
prefix = BaseInstance(None, session).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-base_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_single_instance(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created."""
|
||||||
|
instance = SingleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created with positional arguments."""
|
||||||
|
instance = SingleInstance(root_instance, session, "custom_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "custom_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_the_same_instance_is_returned(self, session):
|
||||||
|
"""Test that single instance is cached and returned on subsequent calls."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_create_duplicate_single_instance(self, session):
|
||||||
|
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
|
||||||
|
instance = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateInstanceError):
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
def test_i_can_retrieve_existing_single_instance(self, session):
|
||||||
|
"""Test that attempting to create an existing SingleInstance returns the same instance."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
|
||||||
|
"""Test that the default ID equals prefix for SingleInstance."""
|
||||||
|
instance = SingleInstance(parent=None, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-single_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_single_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of SingleInstance works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, SingleInstance)
|
||||||
|
assert isinstance(instance, SubSingleInstance)
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with simplified signature works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_id() == "mf-sub_single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-sub_single_instance"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubSingleInstance(root_instance)
|
||||||
|
instance2 = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
|
||||||
|
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
|
||||||
|
"""Test that each MultipleInstance receives a unique auto-generated ID."""
|
||||||
|
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
|
||||||
|
ids = [inst.get_id() for inst in instances]
|
||||||
|
|
||||||
|
# All IDs should be unique
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
# All IDs should start with the prefix
|
||||||
|
assert all(id.startswith("mf-multiple_instance-") for id in ids)
|
||||||
|
|
||||||
|
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
|
||||||
|
"""Test that a custom _id can be provided to MultipleInstance."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance.get_id() == custom_id
|
||||||
|
|
||||||
|
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
|
||||||
|
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
|
||||||
|
"""Test that key prefixed by underscore uses the parent id as prefix."""
|
||||||
|
instance = MultipleInstance(parent=root_instance, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
|
||||||
|
|
||||||
|
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
|
||||||
|
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
|
||||||
|
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == "-test_id"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of MultipleInstance works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="test")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, MultipleInstance)
|
||||||
|
assert isinstance(instance, SubMultipleInstance)
|
||||||
|
assert instance.custom_param == "test"
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
|
||||||
|
"""Test that multiple instances of subclass can be created with unique IDs."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with custom parameters works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="value")
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.custom_param == "value"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses with custom _id."""
|
||||||
|
# Need to pass _id explicitly to enable caching
|
||||||
|
instance1 = SubMultipleInstance(root_instance)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MultipleInstance(parent=root_instance, _id=instance1.get_id())
|
||||||
|
|
||||||
|
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
|
||||||
|
"""Test that subclass has correct auto-generated prefix."""
|
||||||
|
prefix = SubMultipleInstance(root_instance).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-sub_multiple_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstancesManager:
|
||||||
|
|
||||||
|
def test_i_can_register_an_instance_manually(self, session, root_instance):
|
||||||
|
"""Test that an instance can be manually registered."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
|
||||||
|
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "manual_id")
|
||||||
|
|
||||||
|
assert key in InstancesManager.instances
|
||||||
|
assert InstancesManager.instances[key] is instance
|
||||||
|
|
||||||
|
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
|
||||||
|
"""Test that an existing instance can be retrieved by ID."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
|
||||||
|
|
||||||
|
retrieved = InstancesManager.get(session, "get_id")
|
||||||
|
|
||||||
|
assert retrieved is instance
|
||||||
|
|
||||||
|
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
|
||||||
|
"""Test that getting a non-existent instance without type raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
InstancesManager.get(session, "nonexistent_id")
|
||||||
|
|
||||||
|
def test_i_can_get_session_id_from_valid_session(self, session):
|
||||||
|
"""Test that session ID is correctly extracted from a valid session."""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
assert session_id == "test-user-123"
|
||||||
|
|
||||||
|
def test_i_can_handle_none_session(self):
|
||||||
|
"""Test that None session returns a special identifier."""
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
def test_i_can_handle_invalid_session(self):
|
||||||
|
"""Test that invalid sessions return appropriate identifiers."""
|
||||||
|
# Session is None
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
# Session without user_info
|
||||||
|
session_no_user = {}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_user)
|
||||||
|
assert session_id == "** UNKNOWN USER **"
|
||||||
|
|
||||||
|
# Session with user_info but no id
|
||||||
|
session_no_id = {"user_info": {}}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_id)
|
||||||
|
assert session_id == "** INVALID SESSION **"
|
||||||
|
|
||||||
|
def test_i_can_reset_all_instances(self, session, root_instance):
|
||||||
|
"""Test that reset() clears all instances."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id1")
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id2")
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) > 0
|
||||||
|
|
||||||
|
InstancesManager.reset()
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_root_instance_with_positional_args(self):
|
||||||
|
"""Test that RootInstance can be created with positional arguments."""
|
||||||
|
root = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|
||||||
|
assert root is not None
|
||||||
|
assert root.get_id() == Ids.Root
|
||||||
|
assert root.get_session() == special_session
|
||||||
|
assert root.get_parent() is None
|
||||||
|
|
||||||
|
def test_i_can_access_root_instance(self):
|
||||||
|
"""Test that RootInstance is created and accessible."""
|
||||||
|
assert RootInstance is not None
|
||||||
|
assert RootInstance.get_id() == Ids.Root
|
||||||
|
assert RootInstance.get_session() == special_session
|
||||||
@@ -311,7 +311,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 1
|
assert len(nodes) == 1
|
||||||
assert nodes[0] == {"id": "root", "label": "Root"}
|
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
|
||||||
assert len(edges) == 0
|
assert len(edges) == 0
|
||||||
|
|
||||||
def test_i_can_convert_simple_parent_child_relationship(self):
|
def test_i_can_convert_simple_parent_child_relationship(self):
|
||||||
@@ -323,7 +323,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 2
|
assert len(nodes) == 2
|
||||||
assert {"id": "root", "label": "Root"} in nodes
|
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
|
||||||
assert {"id": "child", "label": "Child"} in nodes
|
assert {"id": "child", "label": "Child"} in nodes
|
||||||
|
|
||||||
assert len(edges) == 1
|
assert len(edges) == 1
|
||||||
@@ -405,7 +405,7 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||||
assert "color" in ghost_node
|
assert "color" in ghost_node
|
||||||
assert ghost_node["color"] == "#ff9999"
|
assert ghost_node["color"] == "#cccccc"
|
||||||
|
|
||||||
def test_i_can_use_custom_ghost_color(self):
|
def test_i_can_use_custom_ghost_color(self):
|
||||||
"""Test that custom ghost_color parameter is applied."""
|
"""Test that custom ghost_color parameter is applied."""
|
||||||
@@ -513,3 +513,136 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
||||||
assert ghost_node["label"] == "ghost_parent"
|
assert ghost_node["label"] == "ghost_parent"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_single_root(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["color"] == "#ff0000"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_multiple_roots(self):
|
||||||
|
"""Test root_color is assigned to all nodes without parent."""
|
||||||
|
items = [
|
||||||
|
{"id": "root1", "label": "Root 1"},
|
||||||
|
{"id": "root2", "label": "Root 2"},
|
||||||
|
{"id": "child", "parent": "root1", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#aa0000")
|
||||||
|
|
||||||
|
root_nodes = [n for n in nodes if n["id"] in ("root1", "root2")]
|
||||||
|
assert all(n.get("color") == "#aa0000" for n in root_nodes)
|
||||||
|
|
||||||
|
# child must NOT have root_color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_none(self):
|
||||||
|
"""Test that root_color is applied when parent=None."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": None, "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#112233")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#112233"
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_empty_string(self):
|
||||||
|
"""Test that root_color is applied when parent=''."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": "", "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#334455")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#334455"
|
||||||
|
|
||||||
|
def test_i_do_not_apply_root_color_to_non_roots(self):
|
||||||
|
"""Test that only real roots receive root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
# Only one root → only this one has the color
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_do_not_override_ghost_color_with_root_color(self):
|
||||||
|
"""Ghost nodes must keep ghost_color, not root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
# child is not root → no color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_use_custom_root_color(self):
|
||||||
|
"""Test that a custom root_color is applied instead of default."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#123456")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#123456"
|
||||||
|
|
||||||
|
def test_i_can_mix_root_nodes_and_ghost_nodes(self):
|
||||||
|
"""Ensure root_color applies only to roots and ghost nodes keep ghost_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_not_mark_node_as_root_if_parent_field_exists(self):
|
||||||
|
"""Node with parent key but non-empty value should NOT get root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"},
|
||||||
|
{"id": "other", "parent": "unknown_parent", "label": "Other"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
# "root" is the only real root
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
# "other" is NOT root, even though its parent is missing
|
||||||
|
other_node = next(n for n in nodes if n["id"] == "other")
|
||||||
|
assert "color" not in other_node
|
||||||
|
|
||||||
|
# ghost parent must have ghost_color
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "unknown_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_no_add_root_color_when_its_none(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color=None)
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert "color" not in nodes[0]
|
||||||
|
|||||||
450
tests/html/keyboard_support.js
Normal file
450
tests/html/keyboard_support.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Create keyboard bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store keyboard shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const KeyboardRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
currentKeys: new Set(),
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500 // 500ms timeout for sequences
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize key names to lowercase for case-insensitive comparison
|
||||||
|
* @param {string} key - The key to normalize
|
||||||
|
* @returns {string} - Normalized key name
|
||||||
|
*/
|
||||||
|
function normalizeKey(key) {
|
||||||
|
const keyMap = {
|
||||||
|
'control': 'ctrl',
|
||||||
|
'escape': 'esc',
|
||||||
|
'delete': 'del'
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
return keyMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of keys for Map indexing
|
||||||
|
* @param {Set} keySet - Set of normalized keys
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(keySet) {
|
||||||
|
return Array.from(keySet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a single key or a simultaneous combination)
|
||||||
|
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
|
||||||
|
* @returns {Set} - Set of normalized keys
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Simultaneous combination
|
||||||
|
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
|
||||||
|
}
|
||||||
|
// Single key
|
||||||
|
return new Set([normalizeKey(element.trim())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a key or simultaneous combination)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const keySet of sequence) {
|
||||||
|
const key = setToKey(keySet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where typing should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the focus is inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events and trigger matching combinations
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyboardEvent(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
|
||||||
|
// Add key to current pressed keys
|
||||||
|
KeyboardRegistry.currentKeys.add(key);
|
||||||
|
console.debug("Received key", key);
|
||||||
|
|
||||||
|
// Create a snapshot of current keyboard state
|
||||||
|
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for all elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
// Check all registered elements for matching combinations
|
||||||
|
for (const [elementId, data] of KeyboardRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if focus is inside this element (element itself or any child)
|
||||||
|
const isInside = element.contains(document.activeElement);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree, continue to next element
|
||||||
|
console.debug("No match in tree for event", key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has a URL)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
// Track if ANY element has longer sequences possible
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO element has longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of KeyboardRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}, KeyboardRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
// This handles invalid sequences like "A C" when only "A B" exists
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyup event to remove keys from current pressed keys
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyUp(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
KeyboardRegistry.currentKeys.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global keyboard event listener if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!KeyboardRegistry.listenerAttached) {
|
||||||
|
document.addEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global keyboard event listener
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (KeyboardRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
KeyboardRegistry.currentKeys.clear();
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add keyboard support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
KeyboardRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keyboard support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_keyboard_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (KeyboardRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
634
tests/html/mouse_support.js
Normal file
634
tests/html/mouse_support.js
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/**
|
||||||
|
* Create mouse bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store mouse shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const MouseRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||||
|
clickHandler: null,
|
||||||
|
contextmenuHandler: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize mouse action names
|
||||||
|
* @param {string} action - The action to normalize
|
||||||
|
* @returns {string} - Normalized action name
|
||||||
|
*/
|
||||||
|
function normalizeAction(action) {
|
||||||
|
const normalized = action.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Handle aliases
|
||||||
|
const aliasMap = {
|
||||||
|
'rclick': 'right_click'
|
||||||
|
};
|
||||||
|
|
||||||
|
return aliasMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of actions for Map indexing
|
||||||
|
* @param {Set} actionSet - Set of normalized actions
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(actionSet) {
|
||||||
|
return Array.from(actionSet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a simple click or click with modifiers)
|
||||||
|
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||||
|
* @returns {Set} - Set of normalized actions
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Click with modifiers
|
||||||
|
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||||
|
}
|
||||||
|
// Simple click
|
||||||
|
return new Set([normalizeAction(element)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a click or click with modifiers)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const actionSet of sequence) {
|
||||||
|
const key = setToKey(actionSet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where clicking should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the element that was actually clicked (from registered elements)
|
||||||
|
* @param {Element} target - The clicked element
|
||||||
|
* @returns {string|null} - Element ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
function findRegisteredElement(target) {
|
||||||
|
// Check if target itself is registered
|
||||||
|
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||||
|
return target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent is registered
|
||||||
|
let current = target.parentElement;
|
||||||
|
while (current) {
|
||||||
|
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||||
|
return current.id;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot from mouse event
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
* @returns {Set} - Set of actions representing this click
|
||||||
|
*/
|
||||||
|
function createSnapshot(event, baseAction) {
|
||||||
|
const actions = new Set([baseAction]);
|
||||||
|
|
||||||
|
// Add modifiers if present
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
actions.add('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
actions.add('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
actions.add('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the click was inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse events and trigger matching combinations
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
*/
|
||||||
|
function handleMouseEvent(event, baseAction) {
|
||||||
|
// Different behavior for click vs right_click
|
||||||
|
if (baseAction === 'click') {
|
||||||
|
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||||
|
handleGlobalClick(event);
|
||||||
|
} else if (baseAction === 'right_click') {
|
||||||
|
// Right-click: trigger ONLY if clicked on a registered element
|
||||||
|
handleElementRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle global click events (triggers for all registered elements)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleGlobalClick(event) {
|
||||||
|
console.debug("Global click detected");
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for ALL registered elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
for (const [elementId, data] of MouseRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if click was inside this element
|
||||||
|
const isInside = element.contains(event.target);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle right-click events (triggers only for clicked element)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleElementRightClick(event) {
|
||||||
|
// Find which registered element was clicked
|
||||||
|
const elementId = findRegisteredElement(event.target);
|
||||||
|
|
||||||
|
if (!elementId) {
|
||||||
|
// Right-click wasn't on a registered element - don't prevent default
|
||||||
|
// This allows browser context menu to appear
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("Right-click on registered element", elementId);
|
||||||
|
|
||||||
|
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||||
|
const clickedInside = true;
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'right_click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for this element
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
const data = MouseRegistry.elements.get(elementId);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
console.debug("No match in tree for right-click");
|
||||||
|
// Clear history for invalid sequences
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: true // Right-click only triggers when clicking on element
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global mouse event listeners if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!MouseRegistry.listenerAttached) {
|
||||||
|
// Store handler references for proper removal
|
||||||
|
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||||
|
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||||
|
|
||||||
|
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global mouse event listeners
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (MouseRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up handler references
|
||||||
|
MouseRegistry.clickHandler = null;
|
||||||
|
MouseRegistry.contextmenuHandler = null;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add mouse support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
MouseRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove mouse support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_mouse_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!MouseRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (MouseRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
309
tests/html/test_keyboard_support.html
Normal file
309
tests/html/test_keyboard_support.html
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Keyboard Support Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 2px solid #999;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:focus {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-color: #2196F3;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 5px;
|
||||||
|
border-left: 3px solid #4CAF50;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.focus {
|
||||||
|
border-left-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.no-focus {
|
||||||
|
border-left-color: #FF9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Keyboard Support Test Page</h1>
|
||||||
|
|
||||||
|
<div class="shortcuts-list">
|
||||||
|
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
|
||||||
|
<p><strong>Simple keys:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
|
||||||
|
<li><code>esc</code> - Escape key (POST)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
|
||||||
|
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Sequences with various configs:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
|
||||||
|
<li><code>A B C</code> - Triple sequence (GET request)</li>
|
||||||
|
<li><code>shift shift</code> - Press Shift twice in sequence</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Complex:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
|
||||||
|
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Input (typing should work normally here)</h2>
|
||||||
|
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
|
||||||
|
<strong>Parameters Explained:</strong><br>
|
||||||
|
• <code>has_focus</code>: Whether the registered element itself has focus<br>
|
||||||
|
• <code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
|
||||||
|
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 1</h2>
|
||||||
|
<div id="test-element" class="test-element" tabindex="0">
|
||||||
|
Click me to focus, then try keyboard shortcuts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
|
||||||
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
|
This element also responds to ESC and Shift Shift
|
||||||
|
</div>
|
||||||
|
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||||
|
Remove Element 2 Keyboard Support
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Event Log</h2>
|
||||||
|
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||||
|
<div id="log" class="log-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include htmx -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Mock htmx.ajax for testing -->
|
||||||
|
<script>
|
||||||
|
// Store original htmx.ajax if it exists
|
||||||
|
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||||
|
|
||||||
|
// Override htmx.ajax for testing purposes
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.ajax = function(method, url, config) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
|
const combination = config.values.combination;
|
||||||
|
|
||||||
|
// Build details string with all config options
|
||||||
|
const details = [
|
||||||
|
`Combination: "${combination}"`,
|
||||||
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Focus inside element: ${isInside}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.target) {
|
||||||
|
details.push(`Target: ${config.target}`);
|
||||||
|
}
|
||||||
|
if (config.swap) {
|
||||||
|
details.push(`Swap: ${config.swap}`);
|
||||||
|
}
|
||||||
|
if (config.values) {
|
||||||
|
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus');
|
||||||
|
if (extraVals.length > 0) {
|
||||||
|
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
`[${timestamp}] ${method} ${url}`,
|
||||||
|
...details,
|
||||||
|
hasFocus
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uncomment below to use real htmx.ajax if you have a backend
|
||||||
|
// if (originalHtmxAjax) {
|
||||||
|
// originalHtmxAjax.call(this, method, url, config);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEvent(title, ...details) {
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
const hasFocus = details[details.length - 1];
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||||
|
entry.innerHTML = `
|
||||||
|
<strong>${title}</strong><br>
|
||||||
|
${details.slice(0, -1).join('<br>')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
log.insertBefore(entry, log.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('log').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_keyboard_support('test-element-2');
|
||||||
|
logEvent('Element 2 keyboard support removed',
|
||||||
|
'ESC and Shift Shift no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Keyboard Support Removed';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include keyboard support script -->
|
||||||
|
<script src="keyboard_support.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize keyboard support -->
|
||||||
|
<script>
|
||||||
|
const combinations = {
|
||||||
|
"a": {
|
||||||
|
"hx-post": "/test/key-a"
|
||||||
|
},
|
||||||
|
"Ctrl+S": {
|
||||||
|
"hx-post": "/test/save",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"Ctrl+C": {
|
||||||
|
"hx-post": "/test/copy",
|
||||||
|
"hx-target": "#result",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
},
|
||||||
|
"A B": {
|
||||||
|
"hx-post": "/test/sequence-ab",
|
||||||
|
"hx-vals": {"extra": "data"}
|
||||||
|
},
|
||||||
|
"A B C": {
|
||||||
|
"hx-get": "/test/sequence-abc"
|
||||||
|
},
|
||||||
|
"Ctrl+C C": {
|
||||||
|
"hx-post": "/test/complex-ctrl-c-c"
|
||||||
|
},
|
||||||
|
"Ctrl+C Ctrl+C": {
|
||||||
|
"hx-post": "/test/complex-ctrl-c-twice"
|
||||||
|
},
|
||||||
|
"shift shift": {
|
||||||
|
"hx-post": "/test/shift-shift"
|
||||||
|
},
|
||||||
|
"esc": {
|
||||||
|
"hx-post": "/test/escape"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_keyboard_support('test-element', JSON.stringify(combinations));
|
||||||
|
|
||||||
|
// Add second element that also listens to ESC and shift shift
|
||||||
|
const combinations2 = {
|
||||||
|
"esc": {
|
||||||
|
"hx-post": "/test/escape-element2"
|
||||||
|
},
|
||||||
|
"shift shift": {
|
||||||
|
"hx-post": "/test/shift-shift-element2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
|
||||||
|
|
||||||
|
// Log initial state
|
||||||
|
logEvent('Keyboard support initialized',
|
||||||
|
'Element 1: All shortcuts configured with HTMX options',
|
||||||
|
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
|
||||||
|
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
356
tests/html/test_mouse_support.html
Normal file
356
tests/html/test_mouse_support.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mouse Support Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 2px solid #999;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:focus {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-color: #2196F3;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 5px;
|
||||||
|
border-left: 3px solid #4CAF50;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.focus {
|
||||||
|
border-left-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.no-focus {
|
||||||
|
border-left-color: #FF9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background-color: #FF5722;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
background-color: #E64A19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #2196F3;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Mouse Support Test Page</h1>
|
||||||
|
|
||||||
|
<div class="actions-list">
|
||||||
|
<h3>🖱️ Configured Mouse Actions</h3>
|
||||||
|
<p><strong>Element 1 - All Actions:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple left click</li>
|
||||||
|
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||||
|
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||||
|
<li><code>shift+click</code> - Shift + Click</li>
|
||||||
|
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||||
|
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||||
|
<li><code>click click</code> - Click twice in sequence</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple click</li>
|
||||||
|
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||||
|
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||||
|
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||||
|
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||||
|
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||||
|
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 1 (All Actions)</h2>
|
||||||
|
<div id="test-element-1" class="test-element" tabindex="0">
|
||||||
|
Try different mouse actions here!<br>
|
||||||
|
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||||
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
|
This element uses "rclick" alias for right-click<br>
|
||||||
|
Also has a "click rclick" sequence
|
||||||
|
</div>
|
||||||
|
<button class="remove-button" onclick="removeElement2()">
|
||||||
|
Remove Element 2 Mouse Support
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Input (normal clicking should work here)</h2>
|
||||||
|
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||||
|
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container" style="background-color: #f9f9f9;">
|
||||||
|
<h2>🎯 Click Outside Test Area</h2>
|
||||||
|
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||||
|
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||||
|
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Event Log</h2>
|
||||||
|
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||||
|
<div id="log" class="log-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include htmx -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Mock htmx.ajax for testing -->
|
||||||
|
<script>
|
||||||
|
// Store original htmx.ajax if it exists
|
||||||
|
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||||
|
|
||||||
|
// Override htmx.ajax for testing purposes
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.ajax = function(method, url, config) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
|
const combination = config.values.combination;
|
||||||
|
|
||||||
|
// Build details string with all config options
|
||||||
|
const details = [
|
||||||
|
`Combination: "${combination}"`,
|
||||||
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Click inside element: ${isInside}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.target) {
|
||||||
|
details.push(`Target: ${config.target}`);
|
||||||
|
}
|
||||||
|
if (config.swap) {
|
||||||
|
details.push(`Swap: ${config.swap}`);
|
||||||
|
}
|
||||||
|
if (config.values) {
|
||||||
|
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||||
|
if (extraVals.length > 0) {
|
||||||
|
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
`[${timestamp}] ${method} ${url}`,
|
||||||
|
...details,
|
||||||
|
hasFocus
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uncomment below to use real htmx.ajax if you have a backend
|
||||||
|
// if (originalHtmxAjax) {
|
||||||
|
// originalHtmxAjax.call(this, method, url, config);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEvent(title, ...details) {
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
const hasFocus = details[details.length - 1];
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||||
|
entry.innerHTML = `
|
||||||
|
<strong>${title}</strong><br>
|
||||||
|
${details.slice(0, -1).join('<br>')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
log.insertBefore(entry, log.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('log').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_mouse_support('test-element-2');
|
||||||
|
logEvent('Element 2 mouse support removed',
|
||||||
|
'Click and right-click no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Mouse Support Removed';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include mouse support script -->
|
||||||
|
<script src="mouse_support.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize mouse support -->
|
||||||
|
<script>
|
||||||
|
// Element 1 - Full configuration
|
||||||
|
const combinations1 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/click"
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/test/right-click"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/test/ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/test/shift-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+shift+click": {
|
||||||
|
"hx-post": "/test/ctrl-shift-click",
|
||||||
|
"hx-vals": {"modifier": "both"}
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/test/click-then-right-click",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/test/double-click-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||||
|
|
||||||
|
// Element 2 - Using rclick alias
|
||||||
|
const combinations2 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/element2-click"
|
||||||
|
},
|
||||||
|
"rclick": { // Using rclick alias instead of right_click
|
||||||
|
"hx-post": "/test/element2-rclick"
|
||||||
|
},
|
||||||
|
"click rclick": { // Sequence using rclick alias
|
||||||
|
"hx-post": "/test/element2-click-rclick-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||||
|
|
||||||
|
// Log initial state
|
||||||
|
logEvent('Mouse support initialized',
|
||||||
|
'Element 1: All mouse actions configured',
|
||||||
|
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||||
|
'Smart timeout: 500ms for sequences', false);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
tests/testclient/test_finds.py
Normal file
42
tests/testclient/test_finds.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
from fasthtml.components import Div, Span
|
||||||
|
|
||||||
|
from myfasthtml.test.matcher import find
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('ft, expected', [
|
||||||
|
("hello", "hello"),
|
||||||
|
(Div(id="id1"), Div(id="id1")),
|
||||||
|
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
|
||||||
|
(Div(id="id1", id2="id2"), Div(id="id1")),
|
||||||
|
(Div(Div(id="id2"), id2="id1"), Div(id="id1")),
|
||||||
|
])
|
||||||
|
def test_i_can_find(ft, expected):
|
||||||
|
assert find(expected, expected) == [expected]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_element_by_id_in_a_list():
|
||||||
|
a = Div(id="id1")
|
||||||
|
b = Div(id="id2")
|
||||||
|
c = Div(id="id3")
|
||||||
|
|
||||||
|
assert find([a, b, c], b) == [b]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_find_sub_element():
|
||||||
|
a = Div(id="id1")
|
||||||
|
b = Div(a, id="id2")
|
||||||
|
c = Div(b, id="id3")
|
||||||
|
|
||||||
|
assert find(c, a) == [a]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('ft, expected', [
|
||||||
|
(None, Div(id="id1")),
|
||||||
|
(Span(id="id1"), Div(id="id1")),
|
||||||
|
(Div(id2="id1"), Div(id="id1")),
|
||||||
|
(Div(id="id2"), Div(id="id1")),
|
||||||
|
])
|
||||||
|
def test_i_cannot_find(ft, expected):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
find(expected, ft)
|
||||||
Reference in New Issue
Block a user