9 Commits

58 changed files with 739 additions and 9729 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -18,7 +18,6 @@ clean-tests:
rm -rf .sesskey
rm -rf tests/.sesskey
rm -rf tests/*.db
rm -rf tests/.myFastHtmlDb
# Alias to clean everything
clean: clean-build clean-tests
@@ -26,4 +25,3 @@ clean: clean-build clean-tests
clean-all : clean
rm -rf src/.sesskey
rm -rf src/Users.db
rm -rf src/.myFastHtmlDb

View File

@@ -1,345 +0,0 @@
# Keyboard Support - Test Instructions
## ⚠️ Breaking Change
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
**Old format (no longer supported)**:
```javascript
{"A": "/url"}
```
**New format (required)**:
```javascript
{"A": {"hx-post": "/url"}}
```
## Files
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
- `test_keyboard_support.html` - Test page to verify functionality
## Key Features
### Multiple Simultaneous Triggers
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
```javascript
add_keyboard_support('modal', '{"esc": "/close-modal"}');
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
// Pressing ESC will trigger all 3 URLs simultaneously
```
This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.).
### Smart Timeout Logic (Longest Match)
The library uses **a single global timeout** based on the sequence state, not on individual elements:
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
Examples:
**Example 1**: Three elements, same combination
```javascript
add_keyboard_support('elem1', '{"esc": "/url1"}');
add_keyboard_support('elem2', '{"esc": "/url2"}');
add_keyboard_support('elem3', '{"esc": "/url3"}');
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
```
**Example 2**: Mixed - one has longer sequence
```javascript
add_keyboard_support('elem1', '{"A": "/url1"}');
add_keyboard_support('elem2', '{"A": "/url2"}');
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
// Press A:
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
// - If B arrives: only elem3 triggers with "A B"
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
```
**Example 3**: Different combinations
```javascript
add_keyboard_support('elem1', '{"A B": "/url1"}');
add_keyboard_support('elem2', '{"C D": "/url2"}');
// Press A: elem1 waits for B, elem2 not affected
// Press C: elem2 waits for D, elem1 not affected
```
The timeout is tied to the **sequence being typed**, not to individual elements.
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
- `<input>` elements
- `<textarea>` elements
- Any `contenteditable` element
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
## How to Test
1. **Download both files** to the same directory
2. **Open `test_keyboard_support.html`** in a web browser
3. **Try the configured shortcuts**:
- `a` - Simple key (waits if "A B" might follow)
- `Ctrl+S` - Save combination (immediate)
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
- `A B` - Sequence (waits because "A B C" exists)
- `A B C` - Triple sequence (triggers immediately)
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
- `shift shift` - Press Shift twice in sequence
- `esc` - Escape key (immediate)
4. **Test focus behavior**:
- Click on the test element to focus it (turns blue)
- Try shortcuts with focus
- Click outside to remove focus
- Try shortcuts without focus
- The log shows whether the element had focus when triggered
5. **Test input protection**:
- Try typing in the input field
- Use Ctrl+C, Ctrl+A, etc. - should work normally
- Shortcuts should NOT trigger while typing in input
## Expected Behavior
### Smart Timeout Examples
**Scenario 1**: Only "A" is configured (no element has "A B")
- Press A → Triggers **immediately**
**Scenario 2**: At least one element has "A B"
- Press A → **ALL elements with "A" wait 500ms**
- If B pressed within 500ms → Only elements with "A B" trigger
- If timeout expires → ALL elements with "A" trigger
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
- Press A → Waits (because "A B" exists)
- Press B → Waits (because "A B C" exists)
- Press C → Triggers "A B C" **immediately**
**Scenario 4**: Multiple elements, ESC on all
```javascript
add_keyboard_support('modal', '{"esc": "/close"}');
add_keyboard_support('editor', '{"esc": "/cancel"}');
```
- Press ESC → **Both trigger simultaneously** (no longer sequences)
## Integration in Your Project
## Integration in Your Project
### Configuration Format
The library now uses **HTMX configuration objects** instead of simple URL strings:
```python
# New format with HTMX configuration
combinations = {
"Ctrl+S": {
"hx-post": "/save-url",
"hx-target": "#result",
"hx-swap": "innerHTML"
},
"A B": {
"hx-post": "/sequence-url",
"hx-vals": {"extra": "data"}
},
"esc": {
"hx-get": "/cancel-url"
}
}
# This will generate the JavaScript call
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
```
### Supported HTMX Attributes
You can use any HTMX attribute in the configuration object:
**HTTP Methods** (one required):
- `hx-post` - POST request
- `hx-get` - GET request
- `hx-put` - PUT request
- `hx-delete` - DELETE request
- `hx-patch` - PATCH request
**Common Options**:
- `hx-target` - Target element selector
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
- `hx-vals` - Additional values to send (object)
- `hx-headers` - Custom headers (object)
- `hx-select` - Select specific content from response
- `hx-confirm` - Confirmation message
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
### Automatic Parameters
The library automatically adds these parameters to every request:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
Example final request:
```javascript
htmx.ajax('POST', '/save-url', {
target: '#result',
swap: 'innerHTML',
values: {
extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic
has_focus: true, // automatic
is_inside: true // automatic
}
})
```
## Integration Examples
### Basic Usage
```python
combinations = {
"Ctrl+S": {
"hx-post": "/save"
}
}
```
### With Target and Swap
```python
combinations = {
"Ctrl+D": {
"hx-delete": "/item",
"hx-target": "#item-list",
"hx-swap": "outerHTML"
}
}
```
### With Extra Values
```python
combinations = {
"Ctrl+N": {
"hx-post": "/create",
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
}
}
```
### Multiple Elements Example
```python
# Modal close
modal_combinations = {
"esc": {
"hx-post": "/modal/close",
"hx-target": "#modal",
"hx-swap": "outerHTML"
}
}
# Editor cancel
editor_combinations = {
"esc": {
"hx-post": "/editor/cancel",
"hx-target": "#editor",
"hx-swap": "innerHTML"
}
}
# Both will trigger when ESC is pressed
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
```
### Removing Keyboard Support
When you no longer need keyboard support for an element:
```python
# Remove keyboard support
f"remove_keyboard_support('{element_id}')"
```
**Behavior**:
- Removes the element from the keyboard registry
- If this was the last element, automatically detaches global event listeners
- Cleans up all associated state (timeouts, snapshots, etc.)
- Other elements continue to work normally
**Example**:
```javascript
// Add support
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
// Later, remove support
remove_keyboard_support('modal');
// If no other elements remain, keyboard listeners are completely removed
```
## API Reference
### add_keyboard_support(elementId, combinationsJson)
Adds keyboard support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
### remove_keyboard_support(elementId)
Removes keyboard support from an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
**Returns**: void
**Side effects**:
- If last element: detaches global event listeners and cleans up all state
## Technical Details
### Trie-based Matching
The library uses a prefix tree (trie) data structure:
- Each node represents a keyboard snapshot (set of pressed keys)
- Leaf nodes contain the HTMX configuration object
- Intermediate nodes indicate longer sequences exist
- Enables efficient O(n) matching where n is sequence length
### HTMX Integration
Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
- `combination`, `has_focus`, and `is_inside` are automatically added to values
- All standard HTMX options are supported
### Key Normalization
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
- Simultaneous keys represented as sorted sets
## Notes
- The test page mocks `htmx.ajax` to display results in the console
- In production, real AJAX calls will be made to your backend
- Sequence timeout is 500ms between keys
- Maximum 10 snapshots kept in history to prevent memory issues

View File

@@ -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

View File

@@ -16,7 +16,6 @@ dnspython==2.8.0
docutils==0.22.2
ecdsa==0.19.1
email-validator==2.3.0
et_xmlfile==2.0.0
fastapi==0.120.0
fastcore==1.8.13
fastlite==0.2.1
@@ -36,15 +35,12 @@ keyring==25.6.0
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.1
myauth==0.2.0
mydbengine==0.1.0
myutils==0.4.0
nh3==0.3.1
numpy==2.3.5
oauthlib==3.3.1
openpyxl==3.1.5
packaging==25.0
pandas==2.3.3
passlib==1.7.4
pipdeptree==2.29.0
pluggy==1.6.0
@@ -61,7 +57,6 @@ python-dotenv==1.1.1
python-fasthtml==0.12.30
python-jose==3.5.0
python-multipart==0.0.20
pytz==2025.2
PyYAML==6.0.3
readme_renderer==44.0
requests==2.32.5
@@ -79,7 +74,6 @@ twine==6.2.0
typer==0.20.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2
urllib3==2.5.0
uvicorn==0.38.0
uvloop==0.22.1

View File

@@ -1,131 +1,53 @@
import logging.config
import logging
import yaml
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.instances import UniqueInstance
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.core.commands import Command
from myfasthtml.core.instances import InstancesManager
from myfasthtml.myfastapp import create_app
with open('logging.yaml', 'r') as f:
config = yaml.safe_load(f)
# At the top of your script or module
logging.config.dictConfig(config)
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=True,
mount_auth_app=True,
pico=False,
vis=True,
title="MyFastHtml",
live=True,
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("/")
def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
layout = Layout(session_instance, "Testing Layout")
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
layout.set_footer("Goodbye World")
for i in range(1000):
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
layout.right_drawer.append(Div(f"Left Drawer Item {i}"))
tabs_manager = TabsManager(session, _id="main")
btn = mk.button("Add Tab",
command=Command("AddTab",
"Add a new tab",
tabs_manager.on_new_tab, "Tabs", Div("Content")).
htmx(target=f"#{tabs_manager.get_id()}"))
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab
btn_show_right_drawer = mk.button("show",
command=layout.commands.toggle_drawer("right"),
command=Command("ShowRightDrawer",
"Show Right Drawer",
layout.toggle_drawer, "right"),
id="btn_show_right_drawer_id")
instances_debugger = InstancesDebugger(layout)
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
id="file_upload_id")
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
layout.set_footer(btn_show_right_drawer)
layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
return layout, keyboard
return layout
if __name__ == "__main__":

View File

@@ -1,54 +0,0 @@
version: 1
disable_existing_loggers: False
formatters:
default:
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: DEBUG
handlers: [console]
loggers:
# Explicit logger configuration (example)
multipart.multipart:
level: INFO
handlers: [console]
propagate: False
python_multipart.multipart:
level: INFO
handlers: [ console ]
propagate: False
httpcore:
level: ERROR
handlers: [ console ]
propagate: False
httpx:
level: INFO
handlers: [ console ]
propagate: False
watchfiles.main:
level: ERROR
handlers: [console]
propagate: False
dbengine.dbengine:
level: ERROR
handlers: [console]
propagate: False
passlib.registry:
level: ERROR
handlers: [console]
propagate: False
socket.socket:
level: ERROR
handlers: [ console ]
propagate: False

View File

@@ -1,53 +1,17 @@
:root {
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--font-weight-medium: 500;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
.mf-icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.mf-icon-16 {
width: 16px;
min-width: 16px;
height: 16px;
margin-top: auto;
}
.mf-icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
}
.mf-icon-24 {
width: 24px;
min-width: 24px;
height: 24px;
margin-top: auto;
}
.mf-icon-28 {
width: 28px;
min-width: 28px;
height: 28px;
margin-top: auto;
}
.mf-icon-32 {
width: 32px;
min-width: 32px;
height: 32px;
margin-top: auto;
margin-bottom: 4px;
}
/*
@@ -117,14 +81,13 @@
/* Left drawer */
.mf-layout-left-drawer {
grid-area: left-drawer;
border-right: 1px solid var(--color-border-primary);
border-right: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
}
/* Right drawer */
.mf-layout-right-drawer {
grid-area: right-drawer;
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
border-left: 1px solid var(--color-border-primary);
border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
}
/* Collapsed drawer states */
@@ -208,7 +171,11 @@
grid-template-columns: 1fr;
}
/**
* Layout Drawer Resizer Styles
*
* Styles for the resizable drawer borders with visual feedback
*/
/**
* Layout Drawer Resizer Styles
*
@@ -230,7 +197,6 @@
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem;
}
/* Base resizer styles */
@@ -311,16 +277,6 @@
opacity: 1;
}
.mf-layout-group {
font-weight: bold;
/*font-size: var(--text-sm);*/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}
/* *********************************************** */
/* *********** Tabs Manager Component ************ */
/* *********************************************** */
@@ -340,21 +296,8 @@
.mf-tabs-header {
display: flex;
gap: 0;
flex-shrink: 1;
flex-shrink: 0;
min-height: 25px;
overflow-x: hidden;
overflow-y: hidden;
white-space: nowrap;
}
.mf-tabs-header-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
/*overflow: hidden; important */
}
/* Individual Tab Button using DaisyUI tab classes */
@@ -376,7 +319,6 @@
background-color: var(--color-base-100);
color: var(--color-base-content);
border-radius: .25rem;
border-bottom: 4px solid var(--color-primary);
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
}
@@ -407,17 +349,10 @@
/* Tab Content Area */
.mf-tab-content {
flex: 1;
overflow: auto;
height: 100%;
}
.mf-tab-content-wrapper {
flex: 1;
overflow: auto;
background-color: var(--color-base-100);
padding: 1rem;
border-top: 1px solid var(--color-border-primary);
}
/* Empty Content State */
@@ -428,247 +363,4 @@
height: 100%;
@apply text-base-content/50;
font-style: italic;
}
.mf-vis {
width: 100%;
height: 100%;
}
.mf-search-results {
margin-top: 0.5rem;
max-height: 200px;
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

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,7 @@ Note: `python-jose` may already be installed if you have FastAPI.
### 1. Update API configuration in `auth/utils.py`
```python
API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL
API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
```

View File

@@ -16,7 +16,6 @@ from ..auth.utils import (
logout_user,
get_user_info
)
from ..core.instances import InstancesManager
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
@@ -182,9 +181,6 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b
if refresh_token:
logout_user(refresh_token)
# release memory
InstancesManager.clear_session(session)
# Clear session
session.clear()

View File

@@ -15,7 +15,7 @@ from fasthtml.common import RedirectResponse, Beforeware
from jose import jwt, JWTError
# Configuration
API_BASE_URL = "http://localhost:5003" # Base URL for FastAPI backend
API_BASE_URL = "http://localhost:5001" # Base URL for FastAPI backend
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
JWT_ALGORITHM = "HS256"
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes

View File

@@ -1,62 +0,0 @@
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance
class BoundariesState:
def __init__(self):
# persisted in DB
self.width: int = 0
self.height: int = 0
class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
class Boundaries(SingleInstance):
"""
Ask the boundaries of the given control
Keep the boundaries updated
"""
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id)
self._owner = owner
self._container_id = container_id or owner.get_id()
self._on_resize = on_resize
self._commands = Commands(self)
self._state = BoundariesState()
self._get_boundaries_command = self._commands.update_boundaries()
@property
def width(self):
return self._state.width
@property
def height(self):
return self._state.height
def update_boundaries(self, width: int, height: int):
"""
Update the component boundaries.
Args:
width: Available width in pixels
height: Available height in pixels
"""
self._state.width = width
self._state.height = height
return self._on_resize() if self._on_resize else self._owner
def render(self):
return Script(f"initBoundaries('{self._container_id}', '{self._get_boundaries_command.url}');")
def __ft__(self):
return self.render()

