Compare commits
29 Commits
AddingTree
...
a6ab4b2a68
| Author | SHA1 | Date | |
|---|---|---|---|
| a6ab4b2a68 | |||
| 84c63f0c5a | |||
| bb8752233e | |||
| dd9aefa143 | |||
| b1be747101 | |||
| 97247f824c | |||
| 4199427c71 | |||
| 3de9aff15c | |||
| cdccd0cbaa | |||
| 3667f1df44 | |||
| 66d5169b41 | |||
| e286b60348 | |||
| edcd3ae1a8 | |||
| ca238303b8 | |||
| c38a012c74 | |||
| 09c4217cb6 | |||
| 5ee671c6df | |||
| 9a76bd57ba | |||
| 93f6da66a5 | |||
| 7ff8b3ea14 | |||
| 7238cb085e | |||
| fb57a6a81d | |||
| 7f56b89e66 | |||
| cba4f2aab4 | |||
| c641f3fd63 | |||
| d302261d07 | |||
| a547b2b882 | |||
| 3d46e092aa | |||
| 5cb628099a |
@@ -1,242 +0,0 @@
|
|||||||
# Developer Mode
|
|
||||||
|
|
||||||
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
|
|
||||||
|
|
||||||
## Primary Objective
|
|
||||||
|
|
||||||
Write production-quality code by:
|
|
||||||
|
|
||||||
1. Exploring available options before implementation
|
|
||||||
2. Validating approach with user
|
|
||||||
3. Implementing only after approval
|
|
||||||
4. Following strict code standards and patterns
|
|
||||||
|
|
||||||
## Development Rules (DEV)
|
|
||||||
|
|
||||||
### DEV-1: Options-First Development
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
**Code must always be testable.**
|
|
||||||
|
|
||||||
### DEV-2: Question-Driven Collaboration
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
### DEV-3: Communication Standards
|
|
||||||
|
|
||||||
**Conversations**: French or English (match user's language)
|
|
||||||
**Code, documentation, comments**: English only
|
|
||||||
|
|
||||||
### DEV-4: 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
|
|
||||||
|
|
||||||
### DEV-5: 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
|
|
||||||
|
|
||||||
### DEV-6: 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
### DEV-7: 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### DEV-8: Command System - HTMX Target-Callback Alignment
|
|
||||||
|
|
||||||
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
|
|
||||||
|
|
||||||
**Two-part requirement:**
|
|
||||||
|
|
||||||
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
|
|
||||||
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
|
|
||||||
|
|
||||||
**Important: FastHTML Auto-Rendering**
|
|
||||||
|
|
||||||
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
|
|
||||||
- FastHTML automatically calls `__ft__()` which returns `render()` for you
|
|
||||||
|
|
||||||
**Binding Commands to Elements**
|
|
||||||
|
|
||||||
Use the `mk` helper from `myfasthtml.controls.helpers`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from myfasthtml.controls.helpers import mk
|
|
||||||
|
|
||||||
# Generic binding
|
|
||||||
mk.mk(element, cmd)
|
|
||||||
|
|
||||||
# Shortcut for buttons
|
|
||||||
mk.button("Label", command=cmd)
|
|
||||||
|
|
||||||
# Shortcut for icons
|
|
||||||
mk.icon(icon_svg, command=cmd)
|
|
||||||
|
|
||||||
# Shortcut for clickable labels
|
|
||||||
mk.label("Label", command=cmd)
|
|
||||||
|
|
||||||
# Shortcut for dialog buttons
|
|
||||||
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
|
|
||||||
```
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
✅ **Correct - Component with __ft__(), returns self:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In Commands class
|
|
||||||
def toggle_node(self, node_id: str):
|
|
||||||
return Command(
|
|
||||||
"ToggleNode",
|
|
||||||
f"Toggle node {node_id}",
|
|
||||||
self._owner._toggle_node, # Returns self (not self.render()!)
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
|
|
||||||
# In TreeView class
|
|
||||||
def _toggle_node(self, node_id: str):
|
|
||||||
"""Toggle expand/collapse state of a node."""
|
|
||||||
if node_id in self._state.opened:
|
|
||||||
self._state.opened.remove(node_id)
|
|
||||||
else:
|
|
||||||
self._state.opened.append(node_id)
|
|
||||||
return self # FastHTML calls __ft__() automatically
|
|
||||||
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
"""FastHTML magic method for rendering."""
|
|
||||||
return self.render()
|
|
||||||
|
|
||||||
|
|
||||||
# In render method - bind command to element
|
|
||||||
def _render_node(self, node_id: str, level: int = 0):
|
|
||||||
toggle = mk.mk(
|
|
||||||
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
|
|
||||||
command=self.commands.toggle_node(node_id)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **Correct - Using shortcuts:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Button with command
|
|
||||||
button = mk.button("Click me", command=self.commands.do_action())
|
|
||||||
|
|
||||||
# Icon with command
|
|
||||||
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
|
|
||||||
|
|
||||||
# Clickable label with command
|
|
||||||
label = mk.label("Select", command=self.commands.select())
|
|
||||||
```
|
|
||||||
|
|
||||||
❌ **Incorrect - Explicitly calling render():**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _toggle_node(self, node_id: str):
|
|
||||||
# ...
|
|
||||||
return self.render() # ❌ Don't do this if you have __ft__()!
|
|
||||||
```
|
|
||||||
|
|
||||||
❌ **Incorrect - Not binding command to element:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ❌ Command created but not bound to any element
|
|
||||||
toggle = Span("▼", cls="toggle") # No mk.mk()!
|
|
||||||
cmd = self.commands.toggle_node(node_id) # Command exists but not used
|
|
||||||
```
|
|
||||||
|
|
||||||
**Validation checklist:**
|
|
||||||
|
|
||||||
1. What HTML does the callback return (via `__ft__()` if present)?
|
|
||||||
2. What is the `target` ID in `.htmx()`?
|
|
||||||
3. Do they match?
|
|
||||||
4. Is the command bound to an element using `mk.mk()` or shortcuts?
|
|
||||||
|
|
||||||
**Common patterns:**
|
|
||||||
|
|
||||||
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
|
|
||||||
- **Partial update**: Callback returns specific element, target is that element's ID
|
|
||||||
- **Multiple updates**: Use swap OOB with multiple elements returned
|
|
||||||
|
|
||||||
### DEV-9: Error Handling Protocol
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
## Managing Rules
|
|
||||||
|
|
||||||
To disable a specific rule, the user can say:
|
|
||||||
|
|
||||||
- "Disable DEV-8" (do not apply the HTMX alignment rule)
|
|
||||||
- "Enable DEV-8" (re-enable a previously disabled rule)
|
|
||||||
|
|
||||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
|
|
||||||
|
|
||||||
## Other Personas
|
|
||||||
|
|
||||||
- Use `/technical-writer` to switch to documentation mode
|
|
||||||
- Use `/unit-tester` to switch unit testing mode
|
|
||||||
- Use `/reset` to return to default Claude Code mode
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Reset to Default Mode
|
|
||||||
|
|
||||||
You are now back to **default Claude Code mode**.
|
|
||||||
|
|
||||||
Follow the standard Claude Code guidelines without any specific persona or specialized behavior.
|
|
||||||
|
|
||||||
Refer to CLAUDE.md for project-specific architecture and patterns.
|
|
||||||
|
|
||||||
## Available Personas
|
|
||||||
|
|
||||||
You can switch to specialized modes:
|
|
||||||
- `/developer` - Full development mode with validation workflow
|
|
||||||
- `/technical-writer` - User documentation writing mode
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Technical Writer Persona
|
|
||||||
|
|
||||||
You are now acting as a **Technical Writer** specialized in user-facing documentation.
|
|
||||||
|
|
||||||
## Your Role
|
|
||||||
|
|
||||||
Focus on creating and improving **user documentation** for the MyFastHtml library:
|
|
||||||
- README sections and examples
|
|
||||||
- Usage guides and tutorials
|
|
||||||
- Getting started documentation
|
|
||||||
- Code examples for end users
|
|
||||||
- API usage documentation (not API reference)
|
|
||||||
|
|
||||||
## What You Don't Handle
|
|
||||||
|
|
||||||
- Docstrings in code (handled by developers)
|
|
||||||
- Internal architecture documentation
|
|
||||||
- Code comments
|
|
||||||
- CLAUDE.md (handled by developers)
|
|
||||||
|
|
||||||
## Documentation Principles
|
|
||||||
|
|
||||||
**Clarity First:**
|
|
||||||
- Write for developers who are new to MyFastHtml
|
|
||||||
- Explain the "why" not just the "what"
|
|
||||||
- Use concrete, runnable examples
|
|
||||||
- Progressive complexity (simple → advanced)
|
|
||||||
|
|
||||||
**Structure:**
|
|
||||||
- Start with the problem being solved
|
|
||||||
- Show minimal working example
|
|
||||||
- Explain key concepts
|
|
||||||
- Provide variations and advanced usage
|
|
||||||
- Link to related documentation
|
|
||||||
|
|
||||||
**Examples Must:**
|
|
||||||
- Be complete and runnable
|
|
||||||
- Include necessary imports
|
|
||||||
- Show expected output when relevant
|
|
||||||
- Use realistic variable names
|
|
||||||
- Follow the project's code standards (PEP 8, snake_case, English)
|
|
||||||
|
|
||||||
## Communication Style
|
|
||||||
|
|
||||||
**Conversations:** French or English (match user's language)
|
|
||||||
**Written documentation:** English only
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Ask questions** to understand what needs documentation
|
|
||||||
2. **Propose structure** before writing content
|
|
||||||
3. **Wait for validation** before proceeding
|
|
||||||
4. **Write incrementally** - one section at a time
|
|
||||||
5. **Request feedback** after each section
|
|
||||||
|
|
||||||
## Style Evolution
|
|
||||||
|
|
||||||
The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time.
|
|
||||||
|
|
||||||
## Exiting This Persona
|
|
||||||
|
|
||||||
To return to normal mode:
|
|
||||||
- Use `/developer` to switch to developer mode
|
|
||||||
- Use `/reset` to return to default Claude Code mode
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# Unit Tester Mode
|
|
||||||
|
|
||||||
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project.
|
|
||||||
|
|
||||||
## Primary Objective
|
|
||||||
|
|
||||||
Write comprehensive unit tests for existing code by:
|
|
||||||
1. Analyzing the code to understand its behavior
|
|
||||||
2. Identifying test cases (success paths and edge cases)
|
|
||||||
3. Proposing test plan for validation
|
|
||||||
4. Implementing tests only after approval
|
|
||||||
|
|
||||||
## Unit Test Rules (UTR)
|
|
||||||
|
|
||||||
### UTR-1: Test Analysis Before Implementation
|
|
||||||
|
|
||||||
Before writing any tests:
|
|
||||||
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` → `tests/foo/test_bar.py`)
|
|
||||||
2. **Analyze the code thoroughly** - Read and understand the implementation
|
|
||||||
3. **If tests exist**: Identify what's already covered and what's missing
|
|
||||||
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
|
|
||||||
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
|
|
||||||
6. **Wait for validation** - Only proceed after explicit approval
|
|
||||||
|
|
||||||
### UTR-2: Test Naming Conventions
|
|
||||||
|
|
||||||
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
|
|
||||||
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
|
||||||
|
|
||||||
**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)
|
|
||||||
```
|
|
||||||
|
|
||||||
### UTR-3: Use Functions, Not Classes (Default)
|
|
||||||
|
|
||||||
- Use **functions** for tests by default
|
|
||||||
- Only use classes when inheritance or grouping is required (see UTR-10)
|
|
||||||
- Before writing tests, **list all planned tests with explanations**
|
|
||||||
- Wait for validation before implementing tests
|
|
||||||
|
|
||||||
### UTR-4: Do NOT Test Python Built-ins
|
|
||||||
|
|
||||||
**Do NOT test Python's built-in functionality.**
|
|
||||||
|
|
||||||
❌ **Bad example - Testing Python list behavior:**
|
|
||||||
```python
|
|
||||||
def test_i_can_add_child_to_node(self):
|
|
||||||
"""Test that we can add a child ID to the children list."""
|
|
||||||
parent_node = TreeNode(label="Parent", type="folder")
|
|
||||||
child_id = "child_123"
|
|
||||||
|
|
||||||
parent_node.children.append(child_id) # Just testing list.append()
|
|
||||||
|
|
||||||
assert child_id in parent_node.children # Just testing list membership
|
|
||||||
```
|
|
||||||
|
|
||||||
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
|
|
||||||
|
|
||||||
✅ **Good example - Testing business logic:**
|
|
||||||
```python
|
|
||||||
def test_i_can_add_child_node(self, root_instance):
|
|
||||||
"""Test adding a child node to a parent."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
|
|
||||||
|
|
||||||
assert child.id in tree_view._state.items # Verify state updated
|
|
||||||
assert child.id in parent.children # Verify relationship established
|
|
||||||
assert child.parent == parent.id # Verify bidirectional link
|
|
||||||
```
|
|
||||||
|
|
||||||
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
|
|
||||||
|
|
||||||
**Other examples of what NOT to test:**
|
|
||||||
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
|
|
||||||
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
|
|
||||||
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
|
|
||||||
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
|
|
||||||
|
|
||||||
### UTR-5: Test Business Logic Only
|
|
||||||
|
|
||||||
**What TO test:**
|
|
||||||
- Your business logic and algorithms
|
|
||||||
- Your validation rules
|
|
||||||
- Your state transformations
|
|
||||||
- Your integration between components
|
|
||||||
- Your error handling for invalid inputs
|
|
||||||
- Your side effects (database updates, command registration, etc.)
|
|
||||||
|
|
||||||
### UTR-6: Test Coverage Requirements
|
|
||||||
|
|
||||||
For each code element, consider testing:
|
|
||||||
|
|
||||||
**Functions/Methods:**
|
|
||||||
- Valid inputs (typical use cases)
|
|
||||||
- Edge cases (empty values, None, boundaries)
|
|
||||||
- Error conditions (invalid inputs, exceptions)
|
|
||||||
- Return values and side effects
|
|
||||||
|
|
||||||
**Classes:**
|
|
||||||
- Initialization (default values, custom values)
|
|
||||||
- State management (attributes, properties)
|
|
||||||
- Methods (all public methods)
|
|
||||||
- Integration (interactions with other classes)
|
|
||||||
|
|
||||||
**Components (Controls):**
|
|
||||||
- Creation and initialization
|
|
||||||
- State changes
|
|
||||||
- Commands and their effects
|
|
||||||
- Rendering (if applicable)
|
|
||||||
- Edge cases and error conditions
|
|
||||||
|
|
||||||
### UTR-7: Ask Questions One at a Time
|
|
||||||
|
|
||||||
**Ask questions to clarify understanding:**
|
|
||||||
- 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 behavior - always verify understanding
|
|
||||||
|
|
||||||
### UTR-8: Communication Language
|
|
||||||
|
|
||||||
**Conversations**: French or English (match user's language)
|
|
||||||
**Code, documentation, comments**: English only
|
|
||||||
|
|
||||||
### UTR-9: 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
|
|
||||||
- Every test should have a clear docstring explaining what it verifies
|
|
||||||
- Include type hints where applicable
|
|
||||||
|
|
||||||
### UTR-10: Test File Organization
|
|
||||||
|
|
||||||
**File paths:**
|
|
||||||
- Always specify the full file path when creating test files
|
|
||||||
- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py`
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```
|
|
||||||
✅ Creating: tests/core/test_new_feature.py
|
|
||||||
✅ Modifying: tests/controls/test_treeview.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test organization for Controls:**
|
|
||||||
|
|
||||||
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class TestControlBehaviour:
|
|
||||||
"""Tests for control behavior and logic."""
|
|
||||||
|
|
||||||
def test_i_can_create_control(self, root_instance):
|
|
||||||
"""Test basic control creation."""
|
|
||||||
control = MyControl(root_instance)
|
|
||||||
assert control is not None
|
|
||||||
|
|
||||||
def test_i_can_update_state(self, root_instance):
|
|
||||||
"""Test state management."""
|
|
||||||
# Test state changes, data updates, etc.
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TestControlRender:
|
|
||||||
"""Tests for control HTML rendering."""
|
|
||||||
|
|
||||||
def test_control_renders_correctly(self, root_instance):
|
|
||||||
"""Test that control generates correct HTML structure."""
|
|
||||||
# Test HTML output, attributes, classes, etc.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_control_renders_with_custom_config(self, root_instance):
|
|
||||||
"""Test rendering with custom configuration."""
|
|
||||||
# Test different rendering scenarios
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why separate behaviour and render tests:**
|
|
||||||
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
|
|
||||||
- **Render tests**: Focus on HTML structure, attributes, and visual representation
|
|
||||||
- **Clarity**: Makes it clear what aspect of the control is being tested
|
|
||||||
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
|
|
||||||
|
|
||||||
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
|
|
||||||
|
|
||||||
### UTR-11: Test Workflow
|
|
||||||
|
|
||||||
1. **Receive code to test** - User provides file path or code section
|
|
||||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
|
||||||
3. **Analyze code** - Read and understand implementation
|
|
||||||
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
|
||||||
5. **Propose test plan** - List new/missing tests with brief explanations
|
|
||||||
6. **Wait for approval** - User validates the test plan
|
|
||||||
7. **Implement tests** - Write all approved tests
|
|
||||||
8. **Verify** - Ensure tests follow naming conventions and structure
|
|
||||||
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
|
||||||
|
|
||||||
## Managing Rules
|
|
||||||
|
|
||||||
To disable a specific rule, the user can say:
|
|
||||||
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
|
|
||||||
- "Enable UTR-4" (re-enable a previously disabled rule)
|
|
||||||
|
|
||||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
For detailed architecture and testing patterns, refer to CLAUDE.md in the project root.
|
|
||||||
|
|
||||||
## Other Personas
|
|
||||||
|
|
||||||
- Use `/developer` to switch to development mode
|
|
||||||
- Use `/technical-writer` to switch to documentation mode
|
|
||||||
- Use `/reset` to return to default Claude Code mode
|
|
||||||
455
CLAUDE.md
455
CLAUDE.md
@@ -1,455 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
## Available Personas
|
|
||||||
|
|
||||||
This project includes specialized personas (slash commands) for different types of work:
|
|
||||||
|
|
||||||
### `/developer` - Development Mode (Default)
|
|
||||||
**Use for:** Writing code, implementing features, fixing bugs
|
|
||||||
|
|
||||||
Activates the full development workflow with:
|
|
||||||
- Options-first approach before coding
|
|
||||||
- Step-by-step validation
|
|
||||||
- Strict PEP 8 compliance
|
|
||||||
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
|
|
||||||
|
|
||||||
### `/technical-writer` - Documentation Mode
|
|
||||||
**Use for:** Writing user-facing documentation
|
|
||||||
|
|
||||||
Focused on creating:
|
|
||||||
- README sections and examples
|
|
||||||
- Usage guides and tutorials
|
|
||||||
- Getting started documentation
|
|
||||||
- Code examples for end users
|
|
||||||
|
|
||||||
**Does NOT handle:**
|
|
||||||
- Docstrings (developer responsibility)
|
|
||||||
- Internal architecture docs
|
|
||||||
- CLAUDE.md updates
|
|
||||||
|
|
||||||
### `/reset` - Default Claude Code
|
|
||||||
**Use for:** Return to standard Claude Code behavior without personas
|
|
||||||
|
|
||||||
## 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
|
|
||||||
1
Makefile
1
Makefile
@@ -18,7 +18,6 @@ clean-tests:
|
|||||||
rm -rf .sesskey
|
rm -rf .sesskey
|
||||||
rm -rf tests/.sesskey
|
rm -rf tests/.sesskey
|
||||||
rm -rf tests/*.db
|
rm -rf tests/*.db
|
||||||
rm -rf tests/.myFastHtmlDb
|
|
||||||
|
|
||||||
# Alias to clean everything
|
# Alias to clean everything
|
||||||
clean: clean-build clean-tests
|
clean: clean-build clean-tests
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
# 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
|
|
||||||
72
src/app.py
72
src/app.py
@@ -4,17 +4,14 @@ 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.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
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.TreeView import TreeView, TreeNode
|
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.instances import UniqueInstance
|
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_p2 import key_command16_regular
|
|
||||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
@@ -33,52 +30,6 @@ app, rt = create_app(protect_routes=True,
|
|||||||
base_url="http://localhost:5003")
|
base_url="http://localhost:5003")
|
||||||
|
|
||||||
|
|
||||||
def create_sample_treeview(parent):
|
|
||||||
"""
|
|
||||||
Create a sample TreeView with a file structure for testing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent instance for the TreeView
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TreeView: Configured TreeView instance with sample data
|
|
||||||
"""
|
|
||||||
tree_view = TreeView(parent, _id="-treeview")
|
|
||||||
|
|
||||||
# Create sample file structure
|
|
||||||
projects = TreeNode(label="Projects", type="folder")
|
|
||||||
tree_view.add_node(projects)
|
|
||||||
|
|
||||||
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
|
|
||||||
tree_view.add_node(myfasthtml, parent_id=projects.id)
|
|
||||||
|
|
||||||
app_py = TreeNode(label="app.py", type="file")
|
|
||||||
tree_view.add_node(app_py, parent_id=myfasthtml.id)
|
|
||||||
|
|
||||||
readme = TreeNode(label="README.md", type="file")
|
|
||||||
tree_view.add_node(readme, parent_id=myfasthtml.id)
|
|
||||||
|
|
||||||
src_folder = TreeNode(label="src", type="folder")
|
|
||||||
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
|
|
||||||
|
|
||||||
controls_py = TreeNode(label="controls.py", type="file")
|
|
||||||
tree_view.add_node(controls_py, parent_id=src_folder.id)
|
|
||||||
|
|
||||||
documents = TreeNode(label="Documents", type="folder")
|
|
||||||
tree_view.add_node(documents, parent_id=projects.id)
|
|
||||||
|
|
||||||
notes = TreeNode(label="notes.txt", type="file")
|
|
||||||
tree_view.add_node(notes, parent_id=documents.id)
|
|
||||||
|
|
||||||
todo = TreeNode(label="todo.md", type="file")
|
|
||||||
tree_view.add_node(todo, parent_id=documents.id)
|
|
||||||
|
|
||||||
# Expand all nodes to show the full structure
|
|
||||||
#tree_view.expand_all()
|
|
||||||
|
|
||||||
return tree_view
|
|
||||||
|
|
||||||
|
|
||||||
@rt("/")
|
@rt("/")
|
||||||
def index(session):
|
def index(session):
|
||||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||||
@@ -86,7 +37,6 @@ def index(session):
|
|||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
|
|
||||||
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
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")
|
||||||
@@ -94,37 +44,31 @@ def index(session):
|
|||||||
instances_debugger = 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=add_tab("Instances", instances_debugger),
|
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
|
||||||
id=instances_debugger.get_id())
|
id=instances_debugger.get_id())
|
||||||
|
|
||||||
commands_debugger = CommandsDebugger(layout)
|
commands_debugger = CommandsDebugger(layout)
|
||||||
btn_show_commands_debugger = mk.label("Commands",
|
btn_show_commands_debugger = mk.label("Commands",
|
||||||
icon=key_command16_regular,
|
icon=None,
|
||||||
command=add_tab("Commands", commands_debugger),
|
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
|
||||||
id=commands_debugger.get_id())
|
id=commands_debugger.get_id())
|
||||||
|
|
||||||
btn_file_upload = mk.label("Upload",
|
btn_file_upload = mk.label("Upload",
|
||||||
icon=folder_open20_regular,
|
icon=folder_open20_regular,
|
||||||
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||||
id="file_upload_id")
|
id="file_upload_id")
|
||||||
|
|
||||||
btn_popup = mk.label("Popup",
|
|
||||||
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
|
|
||||||
|
|
||||||
# Create TreeView with sample data
|
|
||||||
tree_view = create_sample_treeview(layout)
|
|
||||||
|
|
||||||
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, "Debugger")
|
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_file_upload, "Test")
|
layout.left_drawer.add(btn_file_upload, "Test")
|
||||||
layout.left_drawer.add(btn_popup, "Test")
|
|
||||||
layout.left_drawer.add(tree_view, "TreeView")
|
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||||
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
tabs_manager.commands.add_tab("File Open",
|
||||||
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
FileUpload(layout,
|
||||||
|
_id="-file_upload")))
|
||||||
|
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
return layout, keyboard
|
return layout, keyboard
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ All other `hx-*` attributes are supported and will be converted to the appropria
|
|||||||
The library automatically adds these parameters to every request:
|
The library automatically adds these parameters to every request:
|
||||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||||
- `has_focus` - Boolean indicating if the element had focus
|
- `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:
|
Example final request:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -197,8 +196,7 @@ htmx.ajax('POST', '/save-url', {
|
|||||||
values: {
|
values: {
|
||||||
extra: "data", // from hx-vals
|
extra: "data", // from hx-vals
|
||||||
combination: "Ctrl+S", // automatic
|
combination: "Ctrl+S", // automatic
|
||||||
has_focus: true, // automatic
|
has_focus: true // automatic
|
||||||
is_inside: true // automatic
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -264,55 +262,6 @@ f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
|||||||
f"add_keyboard_support('editor', '{json.dumps(editor_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
|
## Technical Details
|
||||||
|
|
||||||
### Trie-based Matching
|
### Trie-based Matching
|
||||||
@@ -328,7 +277,7 @@ The library uses a prefix tree (trie) data structure:
|
|||||||
Configuration objects are mapped to htmx.ajax() calls:
|
Configuration objects are mapped to htmx.ajax() calls:
|
||||||
- `hx-*` attributes are converted to camelCase parameters
|
- `hx-*` attributes are converted to camelCase parameters
|
||||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||||
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
- `combination` and `has_focus` are automatically added to values
|
||||||
- All standard HTMX options are supported
|
- All standard HTMX options are supported
|
||||||
|
|
||||||
### Key Normalization
|
### Key Normalization
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mf-icon-16 {
|
.mf-icon-16 {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
@@ -440,235 +441,3 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* *********************************************** */
|
|
||||||
/* ************** TreeView Component ************* */
|
|
||||||
/* *********************************************** */
|
|
||||||
|
|
||||||
/* TreeView Container */
|
|
||||||
.mf-treeview {
|
|
||||||
width: 100%;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TreeNode Container */
|
|
||||||
.mf-treenode-container {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TreeNode Element */
|
|
||||||
.mf-treenode {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 2px 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input for Editing */
|
|
||||||
.mf-treenode-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2px 0.25rem;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: var(--color-base-100);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.mf-treenode:hover {
|
|
||||||
background-color: var(--color-base-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-treenode.selected {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-primary-content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Icon */
|
|
||||||
.mf-treenode-toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Node Label */
|
|
||||||
.mf-treenode-label {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.mf-treenode-input:focus {
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Buttons - Hidden by default, shown on hover */
|
|
||||||
.mf-treenode-actions {
|
|
||||||
display: none;
|
|
||||||
gap: 0.1rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-treenode:hover .mf-treenode-actions {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* *********************************************** */
|
|
||||||
/* ********** Generic Resizer Classes ************ */
|
|
||||||
/* *********************************************** */
|
|
||||||
|
|
||||||
/* Generic resizer - used by both Layout and Panel */
|
|
||||||
.mf-resizer {
|
|
||||||
position: absolute;
|
|
||||||
width: 4px;
|
|
||||||
cursor: col-resize;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
z-index: 100;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-resizer:hover {
|
|
||||||
background-color: rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active state during resize */
|
|
||||||
.mf-resizing .mf-resizer {
|
|
||||||
background-color: rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent text selection during resize */
|
|
||||||
.mf-resizing {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cursor override for entire body during resize */
|
|
||||||
.mf-resizing * {
|
|
||||||
cursor: col-resize !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Visual indicator for resizer on hover - subtle border */
|
|
||||||
.mf-resizer::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 2px;
|
|
||||||
height: 40px;
|
|
||||||
background-color: rgba(156, 163, 175, 0.4);
|
|
||||||
border-radius: 2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-resizer:hover::before,
|
|
||||||
.mf-resizing .mf-resizer::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Resizer positioning */
|
|
||||||
/* Left resizer is on the right side of the left panel */
|
|
||||||
.mf-resizer-left {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right resizer is on the left side of the right panel */
|
|
||||||
.mf-resizer-right {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Position indicator for resizer */
|
|
||||||
.mf-resizer-left::before {
|
|
||||||
right: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-resizer-right::before {
|
|
||||||
left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable transitions during resize for smooth dragging */
|
|
||||||
.mf-item-resizing {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* *********************************************** */
|
|
||||||
/* *************** Panel Component *************** */
|
|
||||||
/* *********************************************** */
|
|
||||||
|
|
||||||
/* Container principal du panel */
|
|
||||||
.mf-panel {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel gauche */
|
|
||||||
.mf-panel-left {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 250px;
|
|
||||||
min-width: 150px;
|
|
||||||
max-width: 400px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
border-right: 1px solid var(--color-border-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel principal (centre) */
|
|
||||||
.mf-panel-main {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
min-width: 0; /* Important pour permettre le shrink du flexbox */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel droit */
|
|
||||||
.mf-panel-right {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 300px;
|
|
||||||
min-width: 150px;
|
|
||||||
max-width: 500px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
border-left: 1px solid var(--color-border-primary);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -137,12 +137,6 @@
|
|||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<h2>Test Input (typing should work normally here)</h2>
|
<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;">
|
<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>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -157,9 +151,6 @@
|
|||||||
<div id="test-element-2" class="test-element" tabindex="0">
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
This element also responds to ESC and Shift Shift
|
This element also responds to ESC and Shift Shift
|
||||||
</div>
|
</div>
|
||||||
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
|
||||||
Remove Element 2 Keyboard Support
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -181,14 +172,12 @@
|
|||||||
window.htmx.ajax = function(method, url, config) {
|
window.htmx.ajax = function(method, url, config) {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
const hasFocus = config.values.has_focus;
|
const hasFocus = config.values.has_focus;
|
||||||
const isInside = config.values.is_inside;
|
|
||||||
const combination = config.values.combination;
|
const combination = config.values.combination;
|
||||||
|
|
||||||
// Build details string with all config options
|
// Build details string with all config options
|
||||||
const details = [
|
const details = [
|
||||||
`Combination: "${combination}"`,
|
`Combination: "${combination}"`,
|
||||||
`Element has focus: ${hasFocus}`,
|
`Element has focus: ${hasFocus}`
|
||||||
`Focus inside element: ${isInside}`
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.target) {
|
if (config.target) {
|
||||||
@@ -234,16 +223,6 @@
|
|||||||
function clearLog() {
|
function clearLog() {
|
||||||
document.getElementById('log').innerHTML = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Include keyboard support script -->
|
<!-- Include keyboard support script -->
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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()
|
|
||||||
|
|
||||||
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, _id="-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';
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# });
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from myfasthtml.controls.Panel import Panel
|
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
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
|
||||||
@@ -7,14 +6,9 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
|||||||
class InstancesDebugger(SingleInstance):
|
class InstancesDebugger(SingleInstance):
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._panel = Panel(self, _id="-panel")
|
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
nodes, edges = self._get_nodes_and_edges()
|
s_name = InstancesManager.get_session_user_name
|
||||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
|
||||||
return self._panel.set_main(vis_network)
|
|
||||||
|
|
||||||
def _get_nodes_and_edges(self):
|
|
||||||
instances = self._get_instances()
|
instances = self._get_instances()
|
||||||
nodes, edges = from_parent_child_list(
|
nodes, edges = from_parent_child_list(
|
||||||
instances,
|
instances,
|
||||||
@@ -29,7 +23,9 @@ class InstancesDebugger(SingleInstance):
|
|||||||
for node in nodes:
|
for node in nodes:
|
||||||
node["shape"] = "box"
|
node["shape"] = "box"
|
||||||
|
|
||||||
return nodes, edges
|
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||||
|
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
|
||||||
|
return vis_network
|
||||||
|
|
||||||
def _get_instances(self):
|
def _get_instances(self):
|
||||||
return list(InstancesManager.instances.values())
|
return list(InstancesManager.instances.values())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from myfasthtml.core.instances import MultipleInstance
|
|||||||
|
|
||||||
|
|
||||||
class Keyboard(MultipleInstance):
|
class Keyboard(MultipleInstance):
|
||||||
def __init__(self, parent, combinations=None, _id=None):
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class Layout(SingleInstance):
|
|||||||
self.header_right = self.Content(self)
|
self.header_right = self.Content(self)
|
||||||
self.footer_left = self.Content(self)
|
self.footer_left = self.Content(self)
|
||||||
self.footer_right = self.Content(self)
|
self.footer_right = self.Content(self)
|
||||||
self._footer_content = None
|
|
||||||
|
|
||||||
def set_footer(self, content):
|
def set_footer(self, content):
|
||||||
"""
|
"""
|
||||||
@@ -142,7 +141,6 @@ class Layout(SingleInstance):
|
|||||||
content: FastHTML component(s) or content for the main area
|
content: FastHTML component(s) or content for the main area
|
||||||
"""
|
"""
|
||||||
self._main_content = content
|
self._main_content = content
|
||||||
return self
|
|
||||||
|
|
||||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||||
@@ -235,7 +233,7 @@ class Layout(SingleInstance):
|
|||||||
Div: FastHTML Div component for left drawer
|
Div: FastHTML Div component for left drawer
|
||||||
"""
|
"""
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls="mf-resizer mf-resizer-left",
|
cls="mf-layout-resizer mf-layout-resizer-right",
|
||||||
data_command_id=self.commands.update_drawer_width("left").id,
|
data_command_id=self.commands.update_drawer_width("left").id,
|
||||||
data_side="left"
|
data_side="left"
|
||||||
)
|
)
|
||||||
@@ -268,9 +266,8 @@ class Layout(SingleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
Div: FastHTML Div component for right drawer
|
Div: FastHTML Div component for right drawer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls="mf-resizer mf-resizer-right",
|
cls="mf-layout-resizer mf-layout-resizer-left",
|
||||||
data_command_id=self.commands.update_drawer_width("right").id,
|
data_command_id=self.commands.update_drawer_width("right").id,
|
||||||
data_side="right"
|
data_side="right"
|
||||||
)
|
)
|
||||||
@@ -314,7 +311,7 @@ class Layout(SingleInstance):
|
|||||||
self._mk_main(),
|
self._mk_main(),
|
||||||
self._mk_right_drawer(),
|
self._mk_right_drawer(),
|
||||||
self._mk_footer(),
|
self._mk_footer(),
|
||||||
Script(f"initResizer('{self._id}');"),
|
Script(f"initLayoutResizer('{self._id}');"),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
cls="mf-layout",
|
cls="mf-layout",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fasthtml.components import Div
|
|
||||||
from fasthtml.xtend import Script
|
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
|
||||||
from myfasthtml.core.commands import Command
|
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PanelConf:
|
|
||||||
left: bool = False
|
|
||||||
right: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
|
||||||
def toggle_side(self, side: Literal["left", "right"]):
|
|
||||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
|
||||||
|
|
||||||
def update_side_width(self, side: Literal["left", "right"]):
|
|
||||||
"""
|
|
||||||
Create a command to update panel's side width.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
side: Which panel's side to update ("left" or "right")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Command: Command object for updating panel's side width
|
|
||||||
"""
|
|
||||||
return Command(
|
|
||||||
f"UpdatePanelSideWidth_{side}",
|
|
||||||
f"Update {side} side panel width",
|
|
||||||
self._owner.update_side_width,
|
|
||||||
side
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Panel(MultipleInstance):
|
|
||||||
def __init__(self, parent, conf=None, _id=None):
|
|
||||||
super().__init__(parent, _id=_id)
|
|
||||||
self.conf = conf or PanelConf()
|
|
||||||
self.commands = Commands(self)
|
|
||||||
self._main = None
|
|
||||||
self._right = None
|
|
||||||
self._left = None
|
|
||||||
|
|
||||||
def update_side_width(self, side, width):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def toggle_side(self, side):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def set_main(self, main):
|
|
||||||
self._main = main
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_right(self, right):
|
|
||||||
self._right = right
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_left(self, left):
|
|
||||||
self._left = left
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _mk_right(self):
|
|
||||||
if not self.conf.right:
|
|
||||||
return None
|
|
||||||
|
|
||||||
resizer = Div(
|
|
||||||
cls="mf-resizer mf-resizer-right",
|
|
||||||
data_command_id=self.commands.update_side_width("right").id,
|
|
||||||
data_side="right"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Div(resizer, self._right, cls="mf-panel-right")
|
|
||||||
|
|
||||||
def _mk_left(self):
|
|
||||||
if not self.conf.left:
|
|
||||||
return None
|
|
||||||
|
|
||||||
resizer = Div(
|
|
||||||
cls="mf-resizer mf-resizer-left",
|
|
||||||
data_command_id=self.commands.update_side_width("left").id,
|
|
||||||
data_side="left"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Div(self._left, resizer, cls="mf-panel-left")
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
return Div(
|
|
||||||
self._mk_left(),
|
|
||||||
Div(self._main, cls="mf-panel-main"),
|
|
||||||
self._mk_right(),
|
|
||||||
Script(f"initResizer('{self._id}');"),
|
|
||||||
cls="mf-panel",
|
|
||||||
id=self._id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
return self.render()
|
|
||||||
@@ -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 InstancesManager.get(self._session, tab_config["component_id"])
|
return InstancesManager.dynamic_get(self, tab_config["component_type"], tab_config["component_id"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
"""
|
|
||||||
TreeView component for hierarchical data visualization with inline editing.
|
|
||||||
|
|
||||||
This component provides an interactive tree structure with expand/collapse,
|
|
||||||
selection, and inline editing capabilities.
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fasthtml.components import Div, Input, Span
|
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
|
||||||
from myfasthtml.controls.helpers import mk
|
|
||||||
from myfasthtml.core.commands import Command
|
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
|
||||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
|
|
||||||
from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TreeNode:
|
|
||||||
"""
|
|
||||||
Represents a node in the tree structure.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Unique identifier (auto-generated UUID if not provided)
|
|
||||||
label: Display text for the node
|
|
||||||
type: Node type for icon mapping
|
|
||||||
parent: ID of parent node (None for root)
|
|
||||||
children: List of child node IDs
|
|
||||||
"""
|
|
||||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
||||||
label: str = ""
|
|
||||||
type: str = "default"
|
|
||||||
parent: Optional[str] = None
|
|
||||||
children: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class TreeViewState(DbObject):
|
|
||||||
"""
|
|
||||||
Persistent state for TreeView component.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
items: Dictionary mapping node IDs to TreeNode instances
|
|
||||||
opened: List of expanded node IDs
|
|
||||||
selected: Currently selected node ID
|
|
||||||
editing: Node ID currently being edited (None if not editing)
|
|
||||||
icon_config: Mapping of node types to icon identifiers
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, owner):
|
|
||||||
super().__init__(owner)
|
|
||||||
with self.initializing():
|
|
||||||
self.items: dict[str, TreeNode] = {}
|
|
||||||
self.opened: list[str] = []
|
|
||||||
self.selected: Optional[str] = None
|
|
||||||
self.editing: Optional[str] = None
|
|
||||||
self.icon_config: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
|
||||||
"""Command handlers for TreeView actions."""
|
|
||||||
|
|
||||||
def toggle_node(self, node_id: str):
|
|
||||||
"""Create command to expand/collapse a node."""
|
|
||||||
return Command(
|
|
||||||
"ToggleNode",
|
|
||||||
f"Toggle node {node_id}",
|
|
||||||
self._owner._toggle_node,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def add_child(self, parent_id: str):
|
|
||||||
"""Create command to add a child node."""
|
|
||||||
return Command(
|
|
||||||
"AddChild",
|
|
||||||
f"Add child to {parent_id}",
|
|
||||||
self._owner._add_child,
|
|
||||||
parent_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def add_sibling(self, node_id: str):
|
|
||||||
"""Create command to add a sibling node."""
|
|
||||||
return Command(
|
|
||||||
"AddSibling",
|
|
||||||
f"Add sibling to {node_id}",
|
|
||||||
self._owner._add_sibling,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def start_rename(self, node_id: str):
|
|
||||||
"""Create command to start renaming a node."""
|
|
||||||
return Command(
|
|
||||||
"StartRename",
|
|
||||||
f"Start renaming {node_id}",
|
|
||||||
self._owner._start_rename,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def save_rename(self, node_id: str):
|
|
||||||
"""Create command to save renamed node."""
|
|
||||||
return Command(
|
|
||||||
"SaveRename",
|
|
||||||
f"Save rename for {node_id}",
|
|
||||||
self._owner._save_rename,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def cancel_rename(self):
|
|
||||||
"""Create command to cancel renaming."""
|
|
||||||
return Command(
|
|
||||||
"CancelRename",
|
|
||||||
"Cancel rename",
|
|
||||||
self._owner._cancel_rename
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def delete_node(self, node_id: str):
|
|
||||||
"""Create command to delete a node."""
|
|
||||||
return Command(
|
|
||||||
"DeleteNode",
|
|
||||||
f"Delete node {node_id}",
|
|
||||||
self._owner._delete_node,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
def select_node(self, node_id: str):
|
|
||||||
"""Create command to select a node."""
|
|
||||||
return Command(
|
|
||||||
"SelectNode",
|
|
||||||
f"Select node {node_id}",
|
|
||||||
self._owner._select_node,
|
|
||||||
node_id
|
|
||||||
).htmx(target=f"#{self._owner.get_id()}")
|
|
||||||
|
|
||||||
|
|
||||||
class TreeView(MultipleInstance):
|
|
||||||
"""
|
|
||||||
Interactive TreeView component with hierarchical data visualization.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Expand/collapse nodes
|
|
||||||
- Add child/sibling nodes
|
|
||||||
- Inline rename
|
|
||||||
- Delete nodes
|
|
||||||
- Node selection
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
Initialize TreeView component.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent instance
|
|
||||||
items: Optional initial items dictionary {node_id: TreeNode}
|
|
||||||
_id: Optional custom ID
|
|
||||||
"""
|
|
||||||
super().__init__(parent, _id=_id)
|
|
||||||
self._state = TreeViewState(self)
|
|
||||||
self.commands = Commands(self)
|
|
||||||
|
|
||||||
if items:
|
|
||||||
self._state.items = items
|
|
||||||
|
|
||||||
def set_icon_config(self, config: dict[str, str]):
|
|
||||||
"""
|
|
||||||
Set icon configuration for node types.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Dictionary mapping node types to icon identifiers
|
|
||||||
Format: {type: "provider.icon_name"}
|
|
||||||
"""
|
|
||||||
self._state.icon_config = config
|
|
||||||
|
|
||||||
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
|
|
||||||
"""
|
|
||||||
Add a node to the tree.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node: TreeNode instance to add
|
|
||||||
parent_id: Optional parent node ID (None for root)
|
|
||||||
insert_index: Optional index to insert at in parent's children list.
|
|
||||||
If None, appends to end. If provided, inserts at that position.
|
|
||||||
"""
|
|
||||||
self._state.items[node.id] = node
|
|
||||||
node.parent = parent_id
|
|
||||||
|
|
||||||
if parent_id and parent_id in self._state.items:
|
|
||||||
parent = self._state.items[parent_id]
|
|
||||||
if node.id not in parent.children:
|
|
||||||
if insert_index is not None:
|
|
||||||
parent.children.insert(insert_index, node.id)
|
|
||||||
else:
|
|
||||||
parent.children.append(node.id)
|
|
||||||
|
|
||||||
def expand_all(self):
|
|
||||||
"""Expand all nodes that have children."""
|
|
||||||
for node_id, node in self._state.items.items():
|
|
||||||
if node.children and node_id not in self._state.opened:
|
|
||||||
self._state.opened.append(node_id)
|
|
||||||
|
|
||||||
def _toggle_node(self, node_id: str):
|
|
||||||
"""Toggle expand/collapse state of a node."""
|
|
||||||
if node_id in self._state.opened:
|
|
||||||
self._state.opened.remove(node_id)
|
|
||||||
else:
|
|
||||||
self._state.opened.append(node_id)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _add_child(self, parent_id: str, new_label: Optional[str] = None):
|
|
||||||
"""Add a child node to a parent."""
|
|
||||||
if parent_id not in self._state.items:
|
|
||||||
raise ValueError(f"Parent node {parent_id} does not exist")
|
|
||||||
|
|
||||||
parent = self._state.items[parent_id]
|
|
||||||
new_node = TreeNode(
|
|
||||||
label=new_label or "New Node",
|
|
||||||
type=parent.type
|
|
||||||
)
|
|
||||||
|
|
||||||
self.add_node(new_node, parent_id=parent_id)
|
|
||||||
|
|
||||||
# Auto-expand parent
|
|
||||||
if parent_id not in self._state.opened:
|
|
||||||
self._state.opened.append(parent_id)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
|
|
||||||
"""Add a sibling node next to a node."""
|
|
||||||
if node_id not in self._state.items:
|
|
||||||
raise ValueError(f"Node {node_id} does not exist")
|
|
||||||
|
|
||||||
node = self._state.items[node_id]
|
|
||||||
|
|
||||||
if node.parent is None:
|
|
||||||
raise ValueError("Cannot add sibling to root node")
|
|
||||||
|
|
||||||
parent = self._state.items[node.parent]
|
|
||||||
new_node = TreeNode(
|
|
||||||
label=new_label or "New Node",
|
|
||||||
type=node.type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert after current node
|
|
||||||
insert_idx = parent.children.index(node_id) + 1
|
|
||||||
self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _start_rename(self, node_id: str):
|
|
||||||
"""Start renaming a node (sets editing state)."""
|
|
||||||
if node_id not in self._state.items:
|
|
||||||
raise ValueError(f"Node {node_id} does not exist")
|
|
||||||
|
|
||||||
self._state.editing = node_id
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _save_rename(self, node_id: str, node_label: str):
|
|
||||||
"""Save renamed node with new label."""
|
|
||||||
if node_id not in self._state.items:
|
|
||||||
raise ValueError(f"Node {node_id} does not exist")
|
|
||||||
|
|
||||||
self._state.items[node_id].label = node_label
|
|
||||||
self._state.editing = None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _cancel_rename(self):
|
|
||||||
"""Cancel renaming operation."""
|
|
||||||
self._state.editing = None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _delete_node(self, node_id: str):
|
|
||||||
"""Delete a node (only if it has no children)."""
|
|
||||||
if node_id not in self._state.items:
|
|
||||||
raise ValueError(f"Node {node_id} does not exist")
|
|
||||||
|
|
||||||
node = self._state.items[node_id]
|
|
||||||
|
|
||||||
if node.children:
|
|
||||||
raise ValueError(f"Cannot delete node {node_id} with children")
|
|
||||||
|
|
||||||
# Remove from parent's children list
|
|
||||||
if node.parent and node.parent in self._state.items:
|
|
||||||
parent = self._state.items[node.parent]
|
|
||||||
parent.children.remove(node_id)
|
|
||||||
|
|
||||||
# Remove from state
|
|
||||||
del self._state.items[node_id]
|
|
||||||
|
|
||||||
if node_id in self._state.opened:
|
|
||||||
self._state.opened.remove(node_id)
|
|
||||||
|
|
||||||
if self._state.selected == node_id:
|
|
||||||
self._state.selected = None
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _select_node(self, node_id: str):
|
|
||||||
"""Select a node."""
|
|
||||||
if node_id not in self._state.items:
|
|
||||||
raise ValueError(f"Node {node_id} does not exist")
|
|
||||||
|
|
||||||
self._state.selected = node_id
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _render_action_buttons(self, node_id: str):
|
|
||||||
"""Render action buttons for a node (visible on hover)."""
|
|
||||||
return Div(
|
|
||||||
mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
|
|
||||||
mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
|
|
||||||
mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
|
|
||||||
cls="mf-treenode-actions"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _render_node(self, node_id: str, level: int = 0):
|
|
||||||
"""
|
|
||||||
Render a single node and its children recursively.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
node_id: ID of node to render
|
|
||||||
level: Indentation level
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Div containing the node and its children
|
|
||||||
"""
|
|
||||||
node = self._state.items[node_id]
|
|
||||||
is_expanded = node_id in self._state.opened
|
|
||||||
is_selected = node_id == self._state.selected
|
|
||||||
is_editing = node_id == self._state.editing
|
|
||||||
has_children = len(node.children) > 0
|
|
||||||
|
|
||||||
# Toggle icon
|
|
||||||
toggle = mk.icon(
|
|
||||||
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
|
|
||||||
command=self.commands.toggle_node(node_id))
|
|
||||||
|
|
||||||
# Label or input for editing
|
|
||||||
if is_editing:
|
|
||||||
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
|
|
||||||
label_element = mk.mk(Input(
|
|
||||||
name="node_label",
|
|
||||||
value=node.label,
|
|
||||||
cls="mf-treenode-input input input-sm"
|
|
||||||
), command=self.commands.save_rename(node_id))
|
|
||||||
else:
|
|
||||||
label_element = mk.mk(
|
|
||||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
|
||||||
command=self.commands.select_node(node_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Node element
|
|
||||||
node_element = Div(
|
|
||||||
toggle,
|
|
||||||
label_element,
|
|
||||||
self._render_action_buttons(node_id),
|
|
||||||
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
|
|
||||||
data_node_id=node_id,
|
|
||||||
style=f"padding-left: {level * 20}px"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Children (if expanded)
|
|
||||||
children_elements = []
|
|
||||||
if is_expanded and has_children:
|
|
||||||
for child_id in node.children:
|
|
||||||
children_elements.append(
|
|
||||||
self._render_node(child_id, level + 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Div(
|
|
||||||
node_element,
|
|
||||||
*children_elements,
|
|
||||||
cls="mf-treenode-container"
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
"""
|
|
||||||
Render the complete TreeView.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Div: Complete TreeView HTML structure
|
|
||||||
"""
|
|
||||||
# Find root nodes (nodes without parent)
|
|
||||||
root_nodes = [
|
|
||||||
node_id for node_id, node in self._state.items.items()
|
|
||||||
if node.parent is None
|
|
||||||
]
|
|
||||||
|
|
||||||
return Div(
|
|
||||||
*[self._render_node(node_id) for node_id in root_nodes],
|
|
||||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
|
|
||||||
id=self._id,
|
|
||||||
cls="mf-treeview"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
"""FastHTML magic method for rendering."""
|
|
||||||
return self.render()
|
|
||||||
@@ -15,19 +15,6 @@ class mk:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||||
"""
|
|
||||||
Defines a static method for creating a Button object with specific configurations.
|
|
||||||
|
|
||||||
This method constructs a Button instance by wrapping an element with
|
|
||||||
additional configurations such as commands and bindings. Any extra keyword
|
|
||||||
arguments are passed when creating the Button.
|
|
||||||
|
|
||||||
:param element: The underlying widget or element to be wrapped in a Button.
|
|
||||||
:param command: An optional command to associate with the Button. Defaults to None.
|
|
||||||
:param binding: An optional event binding to associate with the Button. Defaults to None.
|
|
||||||
:param kwargs: Additional keyword arguments to further configure the Button.
|
|
||||||
:return: A fully constructed Button instance with the specified configurations.
|
|
||||||
"""
|
|
||||||
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -46,33 +33,13 @@ class mk:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def icon(icon,
|
def icon(icon, size=20,
|
||||||
size=20,
|
|
||||||
can_select=True,
|
can_select=True,
|
||||||
can_hover=False,
|
can_hover=False,
|
||||||
cls='',
|
cls='',
|
||||||
command: Command = None,
|
command: Command = None,
|
||||||
binding: Binding = None,
|
binding: Binding = None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
"""
|
|
||||||
Generates an icon element with customizable properties for size, class, and interactivity.
|
|
||||||
|
|
||||||
This method creates an icon element wrapped in a container with optional classes
|
|
||||||
and event bindings. The icon can be styled and its behavior defined using the parameters
|
|
||||||
provided, allowing for dynamic and reusable UI components.
|
|
||||||
|
|
||||||
:param icon: The icon to display inside the container.
|
|
||||||
:param size: The size of the icon, specified in pixels. Defaults to 20.
|
|
||||||
:param can_select: Indicates whether the icon can be selected. Defaults to True.
|
|
||||||
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
|
|
||||||
:param cls: A string of custom CSS classes to be added to the icon container.
|
|
||||||
:param command: The command object defining the function to be executed on icon interaction.
|
|
||||||
:param binding: The binding object for configuring additional event listeners on the icon.
|
|
||||||
:param kwargs: Additional keyword arguments for configuring attributes and behaviors of the
|
|
||||||
icon element.
|
|
||||||
:return: A styled and interactive icon element embedded inside a container, configured
|
|
||||||
with the defined classes, size, and behaviors.
|
|
||||||
"""
|
|
||||||
merged_cls = merge_classes(f"mf-icon-{size}",
|
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||||
'icon-btn' if can_select else '',
|
'icon-btn' if can_select else '',
|
||||||
'mmt-btn' if can_hover else '',
|
'mmt-btn' if can_hover else '',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from myfasthtml.core.utils import retrieve_user_info
|
|||||||
class DbManager(SingleInstance):
|
class DbManager(SingleInstance):
|
||||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||||
super().__init__(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):
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from dbengine.utils import get_class
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
from myfasthtml.core.utils import pascal_to_snake
|
from myfasthtml.core.utils import pascal_to_snake, snake_to_pascal
|
||||||
|
|
||||||
logger = logging.getLogger("InstancesManager")
|
logger = logging.getLogger("InstancesManager")
|
||||||
|
|
||||||
@@ -214,5 +216,19 @@ class InstancesManager:
|
|||||||
if key[0] != session_id
|
if key[0] != session_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
|
||||||
|
logger.debug(f"Dynamic get: {component_type=} {instance_id=}")
|
||||||
|
cls = InstancesManager._get_class_name(component_type)
|
||||||
|
fully_qualified_name = f"myfasthtml.controls.{cls}.{cls}"
|
||||||
|
cls = get_class(fully_qualified_name)
|
||||||
|
return cls(parent, instance_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_class_name(component_type: str) -> str:
|
||||||
|
component_type = component_type.replace("mf-", "")
|
||||||
|
component_type = snake_to_pascal(component_type)
|
||||||
|
return component_type
|
||||||
|
|
||||||
|
|
||||||
RootInstance = SingleInstance(None, special_session, Ids.Root)
|
RootInstance = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ from fastcore.basics import NotStr
|
|||||||
from myfasthtml.core.utils import quoted_str
|
from myfasthtml.core.utils import quoted_str
|
||||||
from myfasthtml.test.testclient import MyFT
|
from myfasthtml.test.testclient import MyFT
|
||||||
|
|
||||||
MISSING_ATTR = "** MISSING **"
|
|
||||||
|
|
||||||
|
|
||||||
class Predicate:
|
class Predicate:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
@@ -116,18 +114,6 @@ class AttributeForbidden(ChildrenPredicate):
|
|||||||
return element
|
return element
|
||||||
|
|
||||||
|
|
||||||
class TestObject:
|
|
||||||
def __init__(self, cls, **kwargs):
|
|
||||||
self.cls = cls
|
|
||||||
self.attrs = kwargs
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommand(TestObject):
|
|
||||||
def __init__(self, name, **kwargs):
|
|
||||||
super().__init__("Command", **kwargs)
|
|
||||||
self.attrs = {"name": name} | kwargs # name should be first
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DoNotCheck:
|
class DoNotCheck:
|
||||||
desc: str = None
|
desc: str = None
|
||||||
@@ -201,16 +187,6 @@ class ErrorOutput:
|
|||||||
|
|
||||||
self.indent = self.indent[:-2]
|
self.indent = self.indent[:-2]
|
||||||
self._add_to_output(")")
|
self._add_to_output(")")
|
||||||
|
|
||||||
elif isinstance(self.expected, TestObject):
|
|
||||||
cls = _mytype(self.element)
|
|
||||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
|
||||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
|
||||||
# Try to show where the differences are
|
|
||||||
error_str = self._detect_error_2(self.element, self.expected)
|
|
||||||
if error_str:
|
|
||||||
self._add_to_output(error_str)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self._add_to_output(str(self.element))
|
self._add_to_output(str(self.element))
|
||||||
# Try to show where the differences are
|
# Try to show where the differences are
|
||||||
@@ -229,7 +205,7 @@ class ErrorOutput:
|
|||||||
|
|
||||||
if hasattr(element, "tag"):
|
if hasattr(element, "tag"):
|
||||||
# the attributes are compared to the expected element
|
# the attributes are compared to the expected element
|
||||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
|
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
|
||||||
[attr_name for attr_name in expected.attrs if attr_name is not None]}
|
[attr_name for attr_name in expected.attrs if attr_name is not None]}
|
||||||
|
|
||||||
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
||||||
@@ -252,7 +228,7 @@ class ErrorOutput:
|
|||||||
def _detect_error(self, element, expected):
|
def _detect_error(self, element, expected):
|
||||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
||||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
|
||||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||||
if attrs_in_error:
|
if attrs_in_error:
|
||||||
@@ -266,35 +242,6 @@ class ErrorOutput:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _detect_error_2(self, element, expected):
|
|
||||||
"""
|
|
||||||
Too lazy to refactor original _detect_error
|
|
||||||
:param element:
|
|
||||||
:param expected:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
|
||||||
element_cls = _mytype(element)
|
|
||||||
expected_cls = _mytype(expected)
|
|
||||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
|
||||||
|
|
||||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
|
||||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
|
||||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
|
||||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
|
||||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
|
||||||
for name, value in element_attrs.items())
|
|
||||||
if str_attrs_error.strip() or str_tag_error.strip():
|
|
||||||
return f" {str_tag_error} {str_attrs_error}"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
if not self._matches(element, expected):
|
|
||||||
return len(str(element)) * "^"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _matches(element, expected):
|
def _matches(element, expected):
|
||||||
if element == expected:
|
if element == expected:
|
||||||
@@ -400,34 +347,6 @@ def matches(actual, expected, path=""):
|
|||||||
# set the path
|
# set the path
|
||||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||||
|
|
||||||
if isinstance(expected, TestObject):
|
|
||||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
for attr, value in expected.attrs.items():
|
|
||||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
try:
|
|
||||||
matches(getattr(actual, attr), value)
|
|
||||||
except AssertionError as e:
|
|
||||||
match = re.search(r"Error : (.+?)\n", str(e))
|
|
||||||
if match:
|
|
||||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
|
||||||
_actual=getattr(actual, attr),
|
|
||||||
_expected=value)
|
|
||||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
|
||||||
_actual=getattr(actual, attr),
|
|
||||||
_expected=value)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if isinstance(expected, Predicate):
|
|
||||||
assert expected.validate(actual), \
|
|
||||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
@@ -440,14 +359,6 @@ def matches(actual, expected, path=""):
|
|||||||
for actual_child, expected_child in zip(actual, expected):
|
for actual_child, expected_child in zip(actual, expected):
|
||||||
assert matches(actual_child, expected_child, path=path)
|
assert matches(actual_child, expected_child, path=path)
|
||||||
|
|
||||||
elif isinstance(expected, dict):
|
|
||||||
if len(actual) < len(expected):
|
|
||||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
|
||||||
if len(actual) > len(expected):
|
|
||||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
|
||||||
for k, v in expected.items():
|
|
||||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
|
||||||
|
|
||||||
elif isinstance(expected, NotStr):
|
elif isinstance(expected, NotStr):
|
||||||
to_compare = actual.s.lstrip('\n').lstrip()
|
to_compare = actual.s.lstrip('\n').lstrip()
|
||||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||||
@@ -493,9 +404,8 @@ def matches(actual, expected, path=""):
|
|||||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||||
assert matches(actual_child, expected_child, path=path)
|
assert matches(actual_child, expected_child, path=path)
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
assert actual == expected, _error_msg("The values are different",
|
assert actual == expected, _error_msg("The values are different: ",
|
||||||
_actual=actual,
|
_actual=actual,
|
||||||
_expected=expected)
|
_expected=expected)
|
||||||
|
|
||||||
@@ -556,25 +466,3 @@ def find(ft, expected):
|
|||||||
raise AssertionError(f"No element found for '{expected}'")
|
raise AssertionError(f"No element found for '{expected}'")
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def _mytype(x):
|
|
||||||
if hasattr(x, "tag"):
|
|
||||||
return x.tag
|
|
||||||
if isinstance(x, TestObject):
|
|
||||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
|
||||||
return type(x).__name__
|
|
||||||
|
|
||||||
|
|
||||||
def _mygetattr(x, attr):
|
|
||||||
if hasattr(x, "attrs"):
|
|
||||||
return x.attrs.get(attr, MISSING_ATTR)
|
|
||||||
|
|
||||||
if not hasattr(x, attr):
|
|
||||||
return MISSING_ATTR
|
|
||||||
|
|
||||||
return getattr(x, attr, MISSING_ATTR)
|
|
||||||
|
|
||||||
|
|
||||||
def _str_attrs(attrs: dict):
|
|
||||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import pytest
|
|||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
|
|
||||||
|
|
||||||
class RootInstanceForTests(SingleInstance):
|
|
||||||
def __init__(self, session):
|
|
||||||
super().__init__(None, session, _id="TestRoot")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def session():
|
def session():
|
||||||
return {
|
return {
|
||||||
@@ -25,4 +20,4 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
return RootInstanceForTests(session=session)
|
return SingleInstance(None, session, "TestRoot")
|
||||||
|
|||||||
@@ -1,398 +0,0 @@
|
|||||||
"""Unit tests for TreeView component."""
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fasthtml.components import *
|
|
||||||
|
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
|
||||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
|
||||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
|
||||||
from .conftest import root_instance
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def cleanup_db():
|
|
||||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTreeviewBehaviour:
|
|
||||||
"""Tests for TreeView behavior and logic."""
|
|
||||||
|
|
||||||
def test_i_can_create_tree_node_with_auto_generated_id(self):
|
|
||||||
"""Test that TreeNode generates UUID automatically."""
|
|
||||||
node = TreeNode(label="Test Node", type="folder")
|
|
||||||
|
|
||||||
assert node.id is not None
|
|
||||||
assert isinstance(node.id, str)
|
|
||||||
assert len(node.id) > 0
|
|
||||||
|
|
||||||
def test_i_can_create_tree_node_with_default_values(self):
|
|
||||||
"""Test that TreeNode has correct default values."""
|
|
||||||
node = TreeNode()
|
|
||||||
|
|
||||||
assert node.label == ""
|
|
||||||
assert node.type == "default"
|
|
||||||
assert node.parent is None
|
|
||||||
assert node.children == []
|
|
||||||
|
|
||||||
def test_i_can_initialize_tree_view_state(self, root_instance):
|
|
||||||
"""Test that TreeViewState initializes with default values."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
state = tree_view._state
|
|
||||||
|
|
||||||
assert isinstance(state.items, dict)
|
|
||||||
assert len(state.items) == 0
|
|
||||||
assert state.opened == []
|
|
||||||
assert state.selected is None
|
|
||||||
assert state.editing is None
|
|
||||||
assert state.icon_config == {}
|
|
||||||
|
|
||||||
def test_i_can_create_empty_treeview(self, root_instance):
|
|
||||||
"""Test creating an empty TreeView."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
assert tree_view is not None
|
|
||||||
assert len(tree_view._state.items) == 0
|
|
||||||
|
|
||||||
def test_i_can_add_node_to_treeview(self, root_instance):
|
|
||||||
"""Test adding a root node to the TreeView."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Root Node", type="folder")
|
|
||||||
|
|
||||||
tree_view.add_node(node)
|
|
||||||
|
|
||||||
assert node.id in tree_view._state.items
|
|
||||||
assert tree_view._state.items[node.id].label == "Root Node"
|
|
||||||
assert tree_view._state.items[node.id].parent is None
|
|
||||||
|
|
||||||
def test_i_can_add_child_node(self, root_instance):
|
|
||||||
"""Test adding a child node to a parent."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
assert child.id in tree_view._state.items
|
|
||||||
assert child.id in parent.children
|
|
||||||
assert child.parent == parent.id
|
|
||||||
|
|
||||||
def test_i_can_set_icon_config(self, root_instance):
|
|
||||||
"""Test setting icon configuration."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
config = {
|
|
||||||
"folder": "fluent.folder",
|
|
||||||
"file": "fluent.document"
|
|
||||||
}
|
|
||||||
|
|
||||||
tree_view.set_icon_config(config)
|
|
||||||
|
|
||||||
assert tree_view._state.icon_config == config
|
|
||||||
assert tree_view._state.icon_config["folder"] == "fluent.folder"
|
|
||||||
|
|
||||||
def test_i_can_toggle_node(self, root_instance):
|
|
||||||
"""Test expand/collapse a node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Node", type="folder")
|
|
||||||
tree_view.add_node(node)
|
|
||||||
|
|
||||||
# Initially closed
|
|
||||||
assert node.id not in tree_view._state.opened
|
|
||||||
|
|
||||||
# Toggle to open
|
|
||||||
tree_view._toggle_node(node.id)
|
|
||||||
assert node.id in tree_view._state.opened
|
|
||||||
|
|
||||||
# Toggle to close
|
|
||||||
tree_view._toggle_node(node.id)
|
|
||||||
assert node.id not in tree_view._state.opened
|
|
||||||
|
|
||||||
def test_i_can_expand_all_nodes(self, root_instance):
|
|
||||||
"""Test that expand_all opens all nodes with children."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Create hierarchy: root -> child1 -> grandchild
|
|
||||||
# -> child2 (leaf)
|
|
||||||
root = TreeNode(label="Root", type="folder")
|
|
||||||
child1 = TreeNode(label="Child 1", type="folder")
|
|
||||||
grandchild = TreeNode(label="Grandchild", type="file")
|
|
||||||
child2 = TreeNode(label="Child 2", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(root)
|
|
||||||
tree_view.add_node(child1, parent_id=root.id)
|
|
||||||
tree_view.add_node(grandchild, parent_id=child1.id)
|
|
||||||
tree_view.add_node(child2, parent_id=root.id)
|
|
||||||
|
|
||||||
# Initially all closed
|
|
||||||
assert len(tree_view._state.opened) == 0
|
|
||||||
|
|
||||||
# Expand all
|
|
||||||
tree_view.expand_all()
|
|
||||||
|
|
||||||
# Nodes with children should be opened
|
|
||||||
assert root.id in tree_view._state.opened
|
|
||||||
assert child1.id in tree_view._state.opened
|
|
||||||
|
|
||||||
# Leaf nodes should not be in opened list
|
|
||||||
assert grandchild.id not in tree_view._state.opened
|
|
||||||
assert child2.id not in tree_view._state.opened
|
|
||||||
|
|
||||||
def test_i_can_select_node(self, root_instance):
|
|
||||||
"""Test selecting a node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Node", type="folder")
|
|
||||||
tree_view.add_node(node)
|
|
||||||
|
|
||||||
tree_view._select_node(node.id)
|
|
||||||
|
|
||||||
assert tree_view._state.selected == node.id
|
|
||||||
|
|
||||||
def test_i_can_start_rename_node(self, root_instance):
|
|
||||||
"""Test starting rename mode for a node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Old Name", type="folder")
|
|
||||||
tree_view.add_node(node)
|
|
||||||
|
|
||||||
tree_view._start_rename(node.id)
|
|
||||||
|
|
||||||
assert tree_view._state.editing == node.id
|
|
||||||
|
|
||||||
def test_i_can_save_rename_node(self, root_instance):
|
|
||||||
"""Test saving renamed node with new label."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Old Name", type="folder")
|
|
||||||
tree_view.add_node(node)
|
|
||||||
tree_view._start_rename(node.id)
|
|
||||||
|
|
||||||
tree_view._save_rename(node.id, "New Name")
|
|
||||||
|
|
||||||
assert tree_view._state.items[node.id].label == "New Name"
|
|
||||||
assert tree_view._state.editing is None
|
|
||||||
|
|
||||||
def test_i_can_cancel_rename_node(self, root_instance):
|
|
||||||
"""Test canceling rename operation."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
node = TreeNode(label="Name", type="folder")
|
|
||||||
tree_view.add_node(node)
|
|
||||||
tree_view._start_rename(node.id)
|
|
||||||
|
|
||||||
tree_view._cancel_rename()
|
|
||||||
|
|
||||||
assert tree_view._state.editing is None
|
|
||||||
assert tree_view._state.items[node.id].label == "Name"
|
|
||||||
|
|
||||||
def test_i_can_delete_leaf_node(self, root_instance):
|
|
||||||
"""Test deleting a node without children."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Delete child (leaf node)
|
|
||||||
tree_view._delete_node(child.id)
|
|
||||||
|
|
||||||
assert child.id not in tree_view._state.items
|
|
||||||
assert child.id not in parent.children
|
|
||||||
|
|
||||||
def test_i_can_add_sibling_node(self, root_instance):
|
|
||||||
"""Test adding a sibling node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child1 = TreeNode(label="Child 1", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child1, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Add sibling to child1
|
|
||||||
tree_view._add_sibling(child1.id, new_label="Child 2")
|
|
||||||
|
|
||||||
assert len(parent.children) == 2
|
|
||||||
# Sibling should be after child1
|
|
||||||
assert parent.children.index(child1.id) < len(parent.children) - 1
|
|
||||||
|
|
||||||
def test_i_cannot_delete_node_with_children(self, root_instance):
|
|
||||||
"""Test that deleting a node with children raises an error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Try to delete parent (has children)
|
|
||||||
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
|
|
||||||
tree_view._delete_node(parent.id)
|
|
||||||
|
|
||||||
def test_i_cannot_add_sibling_to_root(self, root_instance):
|
|
||||||
"""Test that adding sibling to root node raises an error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
root = TreeNode(label="Root", type="folder")
|
|
||||||
tree_view.add_node(root)
|
|
||||||
|
|
||||||
# Try to add sibling to root (no parent)
|
|
||||||
with pytest.raises(ValueError, match="Cannot add sibling to root node"):
|
|
||||||
tree_view._add_sibling(root.id)
|
|
||||||
|
|
||||||
def test_i_cannot_select_nonexistent_node(self, root_instance):
|
|
||||||
"""Test that selecting a nonexistent node raises an error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Try to select node that doesn't exist
|
|
||||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
|
||||||
tree_view._select_node("nonexistent_id")
|
|
||||||
|
|
||||||
def test_add_node_prevents_duplicate_children(self, root_instance):
|
|
||||||
"""Test that add_node prevents adding duplicate child IDs."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Try to add the same child again
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Child should appear only once in parent's children list
|
|
||||||
assert parent.children.count(child.id) == 1
|
|
||||||
|
|
||||||
def test_sibling_is_inserted_at_correct_position(self, root_instance):
|
|
||||||
"""Test that _add_sibling inserts sibling exactly after reference node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child1 = TreeNode(label="Child 1", type="file")
|
|
||||||
child3 = TreeNode(label="Child 3", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child1, parent_id=parent.id)
|
|
||||||
tree_view.add_node(child3, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Add sibling after child1
|
|
||||||
tree_view._add_sibling(child1.id, new_label="Child 2")
|
|
||||||
|
|
||||||
# Get the newly added sibling
|
|
||||||
sibling_id = parent.children[1]
|
|
||||||
|
|
||||||
# Verify order: child1, sibling (child2), child3
|
|
||||||
assert parent.children[0] == child1.id
|
|
||||||
assert tree_view._state.items[sibling_id].label == "Child 2"
|
|
||||||
assert parent.children[2] == child3.id
|
|
||||||
assert len(parent.children) == 3
|
|
||||||
|
|
||||||
def test_add_child_auto_expands_parent(self, root_instance):
|
|
||||||
"""Test that _add_child automatically expands the parent node."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
|
|
||||||
# Parent should not be expanded initially
|
|
||||||
assert parent.id not in tree_view._state.opened
|
|
||||||
|
|
||||||
# Add child
|
|
||||||
tree_view._add_child(parent.id, new_label="Child")
|
|
||||||
|
|
||||||
# Parent should now be expanded
|
|
||||||
assert parent.id in tree_view._state.opened
|
|
||||||
|
|
||||||
def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance):
|
|
||||||
"""Test that adding child to nonexistent parent raises error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Try to add child to parent that doesn't exist
|
|
||||||
with pytest.raises(ValueError, match="Parent node.*does not exist"):
|
|
||||||
tree_view._add_child("nonexistent_parent_id")
|
|
||||||
|
|
||||||
def test_delete_node_clears_selection_if_selected(self, root_instance):
|
|
||||||
"""Test that deleting a selected node clears the selection."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Select the child
|
|
||||||
tree_view._select_node(child.id)
|
|
||||||
assert tree_view._state.selected == child.id
|
|
||||||
|
|
||||||
# Delete the selected child
|
|
||||||
tree_view._delete_node(child.id)
|
|
||||||
|
|
||||||
# Selection should be cleared
|
|
||||||
assert tree_view._state.selected is None
|
|
||||||
|
|
||||||
def test_delete_node_removes_from_opened_if_expanded(self, root_instance):
|
|
||||||
"""Test that deleting an expanded node removes it from opened list."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
parent = TreeNode(label="Parent", type="folder")
|
|
||||||
child = TreeNode(label="Child", type="file")
|
|
||||||
|
|
||||||
tree_view.add_node(parent)
|
|
||||||
tree_view.add_node(child, parent_id=parent.id)
|
|
||||||
|
|
||||||
# Expand the parent
|
|
||||||
tree_view._toggle_node(parent.id)
|
|
||||||
assert parent.id in tree_view._state.opened
|
|
||||||
|
|
||||||
# Delete the child (making parent a leaf)
|
|
||||||
tree_view._delete_node(child.id)
|
|
||||||
|
|
||||||
# Now delete the parent (now a leaf node)
|
|
||||||
# First remove it from root by creating a grandparent
|
|
||||||
grandparent = TreeNode(label="Grandparent", type="folder")
|
|
||||||
tree_view.add_node(grandparent)
|
|
||||||
parent.parent = grandparent.id
|
|
||||||
grandparent.children.append(parent.id)
|
|
||||||
|
|
||||||
tree_view._delete_node(parent.id)
|
|
||||||
|
|
||||||
# Parent should be removed from opened list
|
|
||||||
assert parent.id not in tree_view._state.opened
|
|
||||||
|
|
||||||
def test_i_cannot_start_rename_nonexistent_node(self, root_instance):
|
|
||||||
"""Test that starting rename on nonexistent node raises error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Try to start rename on node that doesn't exist
|
|
||||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
|
||||||
tree_view._start_rename("nonexistent_id")
|
|
||||||
|
|
||||||
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
|
|
||||||
"""Test that saving rename for nonexistent node raises error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Try to save rename for node that doesn't exist
|
|
||||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
|
||||||
tree_view._save_rename("nonexistent_id", "New Name")
|
|
||||||
|
|
||||||
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
|
|
||||||
"""Test that adding sibling to nonexistent node raises error."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
|
|
||||||
# Try to add sibling to node that doesn't exist
|
|
||||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
|
||||||
tree_view._add_sibling("nonexistent_id")
|
|
||||||
|
|
||||||
|
|
||||||
class TestTreeViewRender:
|
|
||||||
"""Tests for TreeView HTML rendering."""
|
|
||||||
|
|
||||||
def test_i_can_render_empty_treeview(self, root_instance):
|
|
||||||
"""Test that TreeView generates correct HTML structure."""
|
|
||||||
tree_view = TreeView(root_instance)
|
|
||||||
expected = Div(
|
|
||||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
|
||||||
_id=tree_view.get_id(),
|
|
||||||
cls="mf-treeview"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(tree_view.__ft__(), expected)
|
|
||||||
|
|
||||||
def test_node_action_buttons_are_rendered(self):
|
|
||||||
"""Test that action buttons are present in rendered HTML."""
|
|
||||||
# Signature only - implementation later
|
|
||||||
pass
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -3,31 +3,15 @@ from fastcore.basics import NotStr
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
||||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
|
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
||||||
from myfasthtml.test.testclient import MyFT
|
from myfasthtml.test.testclient import MyFT
|
||||||
|
|
||||||
|
|
||||||
class Dummy:
|
@pytest.mark.parametrize('actual, expected', [
|
||||||
def __init__(self, attr1, attr2=None):
|
|
||||||
self.attr1 = attr1
|
|
||||||
self.attr2 = attr2
|
|
||||||
|
|
||||||
|
|
||||||
class Dummy2:
|
|
||||||
def __init__(self, attr1, attr2):
|
|
||||||
self.attr1 = attr1
|
|
||||||
self.attr2 = attr2
|
|
||||||
|
|
||||||
|
|
||||||
class TestMatches:
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('actual, expected', [
|
|
||||||
(None, None),
|
(None, None),
|
||||||
(123, 123),
|
(123, 123),
|
||||||
(Div(), Div()),
|
(Div(), Div()),
|
||||||
([Div(), Span()], [Div(), Span()]),
|
([Div(), Span()], [Div(), Span()]),
|
||||||
({"key": Div(attr="value")}, {"key": Div(attr="value")}),
|
|
||||||
({"key": Dummy(attr1="value")}, {"key": TestObject(Dummy, attr1="value")}),
|
|
||||||
(Div(attr1="value"), Div(attr1="value")),
|
(Div(attr1="value"), Div(attr1="value")),
|
||||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||||
@@ -46,15 +30,12 @@ class TestMatches:
|
|||||||
(Div(123), Div(123)),
|
(Div(123), Div(123)),
|
||||||
(Div(Span(123)), Div(Span(123))),
|
(Div(Span(123)), Div(Span(123))),
|
||||||
(Div(Span(123)), Div(DoNotCheck())),
|
(Div(Span(123)), Div(DoNotCheck())),
|
||||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
|
])
|
||||||
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
|
def test_i_can_match(actual, expected):
|
||||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
|
|
||||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
|
|
||||||
])
|
|
||||||
def test_i_can_match(self, actual, expected):
|
|
||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
|
|
||||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
|
||||||
|
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||||
(None, Div(), "Actual is None"),
|
(None, Div(), "Actual is None"),
|
||||||
(Div(), None, "Actual is not None"),
|
(Div(), None, "Actual is not None"),
|
||||||
(123, Div(), "The types are different"),
|
(123, Div(), "The types are different"),
|
||||||
@@ -86,22 +67,14 @@ class TestMatches:
|
|||||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
||||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||||
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
|
])
|
||||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
|
def test_i_can_detect_errors(actual, expected, error_message):
|
||||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
|
|
||||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
|
|
||||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
|
|
||||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
|
|
||||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
|
|
||||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
|
||||||
|
|
||||||
])
|
|
||||||
def test_i_can_detect_errors(self, actual, expected, error_message):
|
|
||||||
with pytest.raises(AssertionError) as exc_info:
|
with pytest.raises(AssertionError) as exc_info:
|
||||||
matches(actual, expected)
|
matches(actual, expected)
|
||||||
assert error_message in str(exc_info.value)
|
assert error_message in str(exc_info.value)
|
||||||
|
|
||||||
@pytest.mark.parametrize('element, expected_path', [
|
|
||||||
|
@pytest.mark.parametrize('element, expected_path', [
|
||||||
(Div(), "Path : 'div"),
|
(Div(), "Path : 'div"),
|
||||||
(Div(Span()), "Path : 'div.span"),
|
(Div(Span()), "Path : 'div.span"),
|
||||||
(Div(Span(Div())), "Path : 'div.span.div"),
|
(Div(Span(Div())), "Path : 'div.span.div"),
|
||||||
@@ -110,8 +83,8 @@ class TestMatches:
|
|||||||
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
||||||
(Div(attr="value"), "Path : 'div"),
|
(Div(attr="value"), "Path : 'div"),
|
||||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
|
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
|
||||||
])
|
])
|
||||||
def test_i_can_properly_show_path(self, element, expected_path):
|
def test_i_can_properly_show_path(element, expected_path):
|
||||||
def _construct_test_element(source, tail):
|
def _construct_test_element(source, tail):
|
||||||
res = MyFT(source.tag, source.attrs)
|
res = MyFT(source.tag, source.attrs)
|
||||||
if source.children:
|
if source.children:
|
||||||
@@ -128,9 +101,7 @@ class TestMatches:
|
|||||||
assert expected_path in str(exc_info.value)
|
assert expected_path in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
class TestErrorOutput:
|
def test_i_can_output_error_path():
|
||||||
def test_i_can_output_error_path(self):
|
|
||||||
"""The output follows the representation of the given path"""
|
|
||||||
elt = Div()
|
elt = Div()
|
||||||
expected = Div()
|
expected = Div()
|
||||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||||
@@ -142,7 +113,8 @@ class TestErrorOutput:
|
|||||||
' (p "name"="p_name" ...',
|
' (p "name"="p_name" ...',
|
||||||
' (div )']
|
' (div )']
|
||||||
|
|
||||||
def test_i_can_output_error_attribute(self):
|
|
||||||
|
def test_i_can_output_error_attribute():
|
||||||
elt = Div(attr1="value1", attr2="value2")
|
elt = Div(attr1="value1", attr2="value2")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -150,7 +122,8 @@ class TestErrorOutput:
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||||
|
|
||||||
def test_i_can_output_error_attribute_missing_1(self):
|
|
||||||
|
def test_i_can_output_error_attribute_missing_1():
|
||||||
elt = Div(attr2="value2")
|
elt = Div(attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -159,7 +132,8 @@ class TestErrorOutput:
|
|||||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
def test_i_can_output_error_attribute_missing_2(self):
|
|
||||||
|
def test_i_can_output_error_attribute_missing_2():
|
||||||
elt = Div(attr1="value1")
|
elt = Div(attr1="value1")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -168,7 +142,8 @@ class TestErrorOutput:
|
|||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||||
|
|
||||||
def test_i_can_output_error_attribute_wrong_value(self):
|
|
||||||
|
def test_i_can_output_error_attribute_wrong_value():
|
||||||
elt = Div(attr1="value3", attr2="value2")
|
elt = Div(attr1="value3", attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -177,7 +152,8 @@ class TestErrorOutput:
|
|||||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
def test_i_can_output_error_constant(self):
|
|
||||||
|
def test_i_can_output_error_constant():
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -185,7 +161,8 @@ class TestErrorOutput:
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['123']
|
assert error_output.output == ['123']
|
||||||
|
|
||||||
def test_i_can_output_error_constant_wrong_value(self):
|
|
||||||
|
def test_i_can_output_error_constant_wrong_value():
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = 456
|
expected = 456
|
||||||
path = ""
|
path = ""
|
||||||
@@ -194,7 +171,8 @@ class TestErrorOutput:
|
|||||||
assert error_output.output == ['123',
|
assert error_output.output == ['123',
|
||||||
'^^^']
|
'^^^']
|
||||||
|
|
||||||
def test_i_can_output_error_when_predicate(self):
|
|
||||||
|
def test_i_can_output_error_when_predicate():
|
||||||
elt = "before value after"
|
elt = "before value after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -202,7 +180,8 @@ class TestErrorOutput:
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ["before value after"]
|
assert error_output.output == ["before value after"]
|
||||||
|
|
||||||
def test_i_can_output_error_when_predicate_wrong_value(self):
|
|
||||||
|
def test_i_can_output_error_when_predicate_wrong_value():
|
||||||
"""I can display error when the condition predicate is not satisfied."""
|
"""I can display error when the condition predicate is not satisfied."""
|
||||||
elt = "before after"
|
elt = "before after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
@@ -212,7 +191,8 @@ class TestErrorOutput:
|
|||||||
assert error_output.output == ["before after",
|
assert error_output.output == ["before after",
|
||||||
"^^^^^^^^^^^^"]
|
"^^^^^^^^^^^^"]
|
||||||
|
|
||||||
def test_i_can_output_error_child_element(self):
|
|
||||||
|
def test_i_can_output_error_child_element():
|
||||||
"""I can display error when the element has children"""
|
"""I can display error when the element has children"""
|
||||||
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -226,7 +206,8 @@ class TestErrorOutput:
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_i_can_output_error_child_element_text(self):
|
|
||||||
|
def test_i_can_output_error_child_element_text():
|
||||||
"""I can display error when the children is not a FT"""
|
"""I can display error when the children is not a FT"""
|
||||||
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -240,7 +221,8 @@ class TestErrorOutput:
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_i_can_output_error_child_element_indicating_sub_children(self):
|
|
||||||
|
def test_i_can_output_error_child_element_indicating_sub_children():
|
||||||
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -252,7 +234,8 @@ class TestErrorOutput:
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_i_can_output_error_child_element_wrong_value(self):
|
|
||||||
|
def test_i_can_output_error_child_element_wrong_value():
|
||||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -265,7 +248,8 @@ class TestErrorOutput:
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_i_can_output_error_fewer_elements(self):
|
|
||||||
|
def test_i_can_output_error_fewer_elements():
|
||||||
elt = Div(P(id="p_id"), attr1="value1")
|
elt = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -277,61 +261,8 @@ class TestErrorOutput:
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_i_can_output_error_test_object(self):
|
|
||||||
elt = TestObject(Dummy, attr1=123, attr2="value2")
|
|
||||||
expected = elt
|
|
||||||
path = ""
|
|
||||||
error_output = ErrorOutput(path, elt, expected)
|
|
||||||
error_output.compute()
|
|
||||||
assert error_output.output == ['(Dummy "attr1"="123" "attr2"="value2")']
|
|
||||||
|
|
||||||
def test_i_can_output_error_test_object_wrong_type(self):
|
def test_i_can_output_comparison():
|
||||||
elt = Div(attr1=123, attr2="value2")
|
|
||||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
|
||||||
path = ""
|
|
||||||
error_output = ErrorOutput(path, elt, expected)
|
|
||||||
error_output.compute()
|
|
||||||
assert error_output.output == [
|
|
||||||
'(div "attr1"="123" "attr2"="value2")',
|
|
||||||
' ^^^ '
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_i_can_output_error_test_object_wrong_type_2(self):
|
|
||||||
elt = Dummy2(attr1=123, attr2="value2")
|
|
||||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
|
||||||
path = ""
|
|
||||||
error_output = ErrorOutput(path, elt, expected)
|
|
||||||
error_output.compute()
|
|
||||||
assert error_output.output == [
|
|
||||||
'(Dummy2 "attr1"="123" "attr2"="value2")',
|
|
||||||
' ^^^^^^ '
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_i_can_output_error_test_object_wrong_type_3(self):
|
|
||||||
elt = Div(attr1=123, attr2="value2")
|
|
||||||
expected = TestObject("Dummy", attr1=123, attr2="value2")
|
|
||||||
path = ""
|
|
||||||
error_output = ErrorOutput(path, elt, expected)
|
|
||||||
error_output.compute()
|
|
||||||
assert error_output.output == [
|
|
||||||
'(div "attr1"="123" "attr2"="value2")',
|
|
||||||
' ^^^ '
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_i_can_output_error_test_object_wrong_value(self):
|
|
||||||
elt = Dummy(attr1="456", attr2="value2")
|
|
||||||
expected = TestObject(Dummy, attr1="123", attr2="value2")
|
|
||||||
path = ""
|
|
||||||
error_output = ErrorOutput(path, elt, expected)
|
|
||||||
error_output.compute()
|
|
||||||
assert error_output.output == [
|
|
||||||
'(Dummy "attr1"="456" "attr2"="value2")',
|
|
||||||
' ^^^^^^^^^^^^^ '
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorComparisonOutput:
|
|
||||||
def test_i_can_output_comparison(self):
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -346,7 +277,8 @@ class TestErrorComparisonOutput:
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
def test_i_can_output_comparison_with_path(self):
|
|
||||||
|
def test_i_can_output_comparison_with_path():
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||||
@@ -363,7 +295,8 @@ class TestErrorComparisonOutput:
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
def test_i_can_output_comparison_when_missing_attributes(self):
|
|
||||||
|
def test_i_can_output_comparison_when_missing_attributes():
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), attr2="value1")
|
expected = Div(P(id="p_id"), attr2="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -379,7 +312,8 @@ class TestErrorComparisonOutput:
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
def test_i_can_output_comparison_when_wrong_attributes(self):
|
|
||||||
|
def test_i_can_output_comparison_when_wrong_attributes():
|
||||||
actual = Div(P(id="p_id"), attr1="value2")
|
actual = Div(P(id="p_id"), attr1="value2")
|
||||||
expected = Div(P(id="p_id"), attr1="value1")
|
expected = Div(P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -395,7 +329,8 @@ class TestErrorComparisonOutput:
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
def test_i_can_output_comparison_when_fewer_elements(self):
|
|
||||||
|
def test_i_can_output_comparison_when_fewer_elements():
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -412,7 +347,8 @@ class TestErrorComparisonOutput:
|
|||||||
! ** MISSING ** ! | (p "id"="p_id")
|
! ** MISSING ** ! | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
def test_i_can_see_the_diff_when_matching(self):
|
|
||||||
|
def test_i_can_see_the_diff_when_matching():
|
||||||
actual = Div(attr1="value1")
|
actual = Div(attr1="value1")
|
||||||
expected = Div(attr1=Contains("value2"))
|
expected = Div(attr1=Contains("value2"))
|
||||||
|
|
||||||
@@ -425,18 +361,3 @@ Path : 'div'
|
|||||||
Error : The condition 'Contains(value2)' is not satisfied.
|
Error : The condition 'Contains(value2)' is not satisfied.
|
||||||
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
||||||
^^^^^^^^^^^^^^^^ |"""
|
^^^^^^^^^^^^^^^^ |"""
|
||||||
|
|
||||||
def test_i_can_see_the_diff_with_test_object_when_wrong_type(self):
|
|
||||||
actual = Div(attr1=123, attr2="value2")
|
|
||||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
|
||||||
|
|
||||||
actual_out = ErrorOutput("dummy", actual, expected)
|
|
||||||
expected_out = ErrorOutput("div", expected, expected)
|
|
||||||
|
|
||||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
|
||||||
|
|
||||||
res = comparison_out.render()
|
|
||||||
|
|
||||||
assert "\n" + res == '''
|
|
||||||
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
|
||||||
^^^ |'''
|
|
||||||
|
|||||||
Reference in New Issue
Block a user