View File

@@ -1,39 +0,0 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
commands = self._get_commands()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent(x))
)
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
return vis_network
@staticmethod
def get_command_parent(command):
if (ft := command.get_ft()) is None:
return None
if hasattr(ft, "get_id") and callable(ft.get_id):
return ft.get_id()
if hasattr(ft, "get_prefix") and callable(ft.get_prefix):
return ft.get_prefix()
if hasattr(ft, "attrs"):
return ft.attrs.get("id", None)
return None
def _get_commands(self):
return list(CommandsManager.commands.values())
def __ft__(self):
return self.render()

View File

@@ -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';
# }
# }
# });

View File

@@ -1,98 +0,0 @@
import logging
from io import BytesIO
import pandas as pd
from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("FileUpload")
class FileUploadState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
# persisted in DB
# must not be persisted in DB (prefix ns_ = no_saving_)
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
class Commands(BaseCommands):
def __init__(self, owner):
super().__init__(owner)
def upload_file(self):
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = FileUploadState(self)
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
def mk_sheet_selector(self):
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
[Option(
name,
selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names]
return Select(
*options,
name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2"
)
@staticmethod
def get_sheets_names(file_content):
try:
excel_file = pd.ExcelFile(BytesIO(file_content))
sheet_names = excel_file.sheet_names
except Exception as ex:
logger.error(f"get_sheets_names: {ex=}")
sheet_names = []
return sheet_names
def render(self):
return Div(
Div(
mk.mk(Input(type='file',
name='file',
id=f"fi_{self._id}", # fn stands for 'file name'
value=self._state.ns_file_name,
hx_preserve="true",
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
command=self.commands.upload_file()
),
self.mk_sheet_selector(),
cls="flex"
),
mk.dialog_buttons(),
)
def __ft__(self):
return self.render()

View File

@@ -1,38 +0,0 @@
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel")
def render(self):
nodes, edges = self._get_nodes_and_edges()
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()
nodes, edges = from_parent_child_list(
instances,
id_getter=lambda x: x.get_full_id(),
label_getter=lambda x: f"{x.get_id()}",
parent_getter=lambda x: x.get_full_parent_id()
)
for edge in edges:
edge["color"] = "green"
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
for node in nodes:
node["shape"] = "box"
return nodes, edges
def _get_instances(self):
return list(InstancesManager.instances.values())
def __ft__(self):
return self.render()

View File

@@ -1,23 +0,0 @@
import json
from fasthtml.xtend import Script
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand):
self.combinations[sequence] = command
return self
def render(self):
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
def __ft__(self):
return self.render()

View File

@@ -10,13 +10,11 @@ from typing import Literal
from fasthtml.common import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Boundaries import Boundaries
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.controls.helpers import mk
from myfasthtml.controls.helpers import mk, Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.utils import get_id
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
@@ -25,7 +23,7 @@ logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
def __init__(self, owner):
super().__init__(owner)
super().__init__(owner.get_session(), owner.get_id())
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
@@ -35,7 +33,7 @@ class LayoutState(DbObject):
class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"]):
"""
@@ -66,41 +64,19 @@ class Layout(SingleInstance):
right_drawer (bool): Whether to include a right drawer
"""
class Content:
def __init__(self, owner):
class DrawerContent:
def __init__(self, owner, side: Literal["left", "right"]):
self._owner = owner
self._content = {}
self._groups = []
self._ids = set()
self.side = side
self._content = []
def add_group(self, group, group_ft=None):
group_ft = group_ft or Div(group, cls="mf-layout-group")
if not group:
group_ft = None
self._groups.append((group, group_ft))
self._content[group] = []
def add(self, content, group=None):
content_id = get_id(content)
if content_id in self._ids:
return
if group not in self._content:
self.add_group(group)
self._content[group] = []
self._content[group].append(content)
if content_id is not None:
self._ids.add(content_id)
def append(self, content):
self._content.append(content)
def get_content(self):
return self._content
def get_groups(self):
return self._groups
def __init__(self, parent, app_name, _id=None):
def __init__(self, session, app_name):
"""
Initialize the Layout component.
@@ -109,21 +85,17 @@ class Layout(SingleInstance):
left_drawer (bool): Enable left drawer. Default is True.
right_drawer (bool): Enable right drawer. Default is True.
"""
super().__init__(parent, _id=_id)
super().__init__(session, Ids.Layout)
self.app_name = app_name
# Content storage
self._header_content = None
self._footer_content = None
self._main_content = None
self._state = LayoutState(self)
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
self.right_drawer = self.Content(self)
self.header_left = self.Content(self)
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
self._footer_content = None
self.left_drawer = self.DrawerContent(self, "left")
self.right_drawer = self.DrawerContent(self, "right")
def set_footer(self, content):
"""
@@ -142,7 +114,6 @@ class Layout(SingleInstance):
content: FastHTML component(s) or content for the main area
"""
self._main_content = content
return self
def toggle_drawer(self, side: Literal["left", "right"]):
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
@@ -188,16 +159,8 @@ class Layout(SingleInstance):
Header: FastHTML Header component
"""
return Header(
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
),
Div( # right
*self.header_right.get_content()[None],
UserProfile(self),
cls="flex gap-1"
),
self._mk_left_drawer_icon(),
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
cls="mf-layout-header"
)
@@ -235,21 +198,14 @@ class Layout(SingleInstance):
Div: FastHTML Div component for left drawer
"""
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_side="left"
)
# Wrap content in scrollable container
content_wrapper = Div(
*[
(
Div(cls="divider") if index > 0 else None,
group_ft,
*[item for item in self.left_drawer.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(self.left_drawer.get_groups())
],
*self.left_drawer.get_content(),
cls="mf-layout-drawer-content"
)
@@ -268,9 +224,8 @@ class Layout(SingleInstance):
Returns:
Div: FastHTML Div component for right drawer
"""
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_side="right"
)
@@ -314,7 +269,7 @@ class Layout(SingleInstance):
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
Script(f"initResizer('{self._id}');"),
Script(f"initLayoutResizer('{self._id}');"),
id=self._id,
cls="mf-layout",
)
@@ -326,4 +281,4 @@ class Layout(SingleInstance):
Returns:
Div: The rendered layout
"""
return self.render()
return self.render()

View File

@@ -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()

View File

@@ -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()

View File

@@ -1,90 +0,0 @@
import logging
from typing import Callable, Any
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
logger = logging.getLogger("Search")
class Commands(BaseCommands):
def search(self):
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms",
swap="innerHTML"))
class Search(MultipleInstance):
def __init__(self,
parent: BaseInstance,
_id=None,
items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
template: Callable[[Any], Any] = None): # once filtered, what to render ?
"""
Represents a component for managing and filtering a list of items based on specific criteria.
This class initializes with a session, an optional identifier, a list of item names,
a callable for extracting a string value from items, and a template callable for rendering
the filtered items. It provides functionality to handle and organize item-based operations.
:param _id: Optional identifier for the component.
:param items: An optional list of names for the items to be filtered.
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
function that returns the item as is.
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
"""
super().__init__(parent, _id=_id)
self.items_names = items_names or ''
self.items = items or []
self.filtered = self.items.copy()
self.get_attr = get_attr or (lambda x: x)
self.template = template or Div
self.commands = Commands(self)
def set_items(self, items):
self.items = items
self.filtered = self.items.copy()
return self
def on_search(self, query):
logger.debug(f"on_search {query=}")
self.search(query)
return tuple(self._mk_search_results())
def search(self, query):
logger.debug(f"search {query=}")
if query is None or query.strip() == "":
self.filtered = self.items.copy()
else:
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
self.filtered = res_seq + res_fuzzy
return self.filtered
def _mk_search_results(self):
return [self.template(item) for item in self.filtered]
def render(self):
return Div(
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
command=self.commands.search()),
Div(
*self._mk_search_results(),
id=f"{self._id}-results",
cls="mf-search-results",
),
id=f"{self._id}",
)
def __ft__(self):
return self.render()

View File

@@ -1,50 +1,29 @@
import logging
import uuid
from dataclasses import dataclass
from typing import Any
from fasthtml.common import Div, Span
from fasthtml.xtend import Script
from fasthtml.common import Div, Button, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Search import Search
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import mk
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager
from myfasthtml.icons.fluent_p1 import tabs24_regular
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
logger = logging.getLogger("TabsManager")
vis_nodes = [
{"id": 1, "label": "Node 1"},
{"id": 2, "label": "Node 2"},
{"id": 3, "label": "Node 3"},
{"id": 4, "label": "Node 4"},
{"id": 5, "label": "Node 5"}
]
vis_edges = [
{"from": 1, "to": 3},
{"from": 1, "to": 2},
{"from": 2, "to": 4},
{"from": 2, "to": 5},
{"from": 3, "to": 3}
]
from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
@dataclass
class Boundaries:
"""Store component boundaries"""
width: int = 1020
height: int = 782
# Structure de tabs:
# {
# "tab-uuid-1": {
# "label": "Users",
# "component_type": "UsersPanel",
# "component_id": "UsersPanel-abc123",
# },
# "tab-uuid-2": { ... }
# }
class TabsManagerState(DbObject):
def __init__(self, owner):
super().__init__(owner)
super().__init__(owner.get_session(), owner.get_id())
with self.initializing():
# persisted in DB
self.tabs: dict[str, Any] = {}
@@ -57,75 +36,23 @@ class TabsManagerState(DbObject):
class Commands(BaseCommands):
def show_tab(self, tab_id):
return Command(f"{self._prefix}ShowTab",
return Command(f"{self._prefix}SowTab",
"Activate or show a specific tab",
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab",
"Close a specific tab",
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False):
return (Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).
htmx(target=f"#{self._id}-controller"))
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
class TabsManager(MultipleInstance):
_tab_count = 0
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def __init__(self, session, _id=None):
super().__init__(session, Ids.TabsManager, _id=_id)
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()
self._search = Search(self,
items=self._get_tab_list(),
get_attr=lambda x: x["label"],
template=self._mk_tab_button,
_id="-search")
logger.debug(f"TabsManager created with id: {self._id}")
logger.debug(f" tabs : {self._get_ordered_tabs()}")
logger.debug(f" active tab : {self._state.active_tab}")
def get_state(self):
return self._state
def _get_ordered_tabs(self):
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
def _get_tab_content(self, tab_id):
if tab_id not in self._state.tabs:
return None
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
return None
return InstancesManager.get(self._session, tab_config["component_id"])
@staticmethod
def _get_tab_count():
res = TabsManager._tab_count
TabsManager._tab_count += 1
return res
def on_new_tab(self, label: str, component: Any, auto_increment=False):
logger.debug(f"on_new_tab {label=}, {component=}, {auto_increment=}")
if auto_increment:
label = f"{label}_{self._get_tab_count()}"
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
tab_id = self._tab_already_exists(label, component)
if tab_id:
return self.show_tab(tab_id)
tab_id = self.add_tab(label, component)
return (
self._mk_tabs_controller(),
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
self._mk_tabs_header_wrapper(True),
)
def on_new_tab(self, label: str, component: Any):
self.add_tab(label, component)
return self
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
"""
@@ -139,18 +66,24 @@ class TabsManager(MultipleInstance):
Returns:
tab_id: The UUID of the tab (new or existing)
"""
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
# copy the state to avoid multiple database call
state = self._state.copy()
# Extract component ID if the component has a get_id() method
component_type, component_id = None, None
if isinstance(component, BaseInstance):
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_type = type(component).__name__
component_id = component.get_id()
# Check if a tab with the same component_type, component_id AND label already exists
existing_tab_id = self._tab_already_exists(label, component)
existing_tab_id = None
if component_id is not None:
for tab_id, tab_data in state.tabs.items():
if (tab_data.get('component_type') == component_type and
tab_data.get('component_id') == component_id and
tab_data.get('label') == label):
existing_tab_id = tab_id
break
if existing_tab_id:
# Update existing tab (only the component instance in memory)
@@ -162,7 +95,6 @@ class TabsManager(MultipleInstance):
# Add tab metadata to state
state.tabs[tab_id] = {
'id': tab_id,
'label': label,
'component_type': component_type,
'component_id': component_id
@@ -180,224 +112,87 @@ class TabsManager(MultipleInstance):
# finally, update the state
self._state.update(state)
self._search.set_items(self._get_tab_list())
return tab_id
def show_tab(self, tab_id):
logger.debug(f"show_tab {tab_id=}")
if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return None
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
self._state.active_tab = tab_id
if tab_id not in self._state._tabs_content:
logger.debug(f" Content does not exist. Creating it.")
content = self._get_tab_content(tab_id)
tab_content = self._mk_tab_content(tab_id, content)
self._state._tabs_content[tab_id] = tab_content
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
else:
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
def close_tab(self, tab_id: str):
"""
Close a tab and remove it from the tabs manager.
Args:
tab_id: ID of the tab to close
Returns:
Self for chaining
"""
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs:
return self
# Copy state
state = self._state.copy()
# Remove from tabs and order
del state.tabs[tab_id]
state.tabs_order.remove(tab_id)
# Remove from content
if tab_id in state._tabs_content:
del state._tabs_content[tab_id]
# If closing active tab, activate another one
if state.active_tab == tab_id:
if state.tabs_order:
# Activate the first remaining tab
state.active_tab = state.tabs_order[0]
else:
state.active_tab = None
# Update state
self._state.update(state)
self._search.set_items(self._get_tab_list())
return self
def add_tab_btn(self):
return mk.icon(tab_add24_regular,
id=f"{self._id}-add-tab-btn",
cls="mf-tab-btn",
command=self.commands.add_tab(f"Untitled",
None,
True))
def _mk_tabs_controller(self):
return Div(
Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"),
Script(f'updateTabs("{self._id}-controller");'),
)
def _mk_tabs_header_wrapper(self, oob=False):
# Create visible tab buttons
visible_tab_buttons = [
self._mk_tab_button(self._state.tabs[tab_id])
for tab_id in self._state.tabs_order
if tab_id in self._state.tabs
]
header_content = [*visible_tab_buttons]
return Div(
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
id=f"{self._id}-header-wrapper",
cls="mf-tabs-header-wrapper",
hx_swap_oob="true" if oob else None
)
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
def _mk_tab_button(self, tab_id: str, tab_data: dict):
"""
Create a single tab button with its label and close button.
Args:
tab_id: Unique identifier for the tab
tab_data: Dictionary containing tab information (label, component_type, etc.)
in_dropdown: Whether this tab is rendered in the dropdown menu
Returns:
Button element representing the tab
"""
tab_id = tab_data["id"]
is_active = tab_id == self._state.active_tab
close_btn = mk.mk(
return Button(
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
command=self.commands.close_tab(tab_id)
)
tab_label = mk.mk(
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"),
command=self.commands.show_tab(tab_id)
)
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
return Div(
tab_label,
close_btn,
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
data_tab_id=tab_id,
data_manager_id=self._id
)
def _mk_tab_content_wrapper(self):
def _mk_tabs_header(self):
"""
Create the tabs header containing all tab buttons.
Returns:
Div element containing all tab buttons
"""
tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
for tab_id in self._state.tabs_order
if tab_id in self._state.tabs
]
return Div(
*tab_buttons,
cls="mf-tabs-header",
id=f"{self._id}-header"
)
def _mk_tab_content(self):
"""
Create the active tab content area.
Returns:
Div element containing the active tab content or empty container
"""
content = None
if self._state.active_tab:
active_tab = self._state.active_tab
if active_tab in self._state._tabs_content:
tab_content = self._state._tabs_content[active_tab]
else:
content = self._get_tab_content(active_tab)
tab_content = self._mk_tab_content(active_tab, content)
self._state._tabs_content[active_tab] = tab_content
else:
tab_content = self._mk_tab_content(None, None)
if self._state.active_tab and self._state.active_tab in self._state._tabs_content:
component = self._state._tabs_content[self._state.active_tab]
content = component
return Div(
tab_content,
cls="mf-tab-content-wrapper",
id=f"{self._id}-content-wrapper",
content if content else Div("No active tab", cls="mf-empty-content"),
cls="mf-tab-content",
id=f"{self._id}-content"
)
def _mk_tab_content(self, tab_id: str, content):
is_active = tab_id == self._state.active_tab
return Div(
content if content else Div("No Content", cls="mf-empty-content"),
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
id=f"{self._id}-{tab_id}-content",
)
def _mk_show_tabs_menu(self):
return Div(
mk.icon(tabs24_regular,
size="32",
tabindex="0",
role="button",
cls="btn btn-xs"),
Div(
self._search,
tabindex="-1",
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
),
cls="dropdown dropdown-end"
)
def _wrap_tab_content(self, tab_content):
return Div(
tab_content,
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
)
def _tab_already_exists(self, label, component):
if not isinstance(component, BaseInstance):
return None
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_id = component.get_id()
if component_id is not None:
for tab_id, tab_data in self._state.tabs.items():
if (tab_data.get('component_type') == component_type and
tab_data.get('component_id') == component_id and
tab_data.get('label') == label):
return tab_id
return None
def _get_tab_list(self):
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")
def render(self):
"""
Render the complete TabsManager component.
Returns:
Div element containing tabs header, content area, and resize script
Div element containing tabs header and content area
"""
return Div(
self._mk_tabs_controller(),
self._mk_tabs_header_wrapper(),
self._mk_tab_content_wrapper(),
self._mk_tabs_header(),
self._mk_tab_content(),
cls="mf-tabs-manager",
id=self._id,
id=self._id
)
def __ft__(self):

View File

@@ -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()

View File

@@ -1,10 +1,9 @@
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.AuthProxy import AuthProxy
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, RootInstance
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.utils import retrieve_user_info
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
@@ -16,7 +15,6 @@ class UserProfileState:
self._session = owner.get_session()
self.theme = "light"
self.load()
def load(self):
user_info = retrieve_user_info(self._session)
@@ -27,7 +25,7 @@ class UserProfileState:
def save(self):
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
auth_proxy = AuthProxy(RootInstance)
auth_proxy = InstancesManager.get_auth_proxy()
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
@@ -37,15 +35,14 @@ class Commands(BaseCommands):
class UserProfile(SingleInstance):
def __init__(self, parent=None, _id=None):
super().__init__(parent, _id=_id)
def __init__(self, session):
super().__init__(session, Ids.UserProfile)
self._state = UserProfileState(self)
self._commands = Commands(self)
def update_dark_mode(self, client_response):
self._state.theme = client_response.get("theme", "light")
self._state.save()
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
def render(self):
user_info = retrieve_user_info(self._session)

View File

@@ -1,100 +0,0 @@
import json
import logging
from fasthtml.components import Script, Div
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("VisNetwork")
class VisNetworkState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
# persisted in DB
self.nodes: list = []
self.edges: list = []
self.options: dict = {
"autoResize": True,
"interaction": {
"dragNodes": True,
"zoomView": True,
"dragView": True,
},
"physics": {"enabled": True}
}
class VisNetwork(MultipleInstance):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
super().__init__(parent, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
self._state = VisNetworkState(self)
self._update_state(nodes, edges, options)
def _update_state(self, nodes, edges, options):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
if not nodes and not edges and not options:
return
state = self._state.copy()
if nodes is not None:
state.nodes = nodes
if edges is not None:
state.edges = edges
if options is not None:
state.options = options
self._state.update(state)
def add_to_options(self, **kwargs):
logger.debug(f"add_to_options: {kwargs=}")
new_options = self._state.options.copy() | kwargs
self._update_state(None, None, new_options)
return self
def render(self):
# Serialize nodes and edges to JSON
# This preserves all properties (color, shape, size, etc.) that are present
js_nodes = ",\n ".join(
json.dumps(node) for node in self._state.nodes
)
js_edges = ",\n ".join(
json.dumps(edge) for edge in self._state.edges
)
# Convert Python options to JS
js_options = json.dumps(self._state.options, indent=2)
return (
Div(
id=self._id,
cls="mf-vis",
),
# The script initializing Vis.js
Script(f"""
(function() {{
const container = document.getElementById("{self._id}");
const nodes = new vis.DataSet([
{js_nodes}
]);
const edges = new vis.DataSet([
{js_edges}
]);
const data = {{
nodes: nodes,
edges: edges
}};
const options = {js_options};
const network = new vis.Network(container, data, options);
}})();
""")
)
def __ft__(self):
return self.render()

View File

@@ -7,72 +7,27 @@ from myfasthtml.core.utils import merge_classes
class Ids:
# Please keep the alphabetical order
Root = "mf-root"
UserSession = "mf-user_session"
AuthProxy = "mf-auth-proxy"
DbManager = "mf-dbmanager"
Layout = "mf-layout"
TabsManager = "mf-tabs-manager"
UserProfile = "mf-user-profile"
class mk:
@staticmethod
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)
@staticmethod
def dialog_buttons(ok_title: str = "OK",
cancel_title: str = "Cancel",
on_ok: Command = None,
on_cancel: Command = None,
cls=None):
return Div(
Div(
mk.button(ok_title, cls="btn btn-primary btn-sm mr-2", command=on_ok),
mk.button(cancel_title, cls="btn btn-ghost btn-sm", command=on_cancel),
cls="flex justify-end"
),
cls=merge_classes("flex justify-end w-full mt-1", cls)
)
@staticmethod
def icon(icon,
size=20,
def icon(icon, size=20,
can_select=True,
can_hover=False,
cls='',
command: Command = None,
binding: Binding = None,
**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}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',
@@ -81,27 +36,6 @@ class mk:
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def label(text: str,
icon=None,
size: str = "sm",
cls='',
command: Command = None,
binding: Binding = None,
**kwargs):
merged_cls = merge_classes("flex", cls, kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size}")
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def convert_size(size: str):
return (size.replace("xs", "16").
replace("sm", "20").
replace("md", "24").
replace("lg", "28").
replace("xl", "32"))
@staticmethod
def manage_command(ft, command: Command):
if command:
@@ -125,6 +59,6 @@ class mk:
@staticmethod
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command) if command else ft
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
return ft

View File

@@ -1,10 +1,11 @@
from myfasthtml.auth.utils import login_user, save_user_info, register_user
from myfasthtml.core.instances import SingleInstance
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import special_session, UniqueInstance
class AuthProxy(SingleInstance):
def __init__(self, parent, base_url: str = None):
super().__init__(parent)
class AuthProxy(UniqueInstance):
def __init__(self, base_url: str = None):
super().__init__(special_session, Ids.AuthProxy)
self._base_url = base_url
def login_user(self, email: str, password: str):

View File

@@ -129,14 +129,6 @@ class DataConverter:
pass
class LambdaConverter(DataConverter):
def __init__(self, func):
self.func = func
def convert(self, data):
return self.func(data)
class BooleanConverter(DataConverter):
def convert(self, data):
if data is None:

View File

@@ -30,7 +30,6 @@ class BaseCommand:
self.description = description
self._htmx_extra = {}
self._bindings = []
self._ft = None
# register the command
CommandsManager.register(self)
@@ -39,14 +38,13 @@ class BaseCommand:
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML",
"hx-vals": {"c_id": f"{self.id}"},
"hx-vals": f'{{"c_id": "{self.id}"}}',
} | self._htmx_extra
def execute(self, client_response: dict = None):
raise NotImplementedError
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
# Note that the default value is the same than in get_htmx_params()
def htmx(self, target="this", swap="innerHTML"):
if target is None:
self._htmx_extra["hx-swap"] = "none"
elif target != "this":
@@ -54,12 +52,8 @@ class BaseCommand:
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "outerHTML":
elif swap != "innerHTML":
self._htmx_extra["hx-swap"] = swap
if trigger is not None:
self._htmx_extra["hx-trigger"] = trigger
return self
def bind_ft(self, ft):
@@ -69,7 +63,6 @@ class BaseCommand:
:param ft:
:return:
"""
self._ft = ft
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
@@ -93,13 +86,6 @@ class BaseCommand:
return self
@property
def url(self):
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def get_ft(self):
return self._ft
def __str__(self):
return f"Command({self.name})"
@@ -130,19 +116,6 @@ class Command(BaseCommand):
self.args = args
self.kwargs = kwargs
def _convert(self, key, value):
if key in self.callback_parameters:
param = self.callback_parameters[key]
if param.annotation == bool:
return value == "true"
elif param.annotation == int:
return int(value)
elif param.annotation == float:
return float(value)
elif param.annotation == list:
return value.split(",")
return value
def execute(self, client_response: dict = None):
ret_from_bindings = []
@@ -156,7 +129,7 @@ class Command(BaseCommand):
if client_response:
for k, v in client_response.items():
if k in self.callback_parameters:
new_kwargs[k] = self._convert(k, v)
new_kwargs[k] = v
if 'client_response' in self.callback_parameters:
new_kwargs['client_response'] = client_response
@@ -168,8 +141,8 @@ class Command(BaseCommand):
# Set the hx-swap-oob attribute on all elements returned by the callback
if isinstance(ret, (list, tuple)):
for r in ret[1:]:
if hasattr(r, 'attrs') and r.get("id", None) is not None:
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
if hasattr(r, 'attrs'):
r.attrs["hx-swap-oob"] = "true"
if not ret_from_bindings:
return ret
@@ -180,15 +153,6 @@ class Command(BaseCommand):
return [ret] + ret_from_bindings
class LambdaCommand(Command):
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, delegate)
self.htmx(target=None)
def execute(self, client_response: dict = None):
return self.callback(client_response)
class CommandsManager:
commands = {}

View File

@@ -3,14 +3,14 @@ from types import SimpleNamespace
from dbengine.dbengine import DbEngine
from myfasthtml.core.instances import SingleInstance, BaseInstance
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.utils import retrieve_user_info
class DbManager(SingleInstance):
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(parent, auto_register=auto_register)
def __init__(self, session, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(session, Ids.DbManager, auto_register=auto_register)
self.db = DbEngine(root=root)
def save(self, entry, obj):
@@ -35,12 +35,12 @@ class DbObject:
It loads from DB at startup
"""
_initializing = False
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
self._owner = owner
def __init__(self, session, name=None, db_manager=None):
self._session = session
self._name = name or self.__class__.__name__
self._db_manager = db_manager or DbManager(self._owner)
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
self._finalize_initialization()
@@ -55,7 +55,7 @@ class DbObject:
self._initializing = old_state
def __setattr__(self, name: str, value: str):
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
if name.startswith("_") or getattr(self, "_initializing", False):
super().__setattr__(name, value)
return
@@ -74,8 +74,7 @@ class DbObject:
self._save_self()
def _save_self(self):
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
not k.startswith("_") and not k.startswith("ns")}
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
if props:
self._db_manager.save(self._name, props)

View File

@@ -1,11 +1,6 @@
import logging
import uuid
from typing import Optional
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.utils import pascal_to_snake
logger = logging.getLogger("InstancesManager")
special_session = {
"user_info": {"id": "** SPECIAL SESSION **"}
@@ -22,92 +17,21 @@ class BaseInstance:
Base class for all instances (manageable by InstancesManager)
"""
def __new__(cls, *args, **kwargs):
# Extract arguments from both positional and keyword arguments
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
# Compute _id
_id = cls.compute_id(_id, parent)
if session is None:
if parent is not None:
session = parent.get_session()
else:
raise TypeError("Either session or parent must be provided")
session_id = InstancesManager.get_session_id(session)
key = (session_id, _id)
if key in InstancesManager.instances:
res = InstancesManager.instances[key]
if type(res) is not cls:
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}")
return res
# Otherwise create a new instance
instance = super().__new__(cls)
instance._is_new_instance = True # mark as fresh
return instance
def __init__(self, parent: Optional['BaseInstance'],
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
if not getattr(self, "_is_new_instance", False):
# Skip __init__ if instance already existed
return
elif not isinstance(self, UniqueInstance):
# No more __init__ unless it's UniqueInstance
self._is_new_instance = False
self._parent = parent
self._session = session or (parent.get_session() if parent else None)
self._id = self.compute_id(_id, parent)
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
def __init__(self, session: dict, prefix: str, _id: str, auto_register: bool = True):
self._session = session
self._id = _id
self._prefix = prefix
if auto_register:
InstancesManager.register(self._session, self)
InstancesManager.register(session, self)
def get_session(self) -> dict:
return self._session
def get_id(self) -> str:
def get_id(self):
return self._id
def get_parent(self) -> Optional['BaseInstance']:
return self._parent
def get_session(self):
return self._session
def get_prefix(self) -> str:
def get_prefix(self):
return self._prefix
def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
def get_full_parent_id(self) -> Optional[str]:
parent = self.get_parent()
return parent.get_full_id() if parent else None
@classmethod
def compute_prefix(cls):
return f"mf-{pascal_to_snake(cls.__name__)}"
@classmethod
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
if _id is None:
prefix = cls.compute_prefix()
if issubclass(cls, SingleInstance):
_id = prefix
else:
_id = f"{prefix}-{str(uuid.uuid4())}"
return _id
if _id.startswith("-") and parent is not None:
return f"{parent.get_prefix()}{_id}"
return _id
class SingleInstance(BaseInstance):
@@ -115,26 +39,19 @@ class SingleInstance(BaseInstance):
Base class for instances that can only have one instance at a time.
"""
def __init__(self,
parent: Optional[BaseInstance] = None,
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
super().__init__(parent, session, _id, auto_register)
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, prefix, auto_register)
class UniqueInstance(BaseInstance):
"""
Base class for instances that can only have one instance at a time.
But unlike SingleInstance, the __init__ is called every time it's instantiated.
Does not throw exception if the instance already exists, it simply overwrites it.
"""
def __init__(self,
parent: Optional[BaseInstance] = None,
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
super().__init__(parent, session, _id, auto_register)
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, prefix, auto_register)
self._prefix = prefix
class MultipleInstance(BaseInstance):
@@ -142,11 +59,9 @@ class MultipleInstance(BaseInstance):
Base class for instances that can have multiple instances at a time.
"""
def __init__(self, parent: BaseInstance,
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
super().__init__(parent, session, _id, auto_register)
def __init__(self, session: dict, prefix: str, auto_register: bool = True, _id=None):
super().__init__(session, prefix, f"{prefix}-{_id or str(uuid.uuid4())}", auto_register)
self._prefix = prefix
class InstancesManager:
@@ -160,7 +75,7 @@ class InstancesManager:
:param instance:
:return:
"""
key = (InstancesManager.get_session_id(session), instance.get_id())
key = (InstancesManager._get_session_id(session), instance.get_id())
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
raise DuplicateInstanceError(instance)
@@ -169,50 +84,38 @@ class InstancesManager:
return instance
@staticmethod
def get(session: dict, instance_id: str):
def get(session: dict, instance_id: str, instance_type: type = None, *args, **kwargs):
"""
Get or create an instance of the given type (from its id)
:param session:
:param instance_id:
:param instance_type:
:param args:
:param kwargs:
:return:
"""
key = (InstancesManager.get_session_id(session), instance_id)
return InstancesManager.instances[key]
try:
key = (InstancesManager._get_session_id(session), instance_id)
return InstancesManager.instances[key]
except KeyError:
if instance_type:
return instance_type(session, *args, **kwargs) # it will be automatically registered
else:
raise
@staticmethod
def get_session_id(session):
if session is None:
def _get_session_id(session):
if not session:
return "** NOT LOGGED IN **"
if "user_info" not in session:
return "** UNKNOWN USER **"
return session["user_info"].get("id", "** INVALID SESSION **")
@staticmethod
def get_session_user_name(session):
if session is None:
return "** NOT LOGGED IN **"
if "user_info" not in session:
return "** UNKNOWN USER **"
return session["user_info"].get("username", "** INVALID SESSION **")
def get_auth_proxy():
return InstancesManager.get(special_session, Ids.AuthProxy)
@staticmethod
def reset():
InstancesManager.instances.clear()
@staticmethod
def clear_session(session):
"""
Remove all instances belonging to the given session.
:param session:
:return:
"""
session_id = InstancesManager.get_session_id(session)
InstancesManager.instances = {
key: instance
for key, instance in InstancesManager.instances.items()
if key[0] != session_id
}
RootInstance = SingleInstance(None, special_session, Ids.Root)
return InstancesManager.instances.clear()

View File

@@ -1,85 +0,0 @@
from difflib import SequenceMatcher
from typing import Any
def _is_subsequence(query: str, target: str) -> tuple[bool, float]:
"""
Determines if a query string is a subsequence of a target string and calculates
a score based on the compactness of the match. The match is case-insensitive.
The function iterates through each character of the query and checks if it
exists in the target string while maintaining the order. If all characters of
the query are found in order, it calculates a score based on the smallest
window in the target that contains all the matched characters.
:param query: The query string to check as a subsequence.
:param target: The target string in which to find the subsequence.
:return: A tuple where the first value is a boolean indicating if a valid
subsequence exists, and the second value is a float representing the
compactness score of the match.
:rtype: tuple[bool, float]
"""
query = query.lower()
target = target.lower()
positions = []
idx = 0
for char in query:
idx = target.find(char, idx)
if idx == -1:
return False, 0.0
positions.append(idx)
idx += 1
# Smallest window containing all matched chars
window_size = positions[-1] - positions[0] + 1
# Score: ratio of query length vs window size (compactness)
score = len(query) / window_size
return True, score
def fuzzy_matching(query: str, choices: list[Any], similarity_threshold: float = 0.7, get_attr=None):
"""
Perform fuzzy matching on a list of items to find the items that are similar
to the given query based on a similarity threshold.
:param query: The search query to be matched, provided as a string.
:param choices: A list of strings representing the items to be compared against the query.
:param similarity_threshold: A float value representing the minimum similarity score
(between 0 and 1) an item needs to achieve to be considered a match. Defaults to 0.7.
:param get_attr: When choice is a object, give the property to use
:return: A list of strings containing the items from the input list that meet or exceed
the similarity threshold, sorted in descending order of similarity.
"""
get_attr = get_attr or (lambda x: x)
matches = []
for file_doc in choices:
# Calculate similarity between search term and filename
similarity = SequenceMatcher(None, query.lower(), get_attr(file_doc).lower()).ratio()
if similarity >= similarity_threshold:
matches.append((file_doc, similarity))
# Sort by similarity score (highest first)
matches.sort(key=lambda x: x[1], reverse=True)
# Return only the FileDocument objects
return [match[0] for match in matches]
def subsequence_matching(query: str, choices: list[Any], get_attr=None):
get_attr = get_attr or (lambda x: x)
matches = []
for item in choices:
matched, score = _is_subsequence(query, get_attr(item))
if matched:
matches.append((item, score))
# Sort by score (highest first)
matches.sort(key=lambda x: x[1], reverse=True)
# Return only the FileDocument objects
return [match[0] for match in matches]

View File

@@ -1,238 +0,0 @@
from collections.abc import Callable
ROOT_COLOR = "#ff9999"
GHOST_COLOR = "#cccccc"
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
"""
Convert a list of nested dictionaries to vis.js nodes and edges format.
Args:
trees: List of nested dictionaries where keys are node names and values are
dictionaries of children (e.g., [{"root1": {"child1": {}}}, {"root2": {}}])
Returns:
tuple: (nodes, edges) where:
- nodes: list of dicts with auto-incremented numeric IDs
- edges: list of dicts with 'from' and 'to' keys
Example:
>>> trees = [{"root1": {"child1": {}}}, {"root2": {"child2": {}}}]
>>> nodes, edges = from_nested_dict(trees)
>>> # nodes = [{"id": 1, "label": "root1"}, {"id": 2, "label": "child1"}, ...]
"""
nodes = []
edges = []
node_id_counter = 1
node_id_map = {} # maps node_label -> node_id
def traverse(subtree: dict, parent_id: int | None = None):
nonlocal node_id_counter
for node_label, children in subtree.items():
# Create node with auto-incremented ID
current_id = node_id_counter
node_id_map[node_label] = current_id
nodes.append({
"id": current_id,
"label": node_label
})
node_id_counter += 1
# Create edge from parent to current node
if parent_id is not None:
edges.append({
"from": parent_id,
"to": current_id
})
# Recursively process children
if children:
traverse(children, parent_id=current_id)
# Process each tree in the list
for tree in trees:
traverse(tree)
return nodes, edges
def from_tree_with_metadata(
trees: list[dict],
id_getter: Callable = None,
label_getter: Callable = None,
children_getter: Callable = None
) -> tuple[list, list]:
"""
Convert a list of trees with metadata to vis.js nodes and edges format.
Args:
trees: List of dictionaries with 'id', 'label', and 'children' keys
(e.g., [{"id": "root1", "label": "Root 1", "children": [...]}, ...])
id_getter: Optional callback to extract node ID from dict
Default: lambda n: n.get("id")
label_getter: Optional callback to extract node label from dict
Default: lambda n: n.get("label", "")
children_getter: Optional callback to extract children list from dict
Default: lambda n: n.get("children", [])
Returns:
tuple: (nodes, edges) where:
- nodes: list of dicts with IDs from tree or auto-incremented
- edges: list of dicts with 'from' and 'to' keys
Example:
>>> trees = [
... {
... "id": "root1",
... "label": "Root Node 1",
... "children": [
... {"id": "child1", "label": "Child 1", "children": []}
... ]
... },
... {"id": "root2", "label": "Root Node 2"}
... ]
>>> nodes, edges = from_tree_with_metadata(trees)
"""
# Default getters
if id_getter is None:
id_getter = lambda n: n.get("id")
if label_getter is None:
label_getter = lambda n: n.get("label", "")
if children_getter is None:
children_getter = lambda n: n.get("children", [])
nodes = []
edges = []
node_id_counter = 1
def traverse(node_dict: dict, parent_id: int | str | None = None):
nonlocal node_id_counter
# Extract ID (use provided or auto-increment)
node_id = id_getter(node_dict)
if node_id is None:
node_id = node_id_counter
node_id_counter += 1
# Extract label
node_label = label_getter(node_dict)
# Create node
nodes.append({
"id": node_id,
"label": node_label
})
# Create edge from parent to current node
if parent_id is not None:
edges.append({
"from": parent_id,
"to": node_id
})
# Recursively process children
children = children_getter(node_dict)
if children:
for child in children:
traverse(child, parent_id=node_id)
# Process each tree in the list
for tree in trees:
traverse(tree)
return nodes, edges
def from_parent_child_list(
items: list,
id_getter: Callable = None,
label_getter: Callable = None,
parent_getter: Callable = None,
ghost_color: str = GHOST_COLOR,
root_color: str | None = ROOT_COLOR
) -> tuple[list, list]:
"""
Convert a list of items with parent references to vis.js nodes and edges format.
Args:
items: List of items (dicts or objects) with parent references
id_getter: callback to extract node ID
label_getter: callback to extract node label
parent_getter: callback to extract parent ID
ghost_color: color for ghost nodes (referenced parents)
root_color: color for root nodes (nodes without parent)
Returns:
tuple: (nodes, edges)
"""
# Default getters
if id_getter is None:
id_getter = lambda item: item.get("id")
if label_getter is None:
label_getter = lambda item: item.get("label", "")
if parent_getter is None:
parent_getter = lambda item: item.get("parent")
nodes = []
edges = []
# Track all existing node IDs
existing_ids = set()
# First pass: create nodes for all items
for item in items:
node_id = id_getter(item)
node_label = label_getter(item)
existing_ids.add(node_id)
nodes.append({
"id": node_id,
"label": node_label,
# root color assigned later
})
# Track ghost nodes
ghost_nodes = set()
# Track which nodes have parents
nodes_with_parent = set()
# Second pass: create edges and detect ghost nodes
for item in items:
node_id = id_getter(item)
parent_id = parent_getter(item)
# Skip roots
if parent_id is None or parent_id == "":
continue
# Child has a parent
nodes_with_parent.add(node_id)
# Create edge parent → child
edges.append({
"from": parent_id,
"to": node_id
})
# Create ghost node if parent not found
if parent_id not in existing_ids and parent_id not in ghost_nodes:
ghost_nodes.add(parent_id)
nodes.append({
"id": parent_id,
"label": str(parent_id),
"color": ghost_color
})
# Final pass: assign color to root nodes
if root_color is not None:
for node in nodes:
if node["id"] not in nodes_with_parent and node["id"] not in ghost_nodes:
# Root node
node["color"] = root_color
return nodes, edges

View File

@@ -1,5 +1,4 @@
import logging
import re
from bs4 import Tag
from fastcore.xml import FT
@@ -224,42 +223,116 @@ def debug_session(session):
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
def get_id(obj):
if isinstance(obj, str):
return obj
elif hasattr(obj, "id"):
return obj.id
elif hasattr(obj, "get_id"):
return obj.get_id()
else:
return str(obj)
import inspect
from typing import Optional
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
if name is None:
return None
def build_args_kwargs(
parameters,
values,
default_args: Optional[list] = None,
default_kwargs: Optional[dict] = None
):
"""
Build (args, kwargs) from a sequence or dict of inspect.Parameter and a dict of values.
- POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD fill `args`
- KEYWORD_ONLY fill `kwargs`
- VAR_POSITIONAL (*args) accepts list/tuple values
- VAR_KEYWORD (**kwargs) accepts dict values
- If not found, fallback to default_args / default_kwargs when provided
- Raises ValueError for missing required or unknown parameters
"""
name = name.strip()
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
def snake_to_pascal(name: str) -> str:
"""Convert a snake_case string to PascalCase."""
if name is None:
return None
if not isinstance(parameters, dict):
parameters = {p.name: p for p in parameters}
name = name.strip()
if not name:
return ""
ordered_params = list(parameters.values())
# Split on underscores and capitalize each part
parts = name.split('_')
return ''.join(word.capitalize() for word in parts if word)
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in ordered_params)
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ordered_params)
args = []
kwargs = {}
consumed_names = set()
default_args = default_args or []
default_kwargs = default_kwargs or {}
# 1 Handle positional parameters
positional_params = [
p for p in ordered_params
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
for i, p in enumerate(positional_params):
if p.name in values:
args.append(values[p.name])
consumed_names.add(p.name)
elif i < len(default_args):
args.append(default_args[i])
elif p.default is not inspect.Parameter.empty:
args.append(p.default)
else:
raise ValueError(f"Missing required positional argument: {p.name}")
# 2 Handle *args
for p in ordered_params:
if p.kind == inspect.Parameter.VAR_POSITIONAL:
if p.name in values:
val = values[p.name]
if not isinstance(val, (list, tuple)):
raise ValueError(f"*{p.name} must be a list or tuple, got {type(val).__name__}")
args.extend(val)
consumed_names.add(p.name)
elif len(default_args) > len(positional_params):
# Add any remaining default_args beyond fixed positionals
args.extend(default_args[len(positional_params):])
# 3 Handle keyword-only parameters
for p in ordered_params:
if p.kind == inspect.Parameter.KEYWORD_ONLY:
if p.name in values:
kwargs[p.name] = values[p.name]
consumed_names.add(p.name)
elif p.name in default_kwargs:
kwargs[p.name] = default_kwargs[p.name]
elif p.default is not inspect.Parameter.empty:
kwargs[p.name] = p.default
else:
raise ValueError(f"Missing required keyword-only argument: {p.name}")
# 4 Handle **kwargs
for p in ordered_params:
if p.kind == inspect.Parameter.VAR_KEYWORD:
if p.name in values:
val = values[p.name]
if not isinstance(val, dict):
raise ValueError(f"**{p.name} must be a dict, got {type(val).__name__}")
kwargs.update(val)
consumed_names.add(p.name)
# Merge any unmatched names if **kwargs exists
remaining = {
k: v for k, v in values.items()
if k not in consumed_names and k not in parameters
}
# Also merge default_kwargs not used yet
for k, v in default_kwargs.items():
if k not in kwargs:
kwargs[k] = v
kwargs.update(remaining)
break
# 5 Handle unknown / unexpected parameters (if no **kwargs)
if not has_var_keyword:
unexpected = [k for k in values if k not in consumed_names and k in parameters]
if unexpected:
raise ValueError(f"Unexpected parameters: {unexpected}")
extra = [k for k in values if k not in consumed_names and k not in parameters]
if extra:
raise ValueError(f"Unknown parameters: {extra}")
return args, kwargs
@utils_rt(Routes.Commands)
@@ -276,7 +349,6 @@ def post(session, c_id: str, client_response: dict = None):
from myfasthtml.core.commands import CommandsManager
command = CommandsManager.get_command(c_id)
if command:
logger.debug(f"Executing command {command.name}.")
return command.execute(client_response)
raise ValueError(f"Command with ID '{c_id}' not found.")
@@ -295,7 +367,6 @@ def post(session, b_id: str, values: dict):
from myfasthtml.core.bindings import BindingsManager
binding = BindingsManager.get_binding(b_id)
if binding:
res = binding.update(values)
return res
return binding.update(values)
raise ValueError(f"Binding with ID '{b_id}' not found.")

View File

@@ -385,7 +385,7 @@ down_to_bottom = NotStr('''<svg name="carbon-DownToBottom" xmlns="http://www.w3.
basketball = NotStr('''<svg name="carbon-Basketball" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2zm11.95 13H22.04a14.409 14.409 0 0 1 2.738-7.153A11.94 11.94 0 0 1 27.95 15zM17 15V4.05a11.918 11.918 0 0 1 6.287 2.438A16.265 16.265 0 0 0 20.04 15zm-2 0h-3.04a16.265 16.265 0 0 0-3.247-8.512A11.918 11.918 0 0 1 15 4.051zm0 2v10.95a11.918 11.918 0 0 1-6.287-2.438A16.265 16.265 0 0 0 11.96 17zm2 0h3.04a16.265 16.265 0 0 0 3.248 8.512A11.918 11.918 0 0 1 17 27.949zM7.22 7.847A14.409 14.409 0 0 1 9.96 15H4.051a11.94 11.94 0 0 1 3.17-7.153zM4.05 17H9.96a14.409 14.409 0 0 1-2.738 7.153A11.94 11.94 0 0 1 4.05 17zm20.73 7.153A14.409 14.409 0 0 1 22.04 17h5.908a11.94 11.94 0 0 1-3.17 7.153z" fill="currentColor" /></svg>''')
thunderstorm_scattered_night = NotStr('''<svg name="carbon-ThunderstormScatteredNight" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M13.338 30l-1.736-1l2.287-4H10l3.993-7l1.737 1l-2.284 4h3.891l-3.999 7z" fill="currentColor" /> <path d="M29.844 13.035a1.52 1.52 0 0 0-1.231-.866a5.356 5.356 0 0 1-3.41-1.716A6.465 6.465 0 0 1 23.92 4.06a1.604 1.604 0 0 0-.3-1.546a1.455 1.455 0 0 0-1.36-.492l-.019.004a7.854 7.854 0 0 0-6.105 6.48A7.372 7.372 0 0 0 13.5 8a7.551 7.551 0 0 0-7.15 5.244A5.993 5.993 0 0 0 8 25v-2a3.993 3.993 0 0 1-.673-7.93l.663-.112l.145-.656a5.496 5.496 0 0 1 10.73 0l.145.656l.663.113A3.993 3.993 0 0 1 19 23v2a5.955 5.955 0 0 0 5.88-7.146a7.502 7.502 0 0 0 4.867-3.3a1.537 1.537 0 0 0 .097-1.52zm-5.693 2.918a5.966 5.966 0 0 0-3.502-2.709a7.508 7.508 0 0 0-2.62-3.694a6.008 6.008 0 0 1 3.77-5.333a8.458 8.458 0 0 0 1.939 7.596a7.404 7.404 0 0 0 3.902 2.228a5.442 5.442 0 0 1-3.489 1.912z" fill="currentColor" /></svg>''')
letter_uu = NotStr('''<svg name="carbon-LetterUu" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M23 23h-4a2 2 0 0 1-2-2v-8h2v8h4v-8h2v8a2 2 0 0 1-2 2z" fill="currentColor" /> <path d="M13 23H9a2 2 0 0 1-2-2V9h2v12h4V9h2v12a2 2 0 0 1-2 2z" fill="currentColor" /></svg>''')
continue_ = NotStr('''<svg name="carbon-Continue" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M10 28a1 1 0 0 1-1-1V5a1 1 0 0 1 1.501-.865l19 11a1 1 0 0 1 0 1.73l-19 11A.998.998 0 0 1 10 28zm1-21.266v18.532L27 16z" fill="currentColor" /> <path d="M4 4h2v24H4z" fill="currentColor" /></svg>''')
continue = NotStr('''<svg name="carbon-Continue" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M10 28a1 1 0 0 1-1-1V5a1 1 0 0 1 1.501-.865l19 11a1 1 0 0 1 0 1.73l-19 11A.998.998 0 0 1 10 28zm1-21.266v18.532L27 16z" fill="currentColor" /> <path d="M4 4h2v24H4z" fill="currentColor" /></svg>''')
login = NotStr('''<svg name="carbon-Login" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M26 30H14a2 2 0 0 1-2-2v-3h2v3h12V4H14v3h-2V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v24a2 2 0 0 1-2 2z" fill="currentColor" /> <path d="M14.59 20.59L18.17 17H4v-2h14.17l-3.58-3.59L16 10l6 6l-6 6l-1.41-1.41z" fill="currentColor" /></svg>''')
favorite = NotStr('''<svg name="carbon-Favorite" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M22.45 6a5.47 5.47 0 0 1 3.91 1.64a5.7 5.7 0 0 1 0 8L16 26.13L5.64 15.64a5.7 5.7 0 0 1 0-8a5.48 5.48 0 0 1 7.82 0l2.54 2.6l2.53-2.58A5.44 5.44 0 0 1 22.45 6m0-2a7.47 7.47 0 0 0-5.34 2.24L16 7.36l-1.11-1.12a7.49 7.49 0 0 0-10.68 0a7.72 7.72 0 0 0 0 10.82L16 29l11.79-11.94a7.72 7.72 0 0 0 0-10.82A7.49 7.49 0 0 0 22.45 4z" fill="currentColor" /></svg>''')
cd_archive = NotStr('''<svg name="carbon-CdArchive" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M16 28a12 12 0 1 1 12-12a12 12 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10 10 0 0 0 16 6z" fill="currentColor" /> <path d="M16 22a6 6 0 1 1 6-6a6 6 0 0 1-6 6zm0-10a4 4 0 1 0 4 4a4 4 0 0 0-4-4z" fill="currentColor" /> <circle cx="16" cy="16" r="2" fill="currentColor" /></svg>''')

View File

@@ -11,10 +11,6 @@ import re
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
if name is None:
return None
name = name.strip()
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')

View File

@@ -10,7 +10,6 @@ from starlette.responses import Response
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.AuthProxy import AuthProxy
from myfasthtml.core.instances import RootInstance
from myfasthtml.core.utils import utils_app
logger = logging.getLogger("MyFastHtml")
@@ -32,7 +31,6 @@ def get_asset_content(filename):
def create_app(daisyui: Optional[bool] = True,
vis: Optional[bool] = True,
protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False,
base_url: Optional[str] = None,
@@ -65,11 +63,6 @@ def create_app(daisyui: Optional[bool] = True,
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
]
if vis:
hdrs += [
Script(src="/myfasthtml/vis-network.min.js"),
]
beforeware = create_auth_beforeware() if protect_routes else None
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
@@ -103,8 +96,8 @@ def create_app(daisyui: Optional[bool] = True,
if mount_auth_app:
# Setup authentication routes
setup_auth_routes(app, rt, base_url=base_url)
# create the AuthProxy instance
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
AuthProxy(base_url) # using the auto register mechanism to expose it
return app, rt

View File

@@ -6,8 +6,6 @@ from fastcore.basics import NotStr
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **"
class Predicate:
def __init__(self, value):
@@ -116,18 +114,6 @@ class AttributeForbidden(ChildrenPredicate):
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
class DoNotCheck:
desc: str = None
@@ -201,16 +187,6 @@ class ErrorOutput:
self.indent = self.indent[:-2]
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:
self._add_to_output(str(self.element))
# Try to show where the differences are
@@ -229,7 +205,7 @@ class ErrorOutput:
if hasattr(element, "tag"):
# 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]}
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):
if hasattr(expected, "tag") and hasattr(element, "tag"):
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
not self._matches(attr_value, expected.attrs[attr_name])]
if attrs_in_error:
@@ -266,35 +242,6 @@ class ErrorOutput:
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
def _matches(element, expected):
if element == expected:
@@ -400,34 +347,6 @@ def matches(actual, expected, path=""):
# set the path
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")), \
_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):
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):
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
@@ -493,88 +404,9 @@ def matches(actual, expected, path=""):
for actual_child, expected_child in zip(actual.children, expected_children):
assert matches(actual_child, expected_child, path=path)
else:
assert actual == expected, _error_msg("The values are different",
assert actual == expected, _error_msg("The values are different: ",
_actual=actual,
_expected=expected)
return True
def find(ft, expected):
res = []
def _type(x):
return type(x)
def _same(_ft, _expected):
if _ft == _expected:
return True
if _ft.tag != _expected.tag:
return False
for attr in _expected.attrs:
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
return False
for expected_child in _expected.children:
for ft_child in _ft.children:
if _same(ft_child, expected_child):
break
else:
return False
return True
def _find(current, current_expected):
if _type(current) != _type(current_expected):
return []
if not hasattr(current, "tag"):
return [current] if current == current_expected else []
_found = []
if _same(current, current_expected):
_found.append(current)
# look at the children
for child in current.children:
_found.extend(_find(child, current_expected))
return _found
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
for current_ft in ft_as_list:
found = _find(current_ft, expected)
res.extend(found)
if len(res) == 0:
raise AssertionError(f"No element found for '{expected}'")
return res
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())

View File

@@ -1,12 +1,5 @@
import pytest
from myfasthtml.core.instances import SingleInstance
class RootInstanceForTests(SingleInstance):
def __init__(self, session):
super().__init__(None, session, _id="TestRoot")
@pytest.fixture(scope="session")
def session():
@@ -21,8 +14,3 @@ def session():
'updated_at': '2025-11-10T15:52:59.006213'
}
}
@pytest.fixture(scope="session")
def root_instance(session):
return RootInstanceForTests(session=session)

View File

@@ -1,8 +1,5 @@
import shutil
import pytest
from fasthtml.components import *
from fasthtml.xtend import Script
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import InstancesManager
@@ -11,12 +8,10 @@ from .conftest import session
@pytest.fixture()
def tabs_manager(root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
yield TabsManager(root_instance)
def tabs_manager(session):
yield TabsManager(session)
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
class TestTabsManagerBehaviour:
@@ -25,12 +20,11 @@ class TestTabsManagerBehaviour:
assert from_instance_manager == tabs_manager
def test_i_can_add_tab(self, tabs_manager):
tab_id = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id = tabs_manager.add_tab("Users", Div("Content 1"))
assert tab_id is not None
assert tab_id in tabs_manager.get_state().tabs
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Users"
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
assert tabs_manager.get_state().tabs_order == [tab_id]
@@ -43,35 +37,6 @@ class TestTabsManagerBehaviour:
assert len(tabs_manager.get_state().tabs) == 2
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
assert tabs_manager.get_state().active_tab == tab_id2
def test_i_can_show_tab(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
assert tabs_manager.get_state().active_tab == tab_id2 # last crated tab is active
tabs_manager.show_tab(tab_id1)
assert tabs_manager.get_state().active_tab == tab_id1
def test_i_can_close_tab(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
tabs_manager.close_tab(tab_id2)
assert len(tabs_manager.get_state().tabs) == 2
assert [tab_id for tab_id in tabs_manager.get_state().tabs] == [tab_id1, tab_id3]
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
assert tabs_manager.get_state().active_tab == tab_id3 # last tab stays active
def test_i_still_have_an_active_tab_after_close(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
tabs_manager.close_tab(tab_id3) # close the currently active tab
assert tabs_manager.get_state().active_tab == tab_id1 # default to the first tab
class TestTabsManagerRender:
@@ -79,23 +44,29 @@ class TestTabsManagerRender:
res = tabs_manager.render()
expected = Div(
Div(
Div(id=f"{tabs_manager.get_id()}-controller"),
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
),
Div(
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
id=f"{tabs_manager.get_id()}-header-wrapper"
),
Div(
Div(id=f"{tabs_manager.get_id()}-None-content"),
id=f"{tabs_manager.get_id()}-content-wrapper"
),
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
Div(id=f"{tabs_manager.get_id()}-content"),
id=tabs_manager.get_id(),
)
assert matches(res, expected)
def test_i_can_render_when_one_tab(self, tabs_manager):
tabs_manager.add_tab("Users", Div("Content 1"))
res = tabs_manager.render()
expected = Div(
Div(
Button(),
id=f"{tabs_manager.get_id()}-header"
),
Div(
Div("Content 1")
),
id=tabs_manager.get_id(),
)
assert matches(res, expected)
def test_i_can_render_when_multiple_tabs(self, tabs_manager):
tabs_manager.add_tab("Users1", Div("Content 1"))
tabs_manager.add_tab("Users2", Div("Content 2"))
@@ -104,22 +75,13 @@ class TestTabsManagerRender:
expected = Div(
Div(
Div(id=f"{tabs_manager.get_id()}-controller"),
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
Button(),
Button(),
Button(),
id=f"{tabs_manager.get_id()}-header"
),
Div(
Div(
Div(), # tab_button
Div(), # tab_button
Div(), # tab_button
id=f"{tabs_manager.get_id()}-header"
),
id=f"{tabs_manager.get_id()}-header-wrapper"
),
Div(
Div("Content 3"), # active tab content
# Lasy loading for the other contents
id=f"{tabs_manager.get_id()}-content-wrapper"
Div("Content 3")
),
id=tabs_manager.get_id(),
)

View File

@@ -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

View File

@@ -5,7 +5,7 @@ import pytest
from fasthtml.components import Button, Div
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@@ -104,8 +104,8 @@ class TestCommandBind:
assert matches(updated, expected)
@pytest.mark.parametrize("return_values", [
[Div(), Div(id="id1"), "hello", Div(id="id2")], # list
(Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple
[Div(), Div(), "hello", Div()], # list
(Div(), Div(), "hello", Div()) # tuple
])
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
"""Test that hx-swap-oob is automatically set, but not for the first."""
@@ -157,40 +157,3 @@ class TestCommandExecute:
command = Command('test', 'Command description', callback_with_param)
command.execute(client_response={"number": "10"})
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
"""Test that hx-swap-oob is automatically set, but not for the first."""
def another_callback():
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
command = Command('test', 'Command description', another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
assert res[1].attrs["hx-swap-oob"] == "true"
assert res[3].attrs["hx-swap-oob"] == "true"
def test_swap_oob_is_not_added_when_there_no_id(self):
"""Test that hx-swap-oob is automatically set, but not for the first."""
def another_callback():
return Div(id="first"), Div(), "hello", Div()
command = Command('test', 'Command description', another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
assert "hx-swap-oob" not in res[1].attrs
assert "hx-swap-oob" not in res[3].attrs
class TestLambaCommand:
def test_i_can_create_a_command_from_lambda(self):
command = LambdaCommand(lambda resp: "Hello World")
assert command.execute() == "Hello World"
def test_by_default_target_is_none(self):
command = LambdaCommand(lambda resp: "Hello World")
assert command.get_htmx_params()["hx-swap"] == "none"

View File

@@ -4,7 +4,6 @@ from dataclasses import dataclass
import pytest
from myfasthtml.core.dbmanager import DbManager, DbObject
from myfasthtml.core.instances import SingleInstance, BaseInstance
@pytest.fixture(scope="session")
@@ -20,14 +19,9 @@ def session():
@pytest.fixture
def parent(session):
return SingleInstance(session=session, _id="test_parent_id")
@pytest.fixture
def db_manager(parent):
def db_manager(session):
shutil.rmtree("TestDb", ignore_errors=True)
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
db_manager_instance = DbManager(session, root="TestDb", auto_register=False)
yield db_manager_instance
@@ -38,17 +32,17 @@ def simplify(res: dict) -> dict:
return {k: v for k, v in res.items() if not k.startswith("_")}
def test_i_can_init(parent, db_manager):
def test_i_can_init(session, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
self.number: int = 42
self.none_value: None = None
dummy = DummyObject(parent)
dummy = DummyObject(session)
props = dummy._get_properties()
@@ -58,17 +52,17 @@ def test_i_can_init(parent, db_manager):
assert len(history) == 1
def test_i_can_init_from_dataclass(parent, db_manager):
def test_i_can_init_from_dataclass(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
DummyObject(parent)
DummyObject(session)
in_db = db_manager.load("DummyObject")
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
@@ -76,10 +70,10 @@ def test_i_can_init_from_dataclass(parent, db_manager):
assert len(history) == 1
def test_i_can_init_from_db_with(parent, db_manager):
def test_i_can_init_from_db_with(session, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
@@ -88,17 +82,17 @@ def test_i_can_init_from_db_with(parent, db_manager):
# insert other values in db
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
dummy = DummyObject(parent)
dummy = DummyObject(session)
assert dummy.value == "other_value"
assert dummy.number == 34
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
def test_i_can_init_from_db_with_dataclass(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
@@ -106,83 +100,37 @@ def test_i_can_init_from_db_with_dataclass(parent, db_manager):
# insert other values in db
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
dummy = DummyObject(parent)
dummy = DummyObject(session)
assert dummy.value == "other_value"
assert dummy.number == 34
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
with self.initializing():
self.to_save: str = "value"
self._not_to_save: str = "value"
self.ns_not_to_save: str = "value"
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(parent)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(parent, db_manager):
def test_db_is_updated_when_attribute_is_modified(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(parent)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_db_is_updated_when_attribute_is_modified(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
dummy = DummyObject(session)
dummy.value = "other_value"
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
dummy = DummyObject(session)
dummy.value = "other_value"
in_db_1 = db_manager.load("DummyObject")
@@ -192,16 +140,16 @@ def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
assert in_db_1["__parent__"] == in_db_2["__parent__"]
def test_i_can_update(parent, db_manager):
def test_i_can_update(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
dummy = DummyObject(session)
clone = dummy.copy()
clone.number = 34
@@ -213,52 +161,54 @@ def test_i_can_update(parent, db_manager):
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
self.number: int = 42
self.none_value: None = None
dummy = DummyObject(parent)
dummy = DummyObject(session)
clone = dummy.copy()
for k in DbObject._forbidden_attrs:
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
dummy = DummyObject(parent)
dummy = DummyObject(session)
clone = dummy.copy()
for k in DbObject._forbidden_attrs:
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
dummy = DummyObject(parent)
dummy = DummyObject(session)
dummy.update(_owner="other_value")
dummy.update(_session="other_value")
assert dummy._owner is parent
assert dummy._session == session

View File

@@ -1,399 +0,0 @@
import pytest
from myfasthtml.core.instances import (
BaseInstance,
SingleInstance,
MultipleInstance,
InstancesManager,
DuplicateInstanceError,
special_session,
Ids,
RootInstance
)
@pytest.fixture(autouse=True)
def reset_instances():
"""Reset instances before each test to ensure isolation."""
InstancesManager.instances.clear()
yield
InstancesManager.instances.clear()
@pytest.fixture
def session():
"""Create a test session."""
return {"user_info": {"id": "test-user-123"}}
@pytest.fixture
def another_session():
"""Create another test session."""
return {"user_info": {"id": "test-user-456"}}
@pytest.fixture
def root_instance(session):
"""Create a root instance for testing."""
return SingleInstance(parent=None, session=session, _id="test-root")
# Example subclasses for testing
class SubSingleInstance(SingleInstance):
"""Example subclass of SingleInstance with simplified signature."""
def __init__(self, parent):
super().__init__(parent=parent)
class SubMultipleInstance(MultipleInstance):
"""Example subclass of MultipleInstance with custom parameter."""
def __init__(self, parent, _id=None, custom_param=None):
super().__init__(parent=parent, _id=_id)
self.custom_param = custom_param
class TestBaseInstance:
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
"""Test that a BaseInstance can be created with positional arguments."""
instance = BaseInstance(root_instance, session, "test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
"""Test that a BaseInstance can be created with keyword arguments."""
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
instance = BaseInstance(root_instance, session=session, _id="test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
"""Test that creating an instance with same session and id returns the existing instance."""
instance1 = BaseInstance(root_instance, session, "same_id")
instance2 = BaseInstance(root_instance, session, "same_id")
assert instance1 is instance2
def test_i_can_control_instances_registration(self, session, root_instance):
"""Test that auto_register=False prevents automatic registration."""
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
session_id = InstancesManager.get_session_id(session)
key = (session_id, "test_id")
assert key not in InstancesManager.instances
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
"""Test that different sessions can have instances with the same id."""
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
instance1 = BaseInstance(root_instance, session, "same_id")
instance2 = BaseInstance(root_instance2, another_session, "same_id")
assert instance1 is not instance2
assert instance1.get_session() == session
assert instance2.get_session() == another_session
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
"""Test that session can be extracted from parent when not provided."""
instance = BaseInstance(parent=root_instance, _id="test_id")
assert instance.get_session() == root_instance.get_session()
assert instance.get_parent() == root_instance
def test_i_cannot_create_instance_without_parent_or_session(self):
"""Test that creating an instance without parent or session raises TypeError."""
with pytest.raises(TypeError, match="Either session or parent must be provided"):
BaseInstance(None, _id="test_id")
def test_i_can_get_auto_generated_id(self, session, root_instance):
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
instance = BaseInstance(parent=root_instance, session=session)
assert instance.get_id() is not None
assert instance.get_id().startswith("mf-base_instance-")
def test_i_can_get_prefix_from_class_name(self, session):
"""Test that get_prefix() returns the correct snake_case prefix."""
prefix = BaseInstance(None, session).get_prefix()
assert prefix == "mf-base_instance"
class TestSingleInstance:
def test_i_can_create_a_single_instance(self, session, root_instance):
"""Test that a SingleInstance can be created."""
instance = SingleInstance(parent=root_instance, session=session)
assert instance is not None
assert instance.get_id() == "mf-single_instance"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
"""Test that a SingleInstance can be created with positional arguments."""
instance = SingleInstance(root_instance, session, "custom_id")
assert instance is not None
assert instance.get_id() == "custom_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_the_same_instance_is_returned(self, session):
"""Test that single instance is cached and returned on subsequent calls."""
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
assert instance1 is instance2
def test_i_cannot_create_duplicate_single_instance(self, session):
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
instance = SingleInstance(parent=None, session=session, _id="unique_id")
with pytest.raises(DuplicateInstanceError):
InstancesManager.register(session, instance)
def test_i_can_retrieve_existing_single_instance(self, session):
"""Test that attempting to create an existing SingleInstance returns the same instance."""
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
assert instance1 is instance2
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
"""Test that the default ID equals prefix for SingleInstance."""
instance = SingleInstance(parent=None, session=session)
assert instance.get_id() == "mf-single_instance"
assert instance.get_prefix() == "mf-single_instance"
class TestSingleInstanceSubclass:
def test_i_can_create_subclass_of_single_instance(self, root_instance):
"""Test that a subclass of SingleInstance works correctly."""
instance = SubSingleInstance(root_instance)
assert instance is not None
assert isinstance(instance, SingleInstance)
assert isinstance(instance, SubSingleInstance)
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
"""Test that subclass with simplified signature works correctly."""
instance = SubSingleInstance(root_instance)
assert instance.get_parent() == root_instance
assert instance.get_session() == root_instance.get_session()
assert instance.get_id() == "mf-sub_single_instance"
assert instance.get_prefix() == "mf-sub_single_instance"
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
"""Test that cache works for subclasses."""
instance1 = SubSingleInstance(root_instance)
instance2 = SubSingleInstance(root_instance)
assert instance1 is instance2
class TestMultipleInstance:
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
instance1 = MultipleInstance(parent=root_instance, session=session)
instance2 = MultipleInstance(parent=root_instance, session=session)
assert instance1 is not instance2
assert instance1.get_id() != instance2.get_id()
assert instance1.get_id().startswith("mf-multiple_instance-")
assert instance2.get_id().startswith("mf-multiple_instance-")
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
"""Test that each MultipleInstance receives a unique auto-generated ID."""
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
ids = [inst.get_id() for inst in instances]
# All IDs should be unique
assert len(ids) == len(set(ids))
# All IDs should start with the prefix
assert all(id.startswith("mf-multiple_instance-") for id in ids)
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
"""Test that a custom _id can be provided to MultipleInstance."""
custom_id = "custom-instance-id"
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
assert instance.get_id() == custom_id
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
custom_id = "custom-instance-id"
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
assert instance1 is instance2
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
"""Test that key prefixed by underscore uses the parent id as prefix."""
instance = MultipleInstance(parent=root_instance, _id="-test_id")
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
assert instance.get_id() == "-test_id"
class TestMultipleInstanceSubclass:
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
"""Test that a subclass of MultipleInstance works correctly."""
instance = SubMultipleInstance(root_instance, custom_param="test")
assert instance is not None
assert isinstance(instance, MultipleInstance)
assert isinstance(instance, SubMultipleInstance)
assert instance.custom_param == "test"
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
"""Test that multiple instances of subclass can be created with unique IDs."""
instance1 = SubMultipleInstance(root_instance, custom_param="first")
instance2 = SubMultipleInstance(root_instance, custom_param="second")
assert instance1 is not instance2
assert instance1.get_id() != instance2.get_id()
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
"""Test that subclass with custom parameters works correctly."""
instance = SubMultipleInstance(root_instance, custom_param="value")
assert instance.get_parent() == root_instance
assert instance.get_session() == root_instance.get_session()
assert instance.custom_param == "value"
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
"""Test that cache works for subclasses."""
instance1 = SubMultipleInstance(root_instance, custom_param="first")
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
assert instance1 is instance2
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
"""Test that cache works for subclasses with custom _id."""
# Need to pass _id explicitly to enable caching
instance1 = SubMultipleInstance(root_instance)
with pytest.raises(TypeError):
MultipleInstance(parent=root_instance, _id=instance1.get_id())
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
"""Test that subclass has correct auto-generated prefix."""
prefix = SubMultipleInstance(root_instance).get_prefix()
assert prefix == "mf-sub_multiple_instance"
class TestInstancesManager:
def test_i_can_register_an_instance_manually(self, session, root_instance):
"""Test that an instance can be manually registered."""
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
InstancesManager.register(session, instance)
session_id = InstancesManager.get_session_id(session)
key = (session_id, "manual_id")
assert key in InstancesManager.instances
assert InstancesManager.instances[key] is instance
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
"""Test that an existing instance can be retrieved by ID."""
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
retrieved = InstancesManager.get(session, "get_id")
assert retrieved is instance
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
"""Test that getting a non-existent instance without type raises KeyError."""
with pytest.raises(KeyError):
InstancesManager.get(session, "nonexistent_id")
def test_i_can_get_session_id_from_valid_session(self, session):
"""Test that session ID is correctly extracted from a valid session."""
session_id = InstancesManager.get_session_id(session)
assert session_id == "test-user-123"
def test_i_can_handle_none_session(self):
"""Test that None session returns a special identifier."""
session_id = InstancesManager.get_session_id(None)
assert session_id == "** NOT LOGGED IN **"
def test_i_can_handle_invalid_session(self):
"""Test that invalid sessions return appropriate identifiers."""
# Session is None
session_id = InstancesManager.get_session_id(None)
assert session_id == "** NOT LOGGED IN **"
# Session without user_info
session_no_user = {}
session_id = InstancesManager.get_session_id(session_no_user)
assert session_id == "** UNKNOWN USER **"
# Session with user_info but no id
session_no_id = {"user_info": {}}
session_id = InstancesManager.get_session_id(session_no_id)
assert session_id == "** INVALID SESSION **"
def test_i_can_reset_all_instances(self, session, root_instance):
"""Test that reset() clears all instances."""
BaseInstance(parent=root_instance, session=session, _id="id1")
BaseInstance(parent=root_instance, session=session, _id="id2")
assert len(InstancesManager.instances) > 0
InstancesManager.reset()
assert len(InstancesManager.instances) == 0
class TestRootInstance:
def test_i_can_create_root_instance_with_positional_args(self):
"""Test that RootInstance can be created with positional arguments."""
root = SingleInstance(None, special_session, Ids.Root)
assert root is not None
assert root.get_id() == Ids.Root
assert root.get_session() == special_session
assert root.get_parent() is None
def test_i_can_access_root_instance(self):
"""Test that RootInstance is created and accessible."""
assert RootInstance is not None
assert RootInstance.get_id() == Ids.Root
assert RootInstance.get_session() == special_session

View File

@@ -1,105 +0,0 @@
from dataclasses import dataclass
from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching
class TestFuzzyMatching:
def test_i_can_find_exact_match_with_fuzzy(self):
# Exact match should always pass
choices = ["hello"]
result = fuzzy_matching("hello", choices)
assert len(result) == 1
assert result[0] == "hello"
def test_i_can_find_close_match_with_fuzzy(self):
# "helo.txt" should match "hello.txt" with high similarity
choices = ["hello"]
result = fuzzy_matching("helo", choices, similarity_threshold=0.7)
assert len(result) == 1
assert result[0] == "hello"
def test_i_cannot_find_dissimilar_match_with_fuzzy(self):
# "world.txt" should not match "hello.txt"
choices = ["hello"]
result = fuzzy_matching("world", choices, similarity_threshold=0.7)
assert len(result) == 0
def test_i_can_sort_by_similarity_in_fuzzy(self):
# hello has a higher similarity than helo
choices = [
"hello",
"helo",
]
result = fuzzy_matching("hello", choices, similarity_threshold=0.7)
assert result == ["hello", "helo"]
def test_i_can_find_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("helo", "1"),
DummyObject("hello", "2"),
DummyObject("xyz", "3"),
]
result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")]
class TestSubsequenceMatching:
def test_i_can_match_subsequence_simple(self):
# "abg" should match "AlphaBetaGamma"
choices = ["AlphaBetaGamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "AlphaBetaGamma"
def test_i_can_match_subsequence_simple_case_insensitive(self):
# "abg" should match "alphabetagamma"
choices = ["alphabetagamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "alphabetagamma"
def test_i_cannot_match_wrong_order_subsequence(self):
# the order is wrong
choices = ["AlphaBetaGamma"]
result = subsequence_matching("gba", choices)
assert len(result) == 0
def test_i_can_match_multiple_documents_subsequence(self):
# "abg" should match both filenames, but "AlphaBetaGamma" has a higher score
choices = [
"AlphaBetaGamma",
"HalleBerryIsGone",
]
result = subsequence_matching("abg", choices)
assert len(result) == 2
assert result[0] == "AlphaBetaGamma"
assert result[1] == "HalleBerryIsGone"
def test_i_cannot_match_unrelated_subsequence(self):
# "xyz" should not match any file
choices = ["AlphaBetaGamma"]
result = subsequence_matching("xyz", choices)
assert len(result) == 0
def test_i_can_match_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("HalleBerryIsGone", "1"),
DummyObject("AlphaBetaGamma", "2"),
DummyObject("xyz", "3"),
]
result = subsequence_matching("abg", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")]

View File

@@ -1,648 +0,0 @@
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
class TestFromNestedDict:
def test_i_can_convert_single_root_node(self):
"""Test conversion of a single root node without children."""
trees = [{"root": {}}]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 1
assert nodes[0] == {"id": 1, "label": "root"}
assert len(edges) == 0
def test_i_can_convert_tree_with_one_level_children(self):
"""Test conversion with direct children, verifying edge creation."""
trees = [{"root": {"child1": {}, "child2": {}}}]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 3
assert nodes[0] == {"id": 1, "label": "root"}
assert nodes[1] == {"id": 2, "label": "child1"}
assert nodes[2] == {"id": 3, "label": "child2"}
assert len(edges) == 2
assert {"from": 1, "to": 2} in edges
assert {"from": 1, "to": 3} in edges
def test_i_can_convert_tree_with_multiple_levels(self):
"""Test recursive conversion with multiple levels of nesting."""
trees = [
{
"root": {
"child1": {
"grandchild1": {},
"grandchild2": {}
},
"child2": {}
}
}
]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 5
assert len(edges) == 4
# Verify hierarchy
assert {"from": 1, "to": 2} in edges # root -> child1
assert {"from": 1, "to": 5} in edges # root -> child2
assert {"from": 2, "to": 3} in edges # child1 -> grandchild1
assert {"from": 2, "to": 4} in edges # child1 -> grandchild2
def test_i_can_generate_auto_incremented_ids(self):
"""Test that IDs start at 1 and increment correctly."""
trees = [{"a": {"b": {"c": {}}}}]
nodes, edges = from_nested_dict(trees)
ids = [node["id"] for node in nodes]
assert ids == [1, 2, 3]
def test_i_can_use_dict_keys_as_labels(self):
"""Test that dictionary keys become node labels."""
trees = [{"RootNode": {"ChildNode": {}}}]
nodes, edges = from_nested_dict(trees)
assert nodes[0]["label"] == "RootNode"
assert nodes[1]["label"] == "ChildNode"
def test_i_can_convert_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
trees = []
nodes, edges = from_nested_dict(trees)
assert nodes == []
assert edges == []
def test_i_can_convert_multiple_root_nodes(self):
"""Test conversion with multiple independent trees."""
trees = [
{"root1": {"child1": {}}},
{"root2": {"child2": {}}}
]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 4
assert nodes[0] == {"id": 1, "label": "root1"}
assert nodes[1] == {"id": 2, "label": "child1"}
assert nodes[2] == {"id": 3, "label": "root2"}
assert nodes[3] == {"id": 4, "label": "child2"}
# Verify edges connect within trees, not across
assert len(edges) == 2
assert {"from": 1, "to": 2} in edges
assert {"from": 3, "to": 4} in edges
def test_i_can_maintain_id_sequence_across_multiple_trees(self):
"""Test that ID counter continues across multiple trees."""
trees = [
{"tree1": {}},
{"tree2": {}},
{"tree3": {}}
]
nodes, edges = from_nested_dict(trees)
ids = [node["id"] for node in nodes]
assert ids == [1, 2, 3]
class TestFromTreeWithMetadata:
def test_i_can_convert_single_node_with_metadata(self):
"""Test basic conversion with explicit id and label."""
trees = [{"id": "root", "label": "Root Node"}]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 1
assert nodes[0] == {"id": "root", "label": "Root Node"}
assert len(edges) == 0
def test_i_can_preserve_string_ids_from_metadata(self):
"""Test that string IDs from the tree are preserved."""
trees = [
{
"id": "root_id",
"label": "Root",
"children": [
{"id": "child_id", "label": "Child"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == "root_id"
assert nodes[1]["id"] == "child_id"
assert edges[0] == {"from": "root_id", "to": "child_id"}
def test_i_can_auto_increment_when_id_is_missing(self):
"""Test fallback to auto-increment when ID is not provided."""
trees = [
{
"label": "Root",
"children": [
{"label": "Child1"},
{"id": "child2_id", "label": "Child2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == 1 # auto-incremented
assert nodes[1]["id"] == 2 # auto-incremented
assert nodes[2]["id"] == "child2_id" # preserved
def test_i_can_convert_tree_with_children(self):
"""Test handling of children list."""
trees = [
{
"id": "root",
"label": "Root",
"children": [
{
"id": "child1",
"label": "Child 1",
"children": [
{"id": "grandchild", "label": "Grandchild"}
]
},
{"id": "child2", "label": "Child 2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 4
assert len(edges) == 3
assert {"from": "root", "to": "child1"} in edges
assert {"from": "root", "to": "child2"} in edges
assert {"from": "child1", "to": "grandchild"} in edges
def test_i_can_use_custom_id_getter(self):
"""Test custom callback for extracting node ID."""
trees = [
{
"node_id": "custom_root",
"label": "Root"
}
]
def custom_id_getter(node):
return node.get("node_id")
nodes, edges = from_tree_with_metadata(
trees,
id_getter=custom_id_getter
)
assert nodes[0]["id"] == "custom_root"
def test_i_can_use_custom_label_getter(self):
"""Test custom callback for extracting node label."""
trees = [
{
"id": "root",
"name": "Custom Label"
}
]
def custom_label_getter(node):
return node.get("name", "")
nodes, edges = from_tree_with_metadata(
trees,
label_getter=custom_label_getter
)
assert nodes[0]["label"] == "Custom Label"
def test_i_can_use_custom_children_getter(self):
"""Test custom callback for extracting children."""
trees = [
{
"id": "root",
"label": "Root",
"kids": [
{"id": "child", "label": "Child"}
]
}
]
def custom_children_getter(node):
return node.get("kids", [])
nodes, edges = from_tree_with_metadata(
trees,
children_getter=custom_children_getter
)
assert len(nodes) == 2
assert nodes[1]["id"] == "child"
def test_i_can_handle_missing_label_with_default(self):
"""Test that missing label returns empty string."""
trees = [{"id": "root"}]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["label"] == ""
def test_i_can_handle_missing_children_with_default(self):
"""Test that missing children returns empty list (no children processed)."""
trees = [{"id": "root", "label": "Root"}]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 1
assert len(edges) == 0
def test_i_can_convert_multiple_root_trees(self):
"""Test conversion with multiple independent trees with metadata."""
trees = [
{
"id": "root1",
"label": "Root 1",
"children": [
{"id": "child1", "label": "Child 1"}
]
},
{
"id": "root2",
"label": "Root 2",
"children": [
{"id": "child2", "label": "Child 2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 4
assert nodes[0]["id"] == "root1"
assert nodes[1]["id"] == "child1"
assert nodes[2]["id"] == "root2"
assert nodes[3]["id"] == "child2"
# Verify edges connect within trees, not across
assert len(edges) == 2
assert {"from": "root1", "to": "child1"} in edges
assert {"from": "root2", "to": "child2"} in edges
def test_i_can_maintain_id_counter_across_multiple_trees_when_missing_ids(self):
"""Test that auto-increment counter continues across multiple trees."""
trees = [
{"label": "Tree1"},
{"label": "Tree2"},
{"label": "Tree3"}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == 1
assert nodes[1]["id"] == 2
assert nodes[2]["id"] == 3
def test_i_can_convert_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
trees = []
nodes, edges = from_tree_with_metadata(trees)
assert nodes == []
assert edges == []
class TestFromParentChildList:
def test_i_can_convert_single_root_node_without_parent(self):
"""Test conversion of a single root node without parent."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 1
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
assert len(edges) == 0
def test_i_can_convert_simple_parent_child_relationship(self):
"""Test conversion with basic parent-child relationship."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
assert {"id": "child", "label": "Child"} in nodes
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_convert_multiple_children_with_same_parent(self):
"""Test that one parent can have multiple children."""
items = [
{"id": "root", "label": "Root"},
{"id": "child1", "parent": "root", "label": "Child 1"},
{"id": "child2", "parent": "root", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3
assert len(edges) == 2
assert {"from": "root", "to": "child1"} in edges
assert {"from": "root", "to": "child2"} in edges
def test_i_can_convert_multi_level_hierarchy(self):
"""Test conversion with multiple levels (root -> child -> grandchild)."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"},
{"id": "grandchild", "parent": "child", "label": "Grandchild"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3
assert len(edges) == 2
assert {"from": "root", "to": "child"} in edges
assert {"from": "child", "to": "grandchild"} in edges
def test_i_can_handle_parent_none_as_root(self):
"""Test that parent=None identifies a root node."""
items = [
{"id": "root", "parent": None, "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_handle_parent_empty_string_as_root(self):
"""Test that parent='' identifies a root node."""
items = [
{"id": "root", "parent": "", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_create_ghost_node_for_missing_parent(self):
"""Test automatic creation of ghost node when parent doesn't exist."""
items = [
{"id": "child", "parent": "missing_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
# Find the ghost node
ghost_node = [n for n in nodes if n["id"] == "missing_parent"][0]
assert ghost_node is not None
assert len(edges) == 1
assert edges[0] == {"from": "missing_parent", "to": "child"}
def test_i_can_apply_ghost_color_to_missing_parent(self):
"""Test that ghost nodes have the default ghost color."""
items = [
{"id": "child", "parent": "ghost", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
assert "color" in ghost_node
assert ghost_node["color"] == "#cccccc"
def test_i_can_use_custom_ghost_color(self):
"""Test that custom ghost_color parameter is applied."""
items = [
{"id": "child", "parent": "ghost", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, ghost_color="#0000ff")
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
assert ghost_node["color"] == "#0000ff"
def test_i_can_create_multiple_ghost_nodes(self):
"""Test handling of multiple missing parents."""
items = [
{"id": "child1", "parent": "ghost1", "label": "Child 1"},
{"id": "child2", "parent": "ghost2", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 4 # 2 real + 2 ghost
ghost_ids = [n["id"] for n in nodes if "color" in n]
assert "ghost1" in ghost_ids
assert "ghost2" in ghost_ids
def test_i_can_avoid_duplicate_ghost_nodes(self):
"""Test that same missing parent creates only one ghost node."""
items = [
{"id": "child1", "parent": "ghost", "label": "Child 1"},
{"id": "child2", "parent": "ghost", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3 # 2 real + 1 ghost
ghost_nodes = [n for n in nodes if n["id"] == "ghost"]
assert len(ghost_nodes) == 1
assert len(edges) == 2
assert {"from": "ghost", "to": "child1"} in edges
assert {"from": "ghost", "to": "child2"} in edges
def test_i_can_use_custom_id_getter(self):
"""Test custom callback for extracting node ID."""
items = [
{"node_id": "root", "label": "Root"}
]
def custom_id_getter(item):
return item.get("node_id")
nodes, edges = from_parent_child_list(
items,
id_getter=custom_id_getter
)
assert nodes[0]["id"] == "root"
def test_i_can_use_custom_label_getter(self):
"""Test custom callback for extracting node label."""
items = [
{"id": "root", "name": "Custom Label"}
]
def custom_label_getter(item):
return item.get("name", "")
nodes, edges = from_parent_child_list(
items,
label_getter=custom_label_getter
)
assert nodes[0]["label"] == "Custom Label"
def test_i_can_use_custom_parent_getter(self):
"""Test custom callback for extracting parent ID."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent_id": "root", "label": "Child"}
]
def custom_parent_getter(item):
return item.get("parent_id")
nodes, edges = from_parent_child_list(
items,
parent_getter=custom_parent_getter
)
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_handle_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
items = []
nodes, edges = from_parent_child_list(items)
assert nodes == []
assert edges == []
def test_i_can_use_id_as_label_for_ghost_nodes(self):
"""Test that ghost nodes use their ID as label by default."""
items = [
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
assert ghost_node["label"] == "ghost_parent"
def test_i_can_apply_root_color_to_single_root(self):
"""Test that a single root node receives the root_color."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
assert len(nodes) == 1
assert nodes[0]["color"] == "#ff0000"
def test_i_can_apply_root_color_to_multiple_roots(self):
"""Test root_color is assigned to all nodes without parent."""
items = [
{"id": "root1", "label": "Root 1"},
{"id": "root2", "label": "Root 2"},
{"id": "child", "parent": "root1", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, root_color="#aa0000")
root_nodes = [n for n in nodes if n["id"] in ("root1", "root2")]
assert all(n.get("color") == "#aa0000" for n in root_nodes)
# child must NOT have root_color
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_can_handle_root_with_parent_none(self):
"""Test that root_color is applied when parent=None."""
items = [
{"id": "r1", "parent": None, "label": "R1"}
]
nodes, edges = from_parent_child_list(items, root_color="#112233")
assert nodes[0]["color"] == "#112233"
def test_i_can_handle_root_with_parent_empty_string(self):
"""Test that root_color is applied when parent=''."""
items = [
{"id": "r1", "parent": "", "label": "R1"}
]
nodes, edges = from_parent_child_list(items, root_color="#334455")
assert nodes[0]["color"] == "#334455"
def test_i_do_not_apply_root_color_to_non_roots(self):
"""Test that only real roots receive root_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
# Only one root → only this one has the color
root_node = next(n for n in nodes if n["id"] == "root")
assert root_node["color"] == "#ff0000"
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_do_not_override_ghost_color_with_root_color(self):
"""Ghost nodes must keep ghost_color, not root_color."""
items = [
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
assert ghost_node["color"] == "#00ff00"
# child is not root → no color
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_can_use_custom_root_color(self):
"""Test that a custom root_color is applied instead of default."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color="#123456")
assert nodes[0]["color"] == "#123456"
def test_i_can_mix_root_nodes_and_ghost_nodes(self):
"""Ensure root_color applies only to roots and ghost nodes keep ghost_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
root_node = next(n for n in nodes if n["id"] == "root")
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
assert root_node["color"] == "#ff0000"
assert ghost_node["color"] == "#00ff00"
def test_i_do_not_mark_node_as_root_if_parent_field_exists(self):
"""Node with parent key but non-empty value should NOT get root_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"},
{"id": "other", "parent": "unknown_parent", "label": "Other"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
# "root" is the only real root
root_node = next(n for n in nodes if n["id"] == "root")
assert root_node["color"] == "#ff0000"
# "other" is NOT root, even though its parent is missing
other_node = next(n for n in nodes if n["id"] == "other")
assert "color" not in other_node
# ghost parent must have ghost_color
ghost_node = next(n for n in nodes if n["id"] == "unknown_parent")
assert ghost_node["color"] == "#00ff00"
def test_i_do_no_add_root_color_when_its_none(self):
"""Test that a single root node receives the root_color."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color=None)
assert len(nodes) == 1
assert "color" not in nodes[0]

View File

@@ -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();
}
};
})();

View File

@@ -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();
}
};
})();

View File

@@ -1,309 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyboard Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.shortcuts-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.shortcuts-list h3 {
margin-top: 0;
}
.shortcuts-list ul {
margin: 10px 0;
padding-left: 20px;
}
.shortcuts-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
h1, h2 {
color: #333;
}
</style>
</head>
<body>
<h1>Keyboard Support Test Page</h1>
<div class="shortcuts-list">
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
<p><strong>Simple keys:</strong></p>
<ul>
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
<li><code>esc</code> - Escape key (POST)</li>
</ul>
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
<ul>
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
</ul>
<p><strong>Sequences with various configs:</strong></p>
<ul>
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
<li><code>A B C</code> - Triple sequence (GET request)</li>
<li><code>shift shift</code> - Press Shift twice in sequence</li>
</ul>
<p><strong>Complex:</strong></p>
<ul>
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
</ul>
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
</div>
<div class="test-container">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
<strong>Parameters Explained:</strong><br>
<code>has_focus</code>: Whether the registered element itself has focus<br>
<code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
</p>
</div>
<div class="test-container">
<h2>Test Element 1</h2>
<div id="test-element" class="test-element" tabindex="0">
Click me to focus, then try keyboard shortcuts
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_keyboard_support('test-element-2');
logEvent('Element 2 keyboard support removed',
'ESC and Shift Shift no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Keyboard Support Removed';
}
</script>
<!-- Include keyboard support script -->
<script src="keyboard_support.js"></script>
<!-- Initialize keyboard support -->
<script>
const combinations = {
"a": {
"hx-post": "/test/key-a"
},
"Ctrl+S": {
"hx-post": "/test/save",
"hx-swap": "innerHTML"
},
"Ctrl+C": {
"hx-post": "/test/copy",
"hx-target": "#result",
"hx-swap": "outerHTML"
},
"A B": {
"hx-post": "/test/sequence-ab",
"hx-vals": {"extra": "data"}
},
"A B C": {
"hx-get": "/test/sequence-abc"
},
"Ctrl+C C": {
"hx-post": "/test/complex-ctrl-c-c"
},
"Ctrl+C Ctrl+C": {
"hx-post": "/test/complex-ctrl-c-twice"
},
"shift shift": {
"hx-post": "/test/shift-shift"
},
"esc": {
"hx-post": "/test/escape"
}
};
add_keyboard_support('test-element', JSON.stringify(combinations));
// Add second element that also listens to ESC and shift shift
const combinations2 = {
"esc": {
"hx-post": "/test/escape-element2"
},
"shift shift": {
"hx-post": "/test/shift-shift-element2"
}
};
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
// Log initial state
logEvent('Keyboard support initialized',
'Element 1: All shortcuts configured with HTMX options',
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
</script>
</body>
</html>

View File

@@ -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>

View File

@@ -1,42 +0,0 @@
import pytest
from fasthtml.components import Div, Span
from myfasthtml.test.matcher import find
@pytest.mark.parametrize('ft, expected', [
("hello", "hello"),
(Div(id="id1"), Div(id="id1")),
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
(Div(id="id1", id2="id2"), Div(id="id1")),
(Div(Div(id="id2"), id2="id1"), Div(id="id1")),
])
def test_i_can_find(ft, expected):
assert find(expected, expected) == [expected]
def test_find_element_by_id_in_a_list():
a = Div(id="id1")
b = Div(id="id2")
c = Div(id="id3")
assert find([a, b, c], b) == [b]
def test_i_can_find_sub_element():
a = Div(id="id1")
b = Div(a, id="id2")
c = Div(b, id="id3")
assert find(c, a) == [a]
@pytest.mark.parametrize('ft, expected', [
(None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")),
(Div(id2="id1"), Div(id="id1")),
(Div(id="id2"), Div(id="id1")),
])
def test_i_cannot_find(ft, expected):
with pytest.raises(AssertionError):
find(expected, ft)

View File

@@ -3,440 +3,361 @@ from fastcore.basics import NotStr
from fasthtml.components import *
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
class Dummy:
def __init__(self, attr1, attr2=None):
self.attr1 = attr1
self.attr2 = attr2
@pytest.mark.parametrize('actual, expected', [
(None, None),
(123, 123),
(Div(), Div()),
([Div(), Span()], [Div(), Span()]),
(Div(attr1="value"), Div(attr1="value")),
(Div(attr1="value", attr2="value"), Div(attr1="value")),
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
])
def test_i_can_match(actual, expected):
assert matches(actual, expected)
class Dummy2:
def __init__(self, attr1, attr2):
self.attr1 = attr1
self.attr2 = attr2
@pytest.mark.parametrize('actual, expected, error_message', [
(None, Div(), "Actual is None"),
(Div(), None, "Actual is not None"),
(123, Div(), "The types are different"),
(123, 124, "The values are different"),
([Div(), Span()], [], "Actual is bigger than expected"),
([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
(Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "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"),
])
def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
assert error_message in str(exc_info.value)
class TestMatches:
@pytest.mark.parametrize('element, expected_path', [
(Div(), "Path : 'div"),
(Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path : '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(element, expected_path):
def _construct_test_element(source, tail):
res = MyFT(source.tag, source.attrs)
if source.children:
res.children = [_construct_test_element(child, tail) for child in source.children]
else:
res.children = [tail]
return res
@pytest.mark.parametrize('actual, expected', [
(None, None),
(123, 123),
(Div(), Div()),
([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", attr2="value"), Div(attr1="value")),
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
(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)
with pytest.raises(AssertionError) as exc_info:
actual = _construct_test_element(element, "Actual")
expected = _construct_test_element(element, "Expected")
matches(actual, expected)
@pytest.mark.parametrize('actual, expected, error_message', [
(None, Div(), "Actual is None"),
(Div(), None, "Actual is not None"),
(123, Div(), "The types are different"),
(123, 124, "The values are different"),
([Div(), Span()], [], "Actual is bigger than expected"),
([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
(Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "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(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"),
(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:
matches(actual, expected)
assert error_message in str(exc_info.value)
@pytest.mark.parametrize('element, expected_path', [
(Div(), "Path : 'div"),
(Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path : '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 _construct_test_element(source, tail):
res = MyFT(source.tag, source.attrs)
if source.children:
res.children = [_construct_test_element(child, tail) for child in source.children]
else:
res.children = [tail]
return res
with pytest.raises(AssertionError) as exc_info:
actual = _construct_test_element(element, "Actual")
expected = _construct_test_element(element, "Expected")
matches(actual, expected)
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(self):
"""The output follows the representation of the given path"""
elt = Div()
expected = Div()
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "id"="div_id" ...',
' (div ...',
' (span "class"="span_class" ...',
' (p "name"="p_name" ...',
' (div )']
def test_i_can_output_error_attribute(self):
elt = Div(attr1="value1", attr2="value2")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
def test_i_can_output_error_attribute_missing_1(self):
elt = Div(attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_attribute_missing_2(self):
elt = Div(attr1="value1")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
' ^^^^^^^^^^^^^^^^^^^^^^^']
def test_i_can_output_error_attribute_wrong_value(self):
elt = Div(attr1="value3", attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_constant(self):
elt = 123
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123']
def test_i_can_output_error_constant_wrong_value(self):
elt = 123
expected = 456
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123',
'^^^']
def test_i_can_output_error_when_predicate(self):
elt = "before value after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before value after"]
def test_i_can_output_error_when_predicate_wrong_value(self):
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before after",
"^^^^^^^^^^^^"]
def test_i_can_output_error_child_element(self):
"""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")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_text(self):
"""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")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children(self):
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1" ...)',
')',
]
def test_i_can_output_error_child_element_wrong_value(self):
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_2")',
' ^^^^^^^^^^^^^^',
')',
]
def test_i_can_output_error_fewer_elements(self):
elt = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' ! ** MISSING ** !',
')',
]
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):
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")',
' ^^^^^^^^^^^^^ '
]
def test_i_can_output_error_path():
elt = Div()
expected = Div()
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "id"="div_id" ...',
' (div ...',
' (span "class"="span_class" ...',
' (p "name"="p_name" ...',
' (div )']
class TestErrorComparisonOutput:
def test_i_can_output_comparison(self):
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
def test_i_can_output_error_attribute():
elt = Div(attr1="value1", attr2="value2")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
def test_i_can_output_error_attribute_missing_1():
elt = Div(attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_attribute_missing_2():
elt = Div(attr1="value1")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
' ^^^^^^^^^^^^^^^^^^^^^^^']
def test_i_can_output_error_attribute_wrong_value():
elt = Div(attr1="value3", attr2="value2")
expected = Div(attr1="value1", attr2="value2")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
' ^^^^^^^^^^^^^^^^ ']
def test_i_can_output_error_constant():
elt = 123
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123']
def test_i_can_output_error_constant_wrong_value():
elt = 123
expected = 456
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['123',
'^^^']
def test_i_can_output_error_when_predicate():
elt = "before value after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before value after"]
def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ["before after",
"^^^^^^^^^^^^"]
def test_i_can_output_error_child_element():
"""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")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_text():
"""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")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
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")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_1" ...)',
')',
]
def test_i_can_output_error_child_element_wrong_value():
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' (div "id"="child_2")',
' ^^^^^^^^^^^^^^',
')',
]
def test_i_can_output_error_fewer_elements():
elt = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' (p "id"="p_id")',
' ! ** MISSING ** !',
')',
]
def test_i_can_output_comparison():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_with_path():
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
def test_i_can_output_comparison_with_path(self):
actual = Div(P(id="p_id"), attr1="value1")
expected = actual
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "id"="div_id" ... | (div "id"="div_id" ...
(span "class"="cls" ... | (span "class"="cls" ...
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_missing_attributes():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), attr2="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
def test_i_can_output_comparison_when_missing_attributes(self):
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(P(id="p_id"), attr2="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr2"="** MISSING **" | (div "attr2"="value1"
^^^^^^^^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_wrong_attributes():
actual = Div(P(id="p_id"), attr1="value2")
expected = Div(P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
def test_i_can_output_comparison_when_wrong_attributes(self):
actual = Div(P(id="p_id"), attr1="value2")
expected = Div(P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value2" | (div "attr1"="value1"
^^^^^^^^^^^^^^^^ |
(p "id"="p_id") | (p "id"="p_id")
) | )'''
def test_i_can_output_comparison_when_fewer_elements():
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
def test_i_can_output_comparison_when_fewer_elements(self):
actual = Div(P(id="p_id"), attr1="value1")
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
actual_out = ErrorOutput("", actual, expected)
expected_out = ErrorOutput("", expected, expected)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
res = comparison_out.render()
assert "\n" + res == '''
(div "attr1"="value1" | (div "attr1"="value1"
(p "id"="p_id") | (span "id"="s_id")
^ ^^^^^^^^^^^ |
! ** MISSING ** ! | (p "id"="p_id")
) | )'''
def test_i_can_see_the_diff_when_matching():
actual = Div(attr1="value1")
expected = Div(attr1=Contains("value2"))
def test_i_can_see_the_diff_when_matching(self):
actual = Div(attr1="value1")
expected = Div(attr1=Contains("value2"))
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
debug_output = str(exc_info.value)
assert "\n" + debug_output == """
with pytest.raises(AssertionError) as exc_info:
matches(actual, expected)
debug_output = str(exc_info.value)
assert "\n" + debug_output == """
Path : 'div'
Error : The condition 'Contains(value2)' is not satisfied.
(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")
^^^ |'''