Compare commits
3 Commits
WorkingOnD
...
AddingTree
| Author | SHA1 | Date | |
|---|---|---|---|
| 659c4e1d4b | |||
| 3271aa0d61 | |||
| a3783b5fb6 |
@@ -1,442 +0,0 @@
|
||||
# Developer Control Mode
|
||||
|
||||
You are now in **Developer Control Mode** - specialized mode for developing UI controls in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Create robust, consistent UI controls by following the established patterns and rules of the project.
|
||||
|
||||
## Control Development Rules (DEV-CONTROL)
|
||||
|
||||
### DEV-CONTROL-01: Class Inheritance
|
||||
|
||||
A control must inherit from one of the three base classes based on its usage:
|
||||
|
||||
| Class | Usage | Example |
|
||||
|-------|-------|---------|
|
||||
| `MultipleInstance` | Multiple instances possible per session | `DataGrid`, `Panel`, `Search` |
|
||||
| `SingleInstance` | One instance per session | `Layout`, `UserProfile`, `CommandsDebugger` |
|
||||
| `UniqueInstance` | One instance, but `__init__` called each time | (special case) |
|
||||
|
||||
```python
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-02: Nested Commands Class
|
||||
|
||||
Each interactive control must define a `Commands` class inheriting from `BaseCommands`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def my_action(self):
|
||||
return Command("MyAction",
|
||||
"Description of the action",
|
||||
self._owner,
|
||||
self._owner.my_action_handler
|
||||
).htmx(target=f"#{self._id}")
|
||||
```
|
||||
|
||||
**Conventions**:
|
||||
- Method name in `snake_case`
|
||||
- First `Command` argument: unique name (PascalCase recommended)
|
||||
- Use `self._owner` to reference the parent control
|
||||
- Use `self._id` for HTMX targets
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-03: State Management with DbObject
|
||||
|
||||
Persistent state must be encapsulated in a class inheriting from `DbObject`:
|
||||
|
||||
```python
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
|
||||
class MyControlState(DbObject):
|
||||
def __init__(self, owner, save_state=True):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
# Persisted attributes
|
||||
self.visible: bool = True
|
||||
self.width: int = 250
|
||||
|
||||
# NOT persisted (ns_ prefix)
|
||||
self.ns_temporary_data = None
|
||||
|
||||
# NOT saved but evaluated (ne_ prefix)
|
||||
self.ne_computed_value = None
|
||||
```
|
||||
|
||||
**Special prefixes**:
|
||||
- `ns_` (no-save): not persisted to database
|
||||
- `ne_` (no-equality): not compared for change detection
|
||||
- `_`: internal variables, ignored
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-04: render() and __ft__() Methods
|
||||
|
||||
Each control must implement:
|
||||
|
||||
```python
|
||||
def render(self):
|
||||
return Div(
|
||||
# Control content
|
||||
id=self._id,
|
||||
cls="mf-my-control"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- `render()` contains the rendering logic
|
||||
- `__ft__()` simply delegates to `render()`
|
||||
- Root element must have `id=self._id`
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-05: Control Initialization
|
||||
|
||||
Standard initialization structure:
|
||||
|
||||
```python
|
||||
def __init__(self, parent, _id=None, **kwargs):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
# 1. State
|
||||
self._state = MyControlState(self)
|
||||
|
||||
# 2. Commands
|
||||
self.commands = Commands(self)
|
||||
|
||||
# 3. Sub-components
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._search = Search(self, _id="-search")
|
||||
|
||||
# 4. Command bindings
|
||||
self._search.bind_command("Search", self.commands.on_search())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-06: Relative IDs for Sub-components
|
||||
|
||||
Use the `-` prefix to create IDs relative to the parent:
|
||||
|
||||
```python
|
||||
# Results in: "{parent_id}-panel"
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
|
||||
# Results in: "{parent_id}-search"
|
||||
self._search = Search(self, _id="-search")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-07: Using the mk Helper Class
|
||||
|
||||
Use `mk` helpers to create interactive elements:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Button with command
|
||||
mk.button("Click me", command=self.commands.my_action())
|
||||
|
||||
# Icon with command and tooltip
|
||||
mk.icon(my_icon, command=self.commands.toggle(), tooltip="Toggle")
|
||||
|
||||
# Label with icon
|
||||
mk.label("Title", icon=my_icon, size="sm")
|
||||
|
||||
# Generic wrapper
|
||||
mk.mk(Input(...), command=self.commands.on_input())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-08: Logging
|
||||
|
||||
Each control must declare a logger with its name:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("MyControl")
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def my_action(self):
|
||||
logger.debug(f"my_action called with {param=}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-09: Command Binding Between Components
|
||||
|
||||
To link a sub-component's actions to the parent control:
|
||||
|
||||
```python
|
||||
# In the parent control
|
||||
self._child = ChildControl(self, _id="-child")
|
||||
self._child.bind_command("ChildAction", self.commands.on_child_action())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-10: Keyboard and Mouse Composition
|
||||
|
||||
For interactive controls, compose `Keyboard` and `Mouse`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-11: Partial Rendering
|
||||
|
||||
For HTMX updates, implement partial rendering methods:
|
||||
|
||||
```python
|
||||
def render_partial(self, fragment="default"):
|
||||
if fragment == "body":
|
||||
return self._mk_body()
|
||||
elif fragment == "header":
|
||||
return self._mk_header()
|
||||
return self._mk_default()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-12: Simple State (Non-Persisted)
|
||||
|
||||
For simple state without DB persistence, use a basic Python class:
|
||||
|
||||
```python
|
||||
class MyControlState:
|
||||
def __init__(self):
|
||||
self.opened = False
|
||||
self.selected = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-13: Dataclasses for Configurations
|
||||
|
||||
Use dataclasses for configurations:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class MyControlConf:
|
||||
title: str = "Default"
|
||||
show_header: bool = True
|
||||
width: Optional[int] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-14: Generated ID Prefixes
|
||||
|
||||
Use short, meaningful prefixes for sub-elements:
|
||||
|
||||
```python
|
||||
f"tb_{self._id}" # table body
|
||||
f"th_{self._id}" # table header
|
||||
f"sn_{self._id}" # sheet name
|
||||
f"fi_{self._id}" # file input
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-15: State Getters
|
||||
|
||||
Expose state via getter methods:
|
||||
|
||||
```python
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def get_selected(self):
|
||||
return self._state.selected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-16: Computed Properties
|
||||
|
||||
Use `@property` for frequent access:
|
||||
|
||||
```python
|
||||
@property
|
||||
def width(self):
|
||||
return self._state.width
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-17: JavaScript Initialization Scripts
|
||||
|
||||
If the control requires JavaScript, include it in the render:
|
||||
|
||||
```python
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Script(f"initMyControl('{self._id}');"),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-18: CSS Classes with Prefix
|
||||
|
||||
Use the `mf-` prefix for custom CSS classes:
|
||||
|
||||
```python
|
||||
cls="mf-my-control"
|
||||
cls="mf-my-control-header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-19: Sub-element Creation Methods
|
||||
|
||||
Prefix creation methods with `_mk_` or `mk_`:
|
||||
|
||||
```python
|
||||
def _mk_header(self):
|
||||
"""Private creation method"""
|
||||
return Div(...)
|
||||
|
||||
def mk_content(self):
|
||||
"""Public creation method (reusable)"""
|
||||
return Div(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Control Template
|
||||
|
||||
```python
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("MyControl")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyControlConf:
|
||||
title: str = "Default"
|
||||
show_header: bool = True
|
||||
|
||||
|
||||
class MyControlState(DbObject):
|
||||
def __init__(self, owner, save_state=True):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
self.visible: bool = True
|
||||
self.ns_temp_data = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle(self):
|
||||
return Command("Toggle",
|
||||
"Toggle visibility",
|
||||
self._owner,
|
||||
self._owner.toggle
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def __init__(self, parent, conf: Optional[MyControlConf] = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or MyControlConf()
|
||||
self._state = MyControlState(self)
|
||||
self.commands = Commands(self)
|
||||
|
||||
logger.debug(f"MyControl created with id={self._id}")
|
||||
|
||||
def toggle(self):
|
||||
self._state.visible = not self._state.visible
|
||||
return self
|
||||
|
||||
def _mk_header(self):
|
||||
return Div(
|
||||
mk.label(self.conf.title),
|
||||
mk.icon(toggle_icon, command=self.commands.toggle()),
|
||||
cls="mf-my-control-header"
|
||||
)
|
||||
|
||||
def _mk_content(self):
|
||||
if not self._state.visible:
|
||||
return None
|
||||
return Div("Content here", cls="mf-my-control-content")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_header() if self.conf.show_header else None,
|
||||
self._mk_content(),
|
||||
Script(f"initMyControl('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-my-control"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
- "Disable DEV-CONTROL-08" (do not apply the logging rule)
|
||||
- "Enable DEV-CONTROL-08" (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 `/developer` to switch to general development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
@@ -237,7 +237,6 @@ For detailed architecture and patterns, refer to CLAUDE.md in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer-control` to switch to control development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/unit-tester` to switch unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -10,6 +10,4 @@ Refer to CLAUDE.md for project-specific architecture and patterns.
|
||||
|
||||
You can switch to specialized modes:
|
||||
- `/developer` - Full development mode with validation workflow
|
||||
- `/developer-control` - Control development mode with DEV-CONTROL rules
|
||||
- `/technical-writer` - User documentation writing mode
|
||||
- `/unit-tester` - Unit testing mode
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
# Technical Writer Mode
|
||||
# Technical Writer Persona
|
||||
|
||||
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
|
||||
You are now acting as a **Technical Writer** specialized in user-facing documentation.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Create comprehensive user documentation by:
|
||||
|
||||
1. Reading the source code to understand the component
|
||||
2. Proposing structure for validation
|
||||
3. Writing documentation following established patterns
|
||||
4. Requesting feedback after completion
|
||||
|
||||
## What You Handle
|
||||
## Your Role
|
||||
|
||||
Focus on creating and improving **user documentation** for the MyFastHtml library:
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
@@ -26,317 +18,47 @@ Create comprehensive user documentation by:
|
||||
- Code comments
|
||||
- CLAUDE.md (handled by developers)
|
||||
|
||||
## Technical Writer Rules (TW)
|
||||
## Documentation Principles
|
||||
|
||||
### TW-1: Standard Documentation Structure
|
||||
**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)
|
||||
|
||||
Every component documentation MUST follow this structure in order:
|
||||
**Structure:**
|
||||
- Start with the problem being solved
|
||||
- Show minimal working example
|
||||
- Explain key concepts
|
||||
- Provide variations and advanced usage
|
||||
- Link to related documentation
|
||||
|
||||
| Section | Purpose | Required |
|
||||
|---------|---------|----------|
|
||||
| **Introduction** | What it is, key features, common use cases | Yes |
|
||||
| **Quick Start** | Minimal working example | Yes |
|
||||
| **Basic Usage** | Visual structure, creation, configuration | Yes |
|
||||
| **Advanced Features** | Complex use cases, customization | If applicable |
|
||||
| **Examples** | 3-4 complete, practical examples | Yes |
|
||||
| **Developer Reference** | Technical details for component developers | Yes |
|
||||
**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)
|
||||
|
||||
**Introduction template:**
|
||||
```markdown
|
||||
## Introduction
|
||||
## Communication Style
|
||||
|
||||
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
|
||||
**Conversations:** French or English (match user's language)
|
||||
**Written documentation:** English only
|
||||
|
||||
**Key features:**
|
||||
## Workflow
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
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
|
||||
|
||||
**Common use cases:**
|
||||
## Style Evolution
|
||||
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
- Use case 3
|
||||
```
|
||||
The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time.
|
||||
|
||||
**Quick Start template:**
|
||||
```markdown
|
||||
## Quick Start
|
||||
## Exiting This Persona
|
||||
|
||||
Here's a minimal example showing [what it does]:
|
||||
|
||||
\`\`\`python
|
||||
[Complete, runnable code]
|
||||
\`\`\`
|
||||
|
||||
This creates a complete [component] with:
|
||||
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
|
||||
**Note:** [Important default behavior or tip]
|
||||
```
|
||||
|
||||
### TW-2: Visual Structure Diagrams
|
||||
|
||||
**Principle:** Include ASCII diagrams to illustrate component structure.
|
||||
|
||||
**Use box-drawing characters:** `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼`
|
||||
|
||||
**Example for a dropdown:**
|
||||
```
|
||||
Closed state:
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
└──────────────┘
|
||||
|
||||
Open state (position="below", align="left"):
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
├──────────────┴─────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Label all important elements
|
||||
- Show different states when relevant (open/closed, visible/hidden)
|
||||
- Keep diagrams simple and focused
|
||||
- Use comments in diagrams when needed
|
||||
|
||||
### TW-3: Component Details Tables
|
||||
|
||||
**Principle:** Use markdown tables to summarize information.
|
||||
|
||||
**Component elements table:**
|
||||
```markdown
|
||||
| Element | Description |
|
||||
|---------------|-----------------------------------------------|
|
||||
| Left panel | Optional collapsible panel (default: visible) |
|
||||
| Main content | Always-visible central content area |
|
||||
```
|
||||
|
||||
**Constructor parameters table:**
|
||||
```markdown
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
| `position` | str | Vertical position: "below"/"above" | `"below"` |
|
||||
```
|
||||
|
||||
**State properties table:**
|
||||
```markdown
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
```
|
||||
|
||||
**CSS classes table:**
|
||||
```markdown
|
||||
| Class | Element |
|
||||
|-----------------------|---------------------------------------|
|
||||
| `mf-dropdown-wrapper` | Container with relative positioning |
|
||||
| `mf-dropdown` | Dropdown content panel |
|
||||
```
|
||||
|
||||
**Commands table:**
|
||||
```markdown
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
| `click()` | Handles click events (toggle or close behavior) |
|
||||
```
|
||||
|
||||
### TW-4: Code Examples Standards
|
||||
|
||||
**All code examples must:**
|
||||
|
||||
1. **Be complete and runnable** - Include all necessary imports
|
||||
2. **Use realistic variable names** - Not `foo`, `bar`, `x`
|
||||
3. **Follow PEP 8** - snake_case, proper indentation
|
||||
4. **Include comments** - Only when clarifying non-obvious logic
|
||||
|
||||
**Standard imports block:**
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.ComponentName import ComponentName
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
```
|
||||
|
||||
**Example with commands:**
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Define action
|
||||
def do_something():
|
||||
return "Result"
|
||||
|
||||
# Create command
|
||||
cmd = Command("action", "Description", do_something)
|
||||
|
||||
# Create component with command
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu", cls="btn"),
|
||||
content=Div(
|
||||
mk.button("Action", command=cmd, cls="btn btn-ghost")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
- Incomplete snippets without imports
|
||||
- Abstract examples without context
|
||||
- `...` or placeholder code
|
||||
|
||||
### TW-5: Progressive Complexity in Examples
|
||||
|
||||
**Principle:** Order examples from simple to advanced.
|
||||
|
||||
**Example naming pattern:**
|
||||
```markdown
|
||||
### Example 1: [Simple Use Case]
|
||||
[Most basic, common usage]
|
||||
|
||||
### Example 2: [Intermediate Use Case]
|
||||
[Common variation or configuration]
|
||||
|
||||
### Example 3: [Advanced Use Case]
|
||||
[Complex scenario or customization]
|
||||
|
||||
### Example 4: [Integration Example]
|
||||
[Combined with other components or commands]
|
||||
```
|
||||
|
||||
**Each example must include:**
|
||||
- Descriptive title
|
||||
- Brief explanation of what it demonstrates
|
||||
- Complete, runnable code
|
||||
- Comments for non-obvious parts
|
||||
|
||||
### TW-6: Developer Reference Section
|
||||
|
||||
**Principle:** Include technical details for developers working on the component.
|
||||
|
||||
**Required subsections:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the [Component] component itself.
|
||||
|
||||
### State
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
|
||||
### Commands
|
||||
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|------------|----------------------------|----------------------|
|
||||
| `toggle()` | Toggles open/closed state | Content tuple |
|
||||
| `render()` | Renders complete component | `Div` |
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
\`\`\`
|
||||
Div(id="{id}")
|
||||
├── Div(cls="wrapper")
|
||||
│ ├── Div(cls="button")
|
||||
│ │ └── [Button content]
|
||||
│ └── Div(id="{id}-content")
|
||||
│ └── [Content]
|
||||
└── Script
|
||||
\`\`\`
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|--------------------------------|
|
||||
| `{id}` | Root container |
|
||||
| `{id}-content` | Content panel |
|
||||
|
||||
**Note:** `{id}` is the instance ID (auto-generated or custom `_id`).
|
||||
|
||||
### Internal Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------|------------------------------------------|
|
||||
| `_mk_content()` | Renders the content panel |
|
||||
```
|
||||
|
||||
### TW-7: Communication Language
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Written documentation**: English only
|
||||
|
||||
**No emojis** in documentation unless explicitly requested.
|
||||
|
||||
### TW-8: Question-Driven Collaboration
|
||||
|
||||
**Ask questions to clarify understanding:**
|
||||
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/3" if multiple questions are needed
|
||||
- Never assume - always clarify ambiguities
|
||||
|
||||
### TW-9: Documentation Workflow
|
||||
|
||||
1. **Receive request** - User specifies component/feature to document
|
||||
2. **Read source code** - Understand implementation thoroughly
|
||||
3. **Propose structure** - Present outline with sections
|
||||
4. **Wait for validation** - Get approval before writing
|
||||
5. **Write documentation** - Follow all TW rules
|
||||
6. **Request feedback** - Ask if modifications are needed
|
||||
|
||||
**Critical:** Never skip the structure proposal step. Always get validation before writing.
|
||||
|
||||
### TW-10: File Location
|
||||
|
||||
Documentation files are created in the `docs/` folder:
|
||||
- Component docs: `docs/ComponentName.md`
|
||||
- Feature docs: `docs/Feature Name.md`
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable TW-2" (do not include ASCII diagrams)
|
||||
- "Enable TW-2" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and component patterns, refer to `CLAUDE.md` in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer` to switch to development mode
|
||||
- Use `/developer-control` to switch to control development mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
To return to normal mode:
|
||||
- Use `/developer` to switch to developer mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -199,610 +199,17 @@ class TestControlRender:
|
||||
|
||||
**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: Required Reading for Control Render Tests
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
|
||||
|
||||
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
|
||||
|
||||
**Mandatory reading:** `docs/testing_rendered_components.md`
|
||||
|
||||
**What you must master:**
|
||||
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
|
||||
- **`find(ft, expected)`** - How to search for elements within an HTML tree
|
||||
- **Predicates** - How to test patterns instead of exact values:
|
||||
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
|
||||
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
|
||||
- **Error messages** - How to read `^^^` markers to understand differences
|
||||
- **Key principle** - Test only what matters, ignore the rest
|
||||
|
||||
**Without this reading, you cannot write correct render tests.**
|
||||
|
||||
---
|
||||
|
||||
### **TEST FILE STRUCTURE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
|
||||
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
|
||||
|
||||
**Why:**
|
||||
- Gives immediate overview of the structure
|
||||
- Facilitates understanding for new contributors
|
||||
- Quickly detects major structural changes
|
||||
- Serves as living documentation of HTML architecture
|
||||
|
||||
**Test format:**
|
||||
```python
|
||||
def test_i_can_render_component_with_no_data(self, component):
|
||||
"""Test that Component renders with correct global structure."""
|
||||
html = component.render()
|
||||
expected = Div(
|
||||
Div(id=f"{component.get_id()}-controller"), # controller
|
||||
Div(id=f"{component.get_id()}-header"), # header
|
||||
Div(id=f"{component.get_id()}-content"), # content
|
||||
id=component.get_id(),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simple test with only IDs of main sections
|
||||
- Inline comments to identify each section
|
||||
- No detailed verification of attributes (classes, content, etc.)
|
||||
- This test must be the first in the `TestComponentRender` class
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.2: Break down complex tests into explicit steps**
|
||||
|
||||
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
|
||||
|
||||
**Why:**
|
||||
- Facilitates debugging (you know exactly which step fails)
|
||||
- Improves test readability
|
||||
- Allows validating structure level by level
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
||||
"""Test that content wrapper shows active tab content."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# Step 1: Validate wrapper global structure
|
||||
expected = Div(
|
||||
Div(), # tab content, tested in step 2
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper",
|
||||
cls=Contains("mf-tab-content-wrapper"),
|
||||
)
|
||||
assert matches(wrapper, expected)
|
||||
|
||||
# Step 2: Extract and validate specific content
|
||||
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
expected = Div(
|
||||
Div("My Content"), # <= actual content
|
||||
cls=Contains("mf-tab-content"),
|
||||
)
|
||||
assert matches(tab_content, expected)
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Step 1: Global structure with empty `Div()` + comment for children tested after
|
||||
- Step 2+: Extraction with `find_one()` + detailed validation
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.3: Three-step pattern for simple tests**
|
||||
|
||||
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
|
||||
|
||||
**The three steps:**
|
||||
1. **Extract the element to test** with `find_one()` or `find()` from the global render
|
||||
2. **Define the expected structure** with `expected = ...`
|
||||
3. **Compare** with `assert matches(element, expected)`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_header_has_two_sides(self, layout):
|
||||
"""Test that there is a left and right header section."""
|
||||
# Step 1: Extract the element to test
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Step 2: Define the expected structure
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(header, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SEARCH FOR ELEMENTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
|
||||
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
|
||||
|
||||
**Why:** More robust, faster, and targeted (an ID is unique).
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - search by ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
|
||||
**Principle:**
|
||||
- `find_one()`: When you search for a unique element and want to test its complete structure
|
||||
- `find()`: When you search for multiple elements or want to count/verify their presence
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ GOOD - find for counting
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SPECIFY EXPECTED STRUCTURE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
|
||||
**Principle:**
|
||||
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
|
||||
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
|
||||
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ GOOD - Contains for style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ AVOID - exact class test
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ AVOID - exact complete style test
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
|
||||
|
||||
**Difference between the two:**
|
||||
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
|
||||
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
|
||||
3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
|
||||
4. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
|
||||
**The `wrapper` parameter:**
|
||||
|
||||
Different `mk` helpers use different wrappers for icons:
|
||||
|
||||
| Helper method | Wrapper element | TestIcon usage |
|
||||
|---------------|-----------------|----------------|
|
||||
| `mk.icon(my_icon)` | `<div>` | `TestIcon("name")` |
|
||||
| `mk.label("Text", icon=my_icon)` | `<span>` | `TestIcon("name", wrapper="span")` |
|
||||
| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
|
||||
|
||||
**The `name` parameter:**
|
||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
||||
- **`name=""`** (empty string): Validates **any icon**
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Example 1: Icon via mk.icon() - wrapper is Div (default)
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><svg .../></div>
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: Icon via mk.label() - wrapper is Span
|
||||
# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
|
||||
# Rendered: <label><span><svg .../></span><span>Back</span></label>
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
|
||||
|
||||
# Example 3: Direct icon (used without helper)
|
||||
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||
# Rendered: <span><svg .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# Example 4: Verify any wrapped icon
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
cls=Contains("icon-wrapper")
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`:
|
||||
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
|
||||
2. Check if there's no wrapper at all → try `TestIconNotStr()`
|
||||
3. The error message will show you the actual structure
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ AVOID - testing all script content
|
||||
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
|
||||
|
||||
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
|
||||
|
||||
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
# ❌ FAILS - Form() has default enctype that may not exist in actual form
|
||||
form = find_one(details, Form()) # AssertionError: Found 0 elements
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
# ✅ WORKS - Remove the default enctype attribute
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
form = find_one(details, expected_form)
|
||||
```
|
||||
|
||||
**Alternative - Search with specific attribute:**
|
||||
```python
|
||||
# ✅ ALSO WORKS - Search by a known attribute
|
||||
form = find_one(details, Form(cls=Contains("my-form-class")))
|
||||
# But still need to delete enctype if Form() is used as pattern
|
||||
```
|
||||
|
||||
**Complete example:**
|
||||
```python
|
||||
def test_column_details_contains_form(self, component):
|
||||
"""Test that column details contains a form with required fields."""
|
||||
details = component.mk_column_details(col_def)
|
||||
|
||||
# Create Form pattern and remove default enctype
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
|
||||
form = find_one(details, expected_form)
|
||||
assert form is not None
|
||||
|
||||
# Now search within the found form
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
```
|
||||
|
||||
**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
|
||||
Why these elements matter:
|
||||
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
|
||||
- _id: Essential for layout identification and resizer initialization
|
||||
- cls="mf-layout": Root CSS class for layout styling
|
||||
"""
|
||||
expected = Div(...)
|
||||
assert matches(layout.render(), expected)
|
||||
|
||||
def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
"""Test that left drawer renders with correct classes when open.
|
||||
|
||||
Why these elements matter:
|
||||
- _id: Required for targeting drawer in HTMX updates
|
||||
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
|
||||
- style Contains width: Drawer width must be applied for sizing
|
||||
"""
|
||||
layout._state.left_drawer_open = True
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
expected = Div(
|
||||
_id=f"{layout._id}_ld",
|
||||
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
|
||||
style=Contains("width: 250px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
|
||||
- No need to follow rigid wording
|
||||
- What matters is the **justification of the choice**, not the format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - explanatory message
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
# ❌ AVOID - no message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **OTHER IMPORTANT RULES**
|
||||
|
||||
---
|
||||
|
||||
**Mandatory render test rules:**
|
||||
|
||||
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
|
||||
|
||||
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||
- First line: Brief description of what is being tested
|
||||
- Blank line
|
||||
- Justification section explaining why tested elements matter (see UTR-11.10)
|
||||
- List of important elements/attributes being tested with explanations (in English)
|
||||
|
||||
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||
|
||||
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
|
||||
|
||||
5. **Test organization for Controls**: Organize tests into thematic classes:
|
||||
- `TestControlBehaviour`: Tests for control behavior and logic
|
||||
- `TestControlRender`: Tests for control HTML rendering
|
||||
|
||||
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
|
||||
```python
|
||||
class TestControlRender:
|
||||
@pytest.fixture
|
||||
def layout(self, root_instance):
|
||||
return Layout(root_instance, app_name="Test App")
|
||||
|
||||
def test_something(self, layout):
|
||||
# layout is injected automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
|
||||
**Test file structure**
|
||||
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
|
||||
- **UTR-11.2**: Break down complex tests into numbered steps
|
||||
- **UTR-11.3**: Three-step pattern for simple tests
|
||||
|
||||
**How to search**
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
|
||||
**How to specify**
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||
|
||||
**How to document**
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
---
|
||||
|
||||
**When proposing render tests:**
|
||||
- Reference specific patterns from the documentation
|
||||
- Explain why you chose to test certain elements and not others
|
||||
- Justify the use of predicates vs exact values
|
||||
- Always include justification documentation (see UTR-11.10)
|
||||
|
||||
---
|
||||
|
||||
### UTR-12: Analyze Execution Flow Before Writing Tests
|
||||
|
||||
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
|
||||
|
||||
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Test: "content_is_cached_after_first_retrieval"
|
||||
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
|
||||
Conclusion: Cache is already filled after create_tab, test would be redundant
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Identify the method being tested
|
||||
2. Trace all method calls it makes
|
||||
3. Identify state changes at each step
|
||||
4. Verify your assumptions about what the test should validate
|
||||
5. Only then write the test
|
||||
|
||||
---
|
||||
|
||||
### UTR-13: Prefer matches() for Content Verification
|
||||
|
||||
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
|
||||
|
||||
**Why:** More robust, clearer error messages, consistent with render test patterns.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ FRAGILE - string matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert "Tab not found" in str(result)
|
||||
|
||||
# ✅ ROBUST - structural matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert matches(result, Div('Tab not found.'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-14: Know FastHTML Attribute Names
|
||||
|
||||
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
|
||||
|
||||
**Key differences:**
|
||||
- Use `attrs.get('class')` not `attrs.get('cls')`
|
||||
- Use `attrs.get('id')` for the ID
|
||||
- Prefer `matches()` with predicates to avoid direct attribute access
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ WRONG - Python parameter name
|
||||
classes = element.attrs.get('cls', '') # Returns None or ''
|
||||
|
||||
# ✅ CORRECT - HTML attribute name
|
||||
classes = element.attrs.get('class', '') # Returns actual classes
|
||||
|
||||
# ✅ BETTER - Use predicates with matches()
|
||||
expected = Div(cls=Contains("active"))
|
||||
assert matches(element, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-15: Test Workflow
|
||||
### 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. **Trace execution flow** - Apply UTR-12 to understand side effects
|
||||
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
6. **Propose test plan** - List new/missing tests with brief explanations
|
||||
7. **Wait for approval** - User validates the test plan
|
||||
8. **Implement tests** - Write all approved tests
|
||||
9. **Verify** - Ensure tests follow naming conventions and structure
|
||||
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
---
|
||||
|
||||
### UTR-16: Propose Parameterized Tests
|
||||
|
||||
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
|
||||
|
||||
**When to parameterize:**
|
||||
- Tests that follow the same pattern with different input values
|
||||
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
|
||||
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
|
||||
- Tests that validate the same method with different valid inputs
|
||||
|
||||
**How to identify candidates:**
|
||||
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
|
||||
2. Look for tests that have identical structure but different parameters
|
||||
3. Look for combinatorial scenarios (side × state combinations)
|
||||
|
||||
**How to propose:**
|
||||
In your test plan, explicitly show:
|
||||
1. The individual tests that would be written without parameterization
|
||||
2. The parameterized version with all test cases
|
||||
3. The reduction in test count
|
||||
|
||||
**Example proposal:**
|
||||
|
||||
```
|
||||
**Without parameterization (4 tests):**
|
||||
- test_i_can_toggle_left_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_left_panel_from_hidden_to_visible
|
||||
- test_i_can_toggle_right_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_right_panel_from_hidden_to_visible
|
||||
|
||||
**With parameterization (1 test, 4 cases):**
|
||||
@pytest.mark.parametrize("side, initial, expected", [
|
||||
("left", True, False),
|
||||
("left", False, True),
|
||||
("right", True, False),
|
||||
("right", False, True),
|
||||
])
|
||||
def test_i_can_toggle_panel_visibility(...)
|
||||
|
||||
**Result:** 1 test instead of 4, same coverage
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Reduces code duplication
|
||||
- Makes it easier to add new test cases
|
||||
- Improves maintainability
|
||||
- Makes the test matrix explicit
|
||||
|
||||
---
|
||||
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
|
||||
|
||||
@@ -819,6 +226,5 @@ For detailed architecture and testing patterns, refer to CLAUDE.md in the projec
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer` to switch to development mode
|
||||
- Use `/developer-control` to switch to control development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
CLAUDE.md
13
CLAUDE.md
@@ -108,17 +108,6 @@ Activates the full development workflow with:
|
||||
- Strict PEP 8 compliance
|
||||
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
|
||||
|
||||
### `/developer-control` - Control Development Mode
|
||||
**Use for:** Developing UI controls in the controls directory
|
||||
|
||||
Specialized mode with rules for:
|
||||
- Control class inheritance (`MultipleInstance`, `SingleInstance`, `UniqueInstance`)
|
||||
- Commands class pattern with `BaseCommands`
|
||||
- State management with `DbObject`
|
||||
- Rendering with `render()` and `__ft__()`
|
||||
- Helper usage (`mk.button`, `mk.icon`, `mk.label`)
|
||||
- Sub-component composition
|
||||
|
||||
### `/technical-writer` - Documentation Mode
|
||||
**Use for:** Writing user-facing documentation
|
||||
|
||||
@@ -184,7 +173,7 @@ pip install -e .
|
||||
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||
|
||||
**Key classes:**
|
||||
- `Command`: Base class for all commands with HTMX integration
|
||||
- `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
|
||||
|
||||
7
Makefile
7
Makefile
@@ -20,13 +20,10 @@ clean-tests:
|
||||
rm -rf tests/*.db
|
||||
rm -rf tests/.myFastHtmlDb
|
||||
|
||||
clean-app:
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
# Alias to clean everything
|
||||
clean: clean-build clean-tests clean-app
|
||||
clean: clean-build clean-tests
|
||||
|
||||
clean-all : clean
|
||||
rm -rf src/.sesskey
|
||||
rm -rf src/Users.db
|
||||
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
@@ -53,7 +53,7 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
|
||||
```
|
||||
@@ -86,7 +86,7 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
```
|
||||
|
||||
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DataGrid Performance Profiling Script
|
||||
|
||||
Generates a 1000-row DataFrame and profiles the DataGrid.render() method
|
||||
to identify performance bottlenecks.
|
||||
|
||||
Usage:
|
||||
python benchmarks/profile_datagrid.py
|
||||
"""
|
||||
|
||||
import cProfile
|
||||
import pstats
|
||||
from io import StringIO
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
|
||||
|
||||
def generate_test_dataframe(rows=1000, cols=10):
|
||||
"""Generate a test DataFrame with mixed column types."""
|
||||
np.random.seed(42)
|
||||
|
||||
data = {
|
||||
'ID': range(rows),
|
||||
'Name': [f'Person_{i}' for i in range(rows)],
|
||||
'Email': [f'user{i}@example.com' for i in range(rows)],
|
||||
'Age': np.random.randint(18, 80, rows),
|
||||
'Salary': np.random.uniform(30000, 150000, rows),
|
||||
'Active': np.random.choice([True, False], rows),
|
||||
'Score': np.random.uniform(0, 100, rows),
|
||||
'Department': np.random.choice(['Sales', 'Engineering', 'Marketing', 'HR'], rows),
|
||||
'Country': np.random.choice(['France', 'USA', 'Germany', 'UK', 'Spain'], rows),
|
||||
'Rating': np.random.uniform(1.0, 5.0, rows),
|
||||
}
|
||||
|
||||
# Add extra columns if needed
|
||||
for i in range(cols - len(data)):
|
||||
data[f'Extra_Col_{i}'] = np.random.random(rows)
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
def profile_datagrid_render(df):
|
||||
"""Profile the DataGrid render method."""
|
||||
|
||||
# Clear instances to start fresh
|
||||
InstancesManager.instances.clear()
|
||||
|
||||
# Create a minimal session
|
||||
session = {
|
||||
"user_info": {
|
||||
"id": "test_tenant_id",
|
||||
"email": "test@email.com",
|
||||
"username": "test user",
|
||||
"role": [],
|
||||
}
|
||||
}
|
||||
|
||||
# Create root instance as parent
|
||||
root = SingleInstance(parent=None, session=session, _id="profile-root")
|
||||
|
||||
# Create DataGrid (parent, settings, save_state, _id)
|
||||
datagrid = DataGrid(root)
|
||||
datagrid.init_from_dataframe(df)
|
||||
|
||||
# Profile the render call
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
|
||||
# Execute render
|
||||
html_output = datagrid.render()
|
||||
|
||||
profiler.disable()
|
||||
|
||||
return profiler, html_output
|
||||
|
||||
|
||||
def print_profile_stats(profiler, top_n=30):
|
||||
"""Print formatted profiling statistics."""
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("PROFILING RESULTS - Top {} functions by cumulative time".format(top_n))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
stats.sort_stats('cumulative')
|
||||
stats.print_stats(top_n)
|
||||
|
||||
output = s.getvalue()
|
||||
print(output)
|
||||
|
||||
# Extract total time
|
||||
for line in output.split('\n'):
|
||||
if 'function calls' in line:
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(line)
|
||||
break
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Top 10 by total time spent (time * ncalls)")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
stats.sort_stats('tottime')
|
||||
stats.print_stats(10)
|
||||
print(s.getvalue())
|
||||
|
||||
|
||||
def main():
|
||||
print("Generating test DataFrame (1000 rows × 10 columns)...")
|
||||
df = generate_test_dataframe(rows=1000, cols=10)
|
||||
print(f"DataFrame shape: {df.shape}")
|
||||
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024:.2f} KB\n")
|
||||
|
||||
print("Profiling DataGrid.render()...")
|
||||
profiler, html_output = profile_datagrid_render(df)
|
||||
|
||||
print(f"\nHTML output length: {len(str(html_output))} characters")
|
||||
|
||||
print_profile_stats(profiler, top_n=30)
|
||||
|
||||
# Clean up instances
|
||||
InstancesManager.reset()
|
||||
|
||||
print("\n✅ Profiling complete!")
|
||||
print("\nNext steps:")
|
||||
print("1. Identify the slowest functions in the 'cumulative time' section")
|
||||
print("2. Look for functions called many times (high ncalls)")
|
||||
print("3. Focus optimization on high cumtime + high ncalls functions")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,540 +0,0 @@
|
||||
# DataGrid Formatting
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| **Core Module** | | `src/myfasthtml/core/formatting/` |
|
||||
| Dataclasses (Condition, Style, Formatter, FormatRule) | :white_check_mark: Implemented | `dataclasses.py` |
|
||||
| Style Presets (DaisyUI 5) | :white_check_mark: Implemented | `presets.py` |
|
||||
| Formatter Presets (EUR, USD, etc.) | :white_check_mark: Implemented | `presets.py` |
|
||||
| ConditionEvaluator (12 operators) | :white_check_mark: Implemented | `condition_evaluator.py` |
|
||||
| StyleResolver | :white_check_mark: Implemented | `style_resolver.py` |
|
||||
| FormatterResolver (Number, Date, Boolean, Text, Enum) | :white_check_mark: Implemented | `formatter_resolver.py` |
|
||||
| FormattingEngine (facade + conflict resolution) | :white_check_mark: Implemented | `engine.py` |
|
||||
| **Condition Features** | | |
|
||||
| `col` parameter (row-level conditions) | :white_check_mark: Implemented | |
|
||||
| `row` parameter (column-level conditions) | :x: Not implemented | |
|
||||
| Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | |
|
||||
| **DataGrid Integration** | | |
|
||||
| Integration in `mk_body_cell_content()` | :x: Not implemented | |
|
||||
| DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` |
|
||||
| **Tests** | | `tests/core/formatting/` |
|
||||
| test_condition_evaluator.py | :white_check_mark: ~45 test cases | |
|
||||
| test_style_resolver.py | :white_check_mark: ~12 test cases | |
|
||||
| test_formatter_resolver.py | :white_check_mark: ~40 test cases | |
|
||||
| test_engine.py | :white_check_mark: ~18 test cases | |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the formatting capabilities for the DataGrid component.
|
||||
|
||||
**Formatting applies at three levels:**
|
||||
|
||||
| Level | Cells Targeted | Condition Evaluated On |
|
||||
|------------|-------------------------|----------------------------------------------|
|
||||
| **Cell** | 1 specific cell | The cell value |
|
||||
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) |
|
||||
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) |
|
||||
|
||||
---
|
||||
|
||||
## Format Rule Structure
|
||||
|
||||
A format is a **list** of rules. Each rule is an object:
|
||||
|
||||
```json
|
||||
{
|
||||
"condition": {},
|
||||
"style": {},
|
||||
"formatter": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- `style` and `formatter` can appear alone (unconditional formatting)
|
||||
- `condition` **cannot** appear alone - must be paired with `style` and/or `formatter`
|
||||
- If `condition` is present, the `style`/`formatter` is applied **only if** the condition is met
|
||||
- Rules are evaluated in order; multiple rules can match
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When multiple rules match the same cell:
|
||||
|
||||
1. **Specificity** = number of conditions in the rule
|
||||
2. **Higher specificity wins**
|
||||
3. **At equal specificity, last rule wins entirely** (no fusion)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"style": {
|
||||
"color": "gray"
|
||||
}
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"operator": "<",
|
||||
"value": 0
|
||||
},
|
||||
"style": {
|
||||
"color": "red"
|
||||
}
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"operator": "==",
|
||||
"value": -5
|
||||
},
|
||||
"style": {
|
||||
"color": "black"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
For `value = -5`: Rule 3 wins (same specificity as rule 2, but defined later).
|
||||
|
||||
---
|
||||
|
||||
## Condition Structure
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Default | Required | Description | Status |
|
||||
|------------------|------------------------|---------|----------|---------------------------------------------|--------|
|
||||
| `operator` | string | - | Yes | Comparison operator | :white_check_mark: |
|
||||
| `value` | scalar / list / object | - | Depends | Value to compare against | :white_check_mark: |
|
||||
| `not` | bool | `false` | No | Inverts the condition result | :white_check_mark: (as `negate`) |
|
||||
| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison | :white_check_mark: |
|
||||
| `col` | string | - | No | Reference column (for row-level conditions) | :white_check_mark: |
|
||||
| `row` | int | - | No | Reference row (for column-level conditions) | :x: Not implemented |
|
||||
|
||||
### Operators
|
||||
|
||||
All operators are :white_check_mark: **implemented**.
|
||||
|
||||
| Operator | Description | Value Required |
|
||||
|--------------|--------------------------|------------------|
|
||||
| `==` | Equal | Yes |
|
||||
| `!=` | Not equal | Yes |
|
||||
| `<` | Less than | Yes |
|
||||
| `<=` | Less than or equal | Yes |
|
||||
| `>` | Greater than | Yes |
|
||||
| `>=` | Greater than or equal | Yes |
|
||||
| `contains` | String contains | Yes |
|
||||
| `startswith` | String starts with | Yes |
|
||||
| `endswith` | String ends with | Yes |
|
||||
| `in` | Value in list | Yes (list) |
|
||||
| `between` | Value between two values | Yes ([min, max]) |
|
||||
| `isempty` | Value is empty/null | No |
|
||||
| `isnotempty` | Value is not empty/null | No |
|
||||
|
||||
### Value Types
|
||||
|
||||
**Literal value:**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "<",
|
||||
"value": 0
|
||||
}
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"A",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Cell reference (compare with another column):**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": ">",
|
||||
"value": {
|
||||
"col": "budget"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Negation
|
||||
|
||||
Use the `not` flag instead of separate operators:
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"not": true
|
||||
}
|
||||
{
|
||||
"operator": "contains",
|
||||
"value": "error",
|
||||
"not": true
|
||||
}
|
||||
```
|
||||
|
||||
### Case Sensitivity
|
||||
|
||||
String comparisons are **case-insensitive by default**.
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "==",
|
||||
"value": "Error",
|
||||
"case_sensitive": true
|
||||
}
|
||||
```
|
||||
|
||||
### Evaluation Behavior
|
||||
|
||||
| Situation | Behavior |
|
||||
|---------------------------|-----------------------------------|
|
||||
| Cell value is `null` | Condition = `false` |
|
||||
| Referenced cell is `null` | Condition = `false` |
|
||||
| Type mismatch | Condition = `false` (no coercion) |
|
||||
| String operators | Converts value to string first |
|
||||
|
||||
### Examples
|
||||
|
||||
```json
|
||||
// Row-level: highlight if "status" column == "error"
|
||||
{
|
||||
"col": "status",
|
||||
"operator": "==",
|
||||
"value": "error"
|
||||
}
|
||||
|
||||
// Column-level: bold if row 0 has value "Total"
|
||||
{
|
||||
"row": 0,
|
||||
"operator": "==",
|
||||
"value": "Total"
|
||||
}
|
||||
|
||||
// Compare with another column
|
||||
{
|
||||
"operator": ">",
|
||||
"value": {
|
||||
"col": "budget"
|
||||
}
|
||||
}
|
||||
|
||||
// Negated condition
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"draft",
|
||||
"pending"
|
||||
],
|
||||
"not": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Structure
|
||||
|
||||
:white_check_mark: **Fully implemented** in `style_resolver.py`
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|--------------------|--------|------------|---------------------------------------------------|
|
||||
| `preset` | string | - | Preset name (applied first, can be overridden) |
|
||||
| `background_color` | string | - | Background color (hex, CSS name, or CSS variable) |
|
||||
| `color` | string | - | Text color |
|
||||
| `font_weight` | string | `"normal"` | `"normal"` or `"bold"` |
|
||||
| `font_style` | string | `"normal"` | `"normal"` or `"italic"` |
|
||||
| `font_size` | string | - | Font size (`"12px"`, `"0.9em"`) |
|
||||
| `text_decoration` | string | `"none"` | `"none"`, `"underline"`, `"line-through"` |
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"style": {
|
||||
"preset": "success",
|
||||
"font_weight": "bold"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Default Presets (DaisyUI 5)
|
||||
|
||||
| Preset | Background | Text |
|
||||
|-------------|--------------------------|----------------------------------|
|
||||
| `primary` | `var(--color-primary)` | `var(--color-primary-content)` |
|
||||
| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` |
|
||||
| `accent` | `var(--color-accent)` | `var(--color-accent-content)` |
|
||||
| `neutral` | `var(--color-neutral)` | `var(--color-neutral-content)` |
|
||||
| `info` | `var(--color-info)` | `var(--color-info-content)` |
|
||||
| `success` | `var(--color-success)` | `var(--color-success-content)` |
|
||||
| `warning` | `var(--color-warning)` | `var(--color-warning-content)` |
|
||||
| `error` | `var(--color-error)` | `var(--color-error-content)` |
|
||||
|
||||
All presets default to `font_weight: "normal"`, `font_style: "normal"`, `text_decoration: "none"`.
|
||||
|
||||
### Resolution Logic
|
||||
|
||||
1. If `preset` is specified, apply all preset properties
|
||||
2. Override with any explicit properties
|
||||
|
||||
**No style fusion:** When multiple rules match, the winning rule's style applies entirely.
|
||||
|
||||
---
|
||||
|
||||
## Formatter Structure
|
||||
|
||||
Formatters transform cell values for display without changing the underlying data.
|
||||
|
||||
### Usage
|
||||
|
||||
```json
|
||||
{
|
||||
"formatter": {
|
||||
"preset": "EUR"
|
||||
}
|
||||
}
|
||||
{
|
||||
"formatter": {
|
||||
"preset": "EUR",
|
||||
"precision": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
If formatting fails (e.g., non-numeric value for `number` formatter), display `"⚠"`.
|
||||
|
||||
---
|
||||
|
||||
## Formatter Types
|
||||
|
||||
All formatter types are :white_check_mark: **implemented** in `formatter_resolver.py`.
|
||||
|
||||
### `number`
|
||||
|
||||
For numbers, currencies, and percentages.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|-----------------|--------|---------|-------------------------------|
|
||||
| `prefix` | string | `""` | Text before value |
|
||||
| `suffix` | string | `""` | Text after value |
|
||||
| `thousands_sep` | string | `""` | Thousands separator |
|
||||
| `decimal_sep` | string | `"."` | Decimal separator |
|
||||
| `precision` | int | `0` | Number of decimal places |
|
||||
| `multiplier` | number | `1` | Multiply value before display |
|
||||
|
||||
### `date`
|
||||
|
||||
For dates and datetimes.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|--------|--------------|-------------------------|
|
||||
| `format` | string | `"%Y-%m-%d"` | strftime format pattern |
|
||||
|
||||
### `boolean`
|
||||
|
||||
For true/false values.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---------------|--------|-----------|-------------------|
|
||||
| `true_value` | string | `"true"` | Display for true |
|
||||
| `false_value` | string | `"false"` | Display for false |
|
||||
| `null_value` | string | `""` | Display for null |
|
||||
|
||||
### `text`
|
||||
|
||||
For text transformations.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|--------------|--------|---------|----------------------------------------------|
|
||||
| `transform` | string | - | `"uppercase"`, `"lowercase"`, `"capitalize"` |
|
||||
| `max_length` | int | - | Truncate if exceeded |
|
||||
| `ellipsis` | string | `"..."` | Suffix when truncated |
|
||||
|
||||
### `enum`
|
||||
|
||||
For mapping values to display labels. Also used for Select dropdowns.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---------------|--------|------------------|------------------------------------|
|
||||
| `source` | object | - | Data source (see below) |
|
||||
| `default` | string | `""` | Label for unknown values |
|
||||
| `allow_empty` | bool | `true` | Show empty option in Select |
|
||||
| `empty_label` | string | `"-- Select --"` | Label for empty option |
|
||||
| `order_by` | string | `"source"` | `"source"`, `"display"`, `"value"` |
|
||||
|
||||
#### Source Types
|
||||
|
||||
**Static mapping:** :white_check_mark: Implemented
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "enum",
|
||||
"source": {
|
||||
"type": "mapping",
|
||||
"value": {
|
||||
"draft": "Brouillon",
|
||||
"pending": "En attente",
|
||||
"approved": "Approuvé"
|
||||
}
|
||||
},
|
||||
"default": "Inconnu"
|
||||
}
|
||||
```
|
||||
|
||||
**From another DataGrid:** :white_check_mark: Implemented (requires `lookup_resolver` injection)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "enum",
|
||||
"source": {
|
||||
"type": "datagrid",
|
||||
"value": "categories_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Empty Value Behavior
|
||||
|
||||
- `allow_empty: true` → Empty option displayed with `empty_label`
|
||||
- `allow_empty: false` → First entry selected by default
|
||||
|
||||
---
|
||||
|
||||
## Default Formatter Presets
|
||||
|
||||
```python
|
||||
formatter_presets = {
|
||||
"EUR": {
|
||||
"type": "number",
|
||||
"suffix": " €",
|
||||
"thousands_sep": " ",
|
||||
"decimal_sep": ",",
|
||||
"precision": 2
|
||||
},
|
||||
"USD": {
|
||||
"type": "number",
|
||||
"prefix": "$",
|
||||
"thousands_sep": ",",
|
||||
"decimal_sep": ".",
|
||||
"precision": 2
|
||||
},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"suffix": "%",
|
||||
"precision": 1,
|
||||
"multiplier": 100
|
||||
},
|
||||
"short_date": {
|
||||
"type": "date",
|
||||
"format": "%d/%m/%Y"
|
||||
},
|
||||
"iso_date": {
|
||||
"type": "date",
|
||||
"format": "%Y-%m-%d"
|
||||
},
|
||||
"yes_no": {
|
||||
"type": "boolean",
|
||||
"true_value": "Yes",
|
||||
"false_value": "No"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
:warning: **Structures exist but integration with formatting engine not implemented**
|
||||
|
||||
### Format Storage Location
|
||||
|
||||
| Level | Storage | Key | Status |
|
||||
|------------|------------------------------|---------|--------|
|
||||
| **Column** | `DataGridColumnState.format` | - | Structure exists |
|
||||
| **Row** | `DataGridRowState.format` | - | Structure exists |
|
||||
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
|
||||
|
||||
### Cell ID Format
|
||||
|
||||
```
|
||||
tcell_{datagrid_id}-{row_index}-{col_index}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataGridsManager
|
||||
|
||||
:white_check_mark: **Implemented** in `src/myfasthtml/controls/DataGridsManager.py`
|
||||
|
||||
Global presets stored as instance attributes:
|
||||
|
||||
| Property | Type | Description | Status |
|
||||
|---------------------|--------|-------------------------------------------|--------|
|
||||
| `style_presets` | dict | Style presets (primary, success, etc.) | :white_check_mark: |
|
||||
| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) | :white_check_mark: |
|
||||
| `default_locale` | string | Default locale for number/date formatting | :x: Not implemented |
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `get_style_presets()` | Get the global style presets |
|
||||
| `get_formatter_presets()` | Get the global formatter presets |
|
||||
| `add_style_preset(name, preset)` | Add or update a style preset |
|
||||
| `add_formatter_preset(name, preset)` | Add or update a formatter preset |
|
||||
| `remove_style_preset(name)` | Remove a style preset |
|
||||
| `remove_formatter_preset(name)` | Remove a formatter preset |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```python
|
||||
# Add custom presets
|
||||
manager.add_style_preset("highlight", {"background-color": "yellow", "color": "black"})
|
||||
manager.add_formatter_preset("CHF", {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
All items below are :x: **not implemented**.
|
||||
|
||||
- **`row` parameter for column-level conditions**: Evaluate condition on a specific row
|
||||
- **AND/OR conditions**: Add explicit `and`/`or` operators if `between`/`in` prove insufficient
|
||||
- **Cell references**: Extend to `{"col": "x", "row": 0}` for specific cell and `{"col": "x", "row_offset": -1}` for
|
||||
relative references
|
||||
- **Enum cascade (draft)**: Dependent dropdowns with `depends_on` and `filter_column`
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"type": "datagrid",
|
||||
"value": "cities_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name",
|
||||
"filter_column": "country_id"
|
||||
},
|
||||
"depends_on": "country"
|
||||
}
|
||||
```
|
||||
- **API source for enum**: `{"type": "api", "value": "https://...", ...}`
|
||||
- **Searchable enum**: For large option lists
|
||||
- **Formatter chaining**: Apply multiple formatters in sequence
|
||||
- **DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()`
|
||||
601
docs/DataGrid.md
601
docs/DataGrid.md
@@ -1,601 +0,0 @@
|
||||
# DataGrid Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The DataGrid component provides a high-performance tabular data display for your FastHTML application. It renders pandas
|
||||
DataFrames with interactive features like column resizing, reordering, and filtering, all powered by HTMX for seamless
|
||||
updates without page reloads.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Display tabular data from pandas DataFrames
|
||||
- Resizable columns with drag handles
|
||||
- Draggable columns for reordering
|
||||
- Real-time filtering with search bar
|
||||
- Virtual scrolling for large datasets (pagination with lazy loading)
|
||||
- Custom scrollbars for consistent cross-browser appearance
|
||||
- Optional state persistence per session
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Data exploration and analysis dashboards
|
||||
- Admin interfaces with tabular data
|
||||
- Report viewers
|
||||
- Database table browsers
|
||||
- CSV/Excel file viewers
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a data table with a pandas DataFrame:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create sample data
|
||||
df = pd.DataFrame({
|
||||
"Name": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"Age": [25, 30, 35, 28],
|
||||
"City": ["Paris", "London", "Berlin", "Madrid"]
|
||||
})
|
||||
|
||||
# Create root instance and data grid
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root)
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
# Render the grid
|
||||
return grid
|
||||
```
|
||||
|
||||
This creates a complete data grid with:
|
||||
|
||||
- A header row with column names ("Name", "Age", "City")
|
||||
- Data rows displaying the DataFrame content
|
||||
- A search bar for filtering data
|
||||
- Resizable column borders (drag to resize)
|
||||
- Draggable columns (drag headers to reorder)
|
||||
- Custom scrollbars for horizontal and vertical scrolling
|
||||
|
||||
**Note:** The DataGrid automatically detects column types (Text, Number, Bool, Datetime) from the DataFrame dtypes and
|
||||
applies appropriate formatting.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The DataGrid component consists of a filter bar, a table with header/body/footer, and custom scrollbars:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Filter Bar │
|
||||
│ ┌─────────────────────────────────────────────┐ ┌────┐ │
|
||||
│ │ 🔍 Search... │ │ ✕ │ │
|
||||
│ └─────────────────────────────────────────────┘ └────┘ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ Header Row ▲ │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────┐ │ │
|
||||
│ │ Column 1 │ Column 2 │ Column 3 │ Column 4 │ █ │
|
||||
│ └──────────┴──────────┴──────────┴──────────┘ █ │
|
||||
├────────────────────────────────────────────────────────█───┤
|
||||
│ Body (scrollable) █ │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────┐ █ │
|
||||
│ │ Value │ Value │ Value │ Value │ █ │
|
||||
│ ├──────────┼──────────┼──────────┼──────────┤ │ │
|
||||
│ │ Value │ Value │ Value │ Value │ │ │
|
||||
│ ├──────────┼──────────┼──────────┼──────────┤ ▼ │
|
||||
│ │ Value │ Value │ Value │ Value │ │
|
||||
│ └──────────┴──────────┴──────────┴──────────┘ │
|
||||
│ ◄═══════════════════════════════════════════════════════► │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|------------|-------------------------------------------------------|
|
||||
| Filter bar | Search input with filter mode toggle and clear button |
|
||||
| Header row | Column names with resize handles and drag support |
|
||||
| Body | Scrollable data rows with virtual pagination |
|
||||
| Scrollbars | Custom vertical and horizontal scrollbars |
|
||||
|
||||
### Creating a DataGrid
|
||||
|
||||
The DataGrid is a `MultipleInstance`, meaning you can create multiple independent grids in your application. Create it
|
||||
by providing a parent instance:
|
||||
|
||||
```python
|
||||
grid = DataGrid(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
grid = DataGrid(parent=root_instance, _id="my-grid")
|
||||
|
||||
# Or with state persistence enabled
|
||||
grid = DataGrid(parent=root_instance, save_state=True)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `parent`: Parent instance (required)
|
||||
- `_id` (str, optional): Custom identifier for the grid
|
||||
- `save_state` (bool, optional): Enable state persistence (column widths, order, filters)
|
||||
|
||||
### Loading Data
|
||||
|
||||
Use the `init_from_dataframe()` method to load data into the grid:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
# Create a DataFrame
|
||||
df = pd.DataFrame({
|
||||
"Product": ["Laptop", "Phone", "Tablet"],
|
||||
"Price": [999.99, 699.99, 449.99],
|
||||
"In Stock": [True, False, True]
|
||||
})
|
||||
|
||||
# Load into grid
|
||||
grid.init_from_dataframe(df)
|
||||
```
|
||||
|
||||
**Column type detection:**
|
||||
|
||||
The DataGrid automatically detects column types from pandas dtypes:
|
||||
|
||||
| pandas dtype | DataGrid type | Display |
|
||||
|--------------------|---------------|-------------------------|
|
||||
| `int64`, `float64` | Number | Right-aligned |
|
||||
| `bool` | Bool | Checkbox icon |
|
||||
| `datetime64` | Datetime | Formatted date |
|
||||
| `object`, others | Text | Left-aligned, truncated |
|
||||
|
||||
### Row Index Column
|
||||
|
||||
By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows:
|
||||
|
||||
```python
|
||||
# Row index is enabled by default
|
||||
grid._state.row_index = True
|
||||
|
||||
# To disable the row index column
|
||||
grid._state.row_index = False
|
||||
grid.init_from_dataframe(df)
|
||||
```
|
||||
|
||||
## Column Features
|
||||
|
||||
### Resizing Columns
|
||||
|
||||
Users can resize columns by dragging the border between column headers:
|
||||
|
||||
- **Drag handle location**: Right edge of each column header
|
||||
- **Minimum width**: 30 pixels
|
||||
- **Persistence**: Resized widths are automatically saved when `save_state=True`
|
||||
|
||||
The resize interaction:
|
||||
|
||||
1. Hover over the right edge of a column header (cursor changes)
|
||||
2. Click and drag to resize
|
||||
3. Release to confirm the new width
|
||||
4. Double-click to reset to default width
|
||||
|
||||
**Programmatic width control:**
|
||||
|
||||
```python
|
||||
# Set a specific column width
|
||||
for col in grid._state.columns:
|
||||
if col.col_id == "my_column":
|
||||
col.width = 200 # pixels
|
||||
break
|
||||
```
|
||||
|
||||
### Moving Columns
|
||||
|
||||
Users can reorder columns by dragging column headers:
|
||||
|
||||
1. Click and hold a column header
|
||||
2. Drag to the desired position
|
||||
3. Release to drop the column
|
||||
|
||||
The columns animate smoothly during the move, and other columns shift to accommodate the new position.
|
||||
|
||||
**Note:** Column order is persisted when `save_state=True`.
|
||||
|
||||
### Column Visibility
|
||||
|
||||
Columns can be hidden programmatically:
|
||||
|
||||
```python
|
||||
# Hide a specific column
|
||||
for col in grid._state.columns:
|
||||
if col.col_id == "internal_id":
|
||||
col.visible = False
|
||||
break
|
||||
```
|
||||
|
||||
Hidden columns are not rendered but remain in the state, allowing them to be shown again later.
|
||||
|
||||
## Filtering
|
||||
|
||||
### Using the Search Bar
|
||||
|
||||
The DataGrid includes a built-in search bar that filters rows in real-time:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐ ┌────┐
|
||||
│ 🔍 Search... │ │ ✕ │
|
||||
└─────────────────────────────────────────────┘ └────┘
|
||||
│ │
|
||||
│ └── Clear button
|
||||
└── Filter mode icon (click to cycle)
|
||||
```
|
||||
|
||||
**How filtering works:**
|
||||
|
||||
1. Type in the search box
|
||||
2. The grid filters rows where ANY visible column contains the search text
|
||||
3. Matching text is highlighted in the results
|
||||
4. Click the ✕ button to clear the filter
|
||||
|
||||
### Filter Modes
|
||||
|
||||
Click the filter icon to cycle through three modes:
|
||||
|
||||
| Mode | Icon | Description |
|
||||
|------------|------|------------------------------------|
|
||||
| **Filter** | 🔍 | Hides non-matching rows |
|
||||
| **Search** | 🔎 | Highlights matches, shows all rows |
|
||||
| **AI** | 🧠 | AI-powered search (future feature) |
|
||||
|
||||
The current mode affects how results are displayed:
|
||||
|
||||
- **Filter mode**: Only matching rows are shown
|
||||
- **Search mode**: All rows shown, matches highlighted
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### State Persistence
|
||||
|
||||
Enable state persistence to save user preferences across sessions:
|
||||
|
||||
```python
|
||||
# Enable state persistence
|
||||
grid = DataGrid(parent=root, save_state=True)
|
||||
```
|
||||
|
||||
**What gets persisted:**
|
||||
|
||||
| State | Description |
|
||||
|-------------------|---------------------------------|
|
||||
| Column widths | User-resized column sizes |
|
||||
| Column order | User-defined column arrangement |
|
||||
| Column visibility | Which columns are shown/hidden |
|
||||
| Sort order | Current sort configuration |
|
||||
| Filter state | Active filters |
|
||||
|
||||
### Virtual Scrolling
|
||||
|
||||
For large datasets, the DataGrid uses virtual scrolling with lazy loading:
|
||||
|
||||
- Only a subset of rows (page) is rendered initially
|
||||
- As the user scrolls down, more rows are loaded automatically
|
||||
- Uses Intersection Observer API for efficient scroll detection
|
||||
- Default page size: configurable via `DATAGRID_PAGE_SIZE`
|
||||
|
||||
This allows smooth performance even with thousands of rows.
|
||||
|
||||
### Text Size
|
||||
|
||||
Customize the text size for the grid body:
|
||||
|
||||
```python
|
||||
# Available sizes: "xs", "sm", "md", "lg"
|
||||
grid._settings.text_size = "sm" # default
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The DataGrid uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|-----------------------------|-------------------------|
|
||||
| `dt2-table-wrapper` | Root table container |
|
||||
| `dt2-table` | Table element |
|
||||
| `dt2-header-container` | Header wrapper |
|
||||
| `dt2-body-container` | Scrollable body wrapper |
|
||||
| `dt2-footer-container` | Footer wrapper |
|
||||
| `dt2-row` | Table row |
|
||||
| `dt2-cell` | Table cell |
|
||||
| `dt2-resize-handle` | Column resize handle |
|
||||
| `dt2-scrollbars-vertical` | Vertical scrollbar |
|
||||
| `dt2-scrollbars-horizontal` | Horizontal scrollbar |
|
||||
| `dt2-highlight-1` | Search match highlight |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change highlight color */
|
||||
.dt2-highlight-1 {
|
||||
background-color: #fef08a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Customize row hover */
|
||||
.dt2-row:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Style the scrollbars */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Data Table
|
||||
|
||||
A basic data table displaying product information:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Sample product data
|
||||
df = pd.DataFrame({
|
||||
"Product": ["Laptop Pro", "Wireless Mouse", "USB-C Hub", "Monitor 27\"", "Keyboard"],
|
||||
"Category": ["Computers", "Accessories", "Accessories", "Displays", "Accessories"],
|
||||
"Price": [1299.99, 49.99, 79.99, 399.99, 129.99],
|
||||
"In Stock": [True, True, False, True, True],
|
||||
"Rating": [4.5, 4.2, 4.8, 4.6, 4.3]
|
||||
})
|
||||
|
||||
# Create and configure grid
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root, _id="products-grid")
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
# Render
|
||||
return Div(
|
||||
H1("Product Catalog"),
|
||||
grid,
|
||||
cls="p-4"
|
||||
)
|
||||
```
|
||||
|
||||
### Example 2: Large Dataset with Filtering
|
||||
|
||||
Handling a large dataset with virtual scrolling and filtering:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Generate large dataset (10,000 rows)
|
||||
np.random.seed(42)
|
||||
n_rows = 10000
|
||||
|
||||
df = pd.DataFrame({
|
||||
"ID": range(1, n_rows + 1),
|
||||
"Name": [f"Item_{i}" for i in range(n_rows)],
|
||||
"Value": np.random.uniform(10, 1000, n_rows).round(2),
|
||||
"Category": np.random.choice(["A", "B", "C", "D"], n_rows),
|
||||
"Active": np.random.choice([True, False], n_rows),
|
||||
"Created": pd.date_range("2024-01-01", periods=n_rows, freq="h")
|
||||
})
|
||||
|
||||
# Create grid with state persistence
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root, _id="large-dataset", save_state=True)
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
return Div(
|
||||
H1("Large Dataset Explorer"),
|
||||
P(f"Displaying {n_rows:,} rows with virtual scrolling"),
|
||||
grid,
|
||||
cls="p-4",
|
||||
style="height: 100vh;"
|
||||
)
|
||||
```
|
||||
|
||||
**Note:** Virtual scrolling loads rows on demand as you scroll, ensuring smooth performance even with 10,000+ rows.
|
||||
|
||||
### Example 3: Dashboard with Multiple Grids
|
||||
|
||||
An application with multiple data grids in different tabs:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create data for different views
|
||||
sales_df = pd.DataFrame({
|
||||
"Date": pd.date_range("2024-01-01", periods=30, freq="D"),
|
||||
"Revenue": [1000 + i * 50 for i in range(30)],
|
||||
"Orders": [10 + i for i in range(30)]
|
||||
})
|
||||
|
||||
customers_df = pd.DataFrame({
|
||||
"Customer": ["Acme Corp", "Tech Inc", "Global Ltd"],
|
||||
"Country": ["USA", "UK", "Germany"],
|
||||
"Total Spent": [15000, 12000, 8500]
|
||||
})
|
||||
|
||||
# Create instances
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="dashboard-tabs")
|
||||
|
||||
# Create grids
|
||||
sales_grid = DataGrid(parent=root, _id="sales-grid")
|
||||
sales_grid.init_from_dataframe(sales_df)
|
||||
|
||||
customers_grid = DataGrid(parent=root, _id="customers-grid")
|
||||
customers_grid.init_from_dataframe(customers_df)
|
||||
|
||||
# Add to tabs
|
||||
tabs.create_tab("Sales", sales_grid)
|
||||
tabs.create_tab("Customers", customers_grid)
|
||||
|
||||
return Div(
|
||||
H1("Sales Dashboard"),
|
||||
tabs,
|
||||
cls="p-4"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the DataGrid component itself.
|
||||
|
||||
### State
|
||||
|
||||
The DataGrid uses two state objects:
|
||||
|
||||
**DatagridState** - Main state for grid data and configuration:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-------------------|---------------------------|----------------------------|---------|
|
||||
| `sidebar_visible` | bool | Whether sidebar is visible | `False` |
|
||||
| `row_index` | bool | Show row index column | `True` |
|
||||
| `columns` | list[DataGridColumnState] | Column definitions | `[]` |
|
||||
| `rows` | list[DataGridRowState] | Row-specific states | `[]` |
|
||||
| `sorted` | list | Sort configuration | `[]` |
|
||||
| `filtered` | dict | Active filters | `{}` |
|
||||
| `selection` | DatagridSelectionState | Selection state | - |
|
||||
| `ne_df` | DataFrame | The data (non-persisted) | `None` |
|
||||
|
||||
**DatagridSettings** - User preferences:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------------------|------|--------------------|---------|
|
||||
| `save_state` | bool | Enable persistence | `False` |
|
||||
| `header_visible` | bool | Show header row | `True` |
|
||||
| `filter_all_visible` | bool | Show filter bar | `True` |
|
||||
| `text_size` | str | Body text size | `"sm"` |
|
||||
|
||||
### Column State
|
||||
|
||||
Each column is represented by `DataGridColumnState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-------------|------------|--------------------|---------|
|
||||
| `col_id` | str | Column identifier | - |
|
||||
| `col_index` | int | Index in DataFrame | - |
|
||||
| `title` | str | Display title | `None` |
|
||||
| `type` | ColumnType | Data type | `Text` |
|
||||
| `visible` | bool | Is column visible | `True` |
|
||||
| `usable` | bool | Is column usable | `True` |
|
||||
| `width` | int | Width in pixels | `150` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------|---------------------------------------------|
|
||||
| `get_page(page_index)` | Load a specific page of data (lazy loading) |
|
||||
| `set_column_width()` | Update column width after resize |
|
||||
| `move_column()` | Move column to new position |
|
||||
| `filter()` | Apply current filter to grid |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------------------|----------------------------------------|
|
||||
| `init_from_dataframe(df, init_state=True)` | Load data from pandas DataFrame |
|
||||
| `set_column_width(col_id, width)` | Set column width programmatically |
|
||||
| `move_column(source_col_id, target_col_id)` | Move column to new position |
|
||||
| `filter()` | Apply filter and return partial render |
|
||||
| `render()` | Render the complete grid |
|
||||
| `render_partial(fragment, redraw_scrollbars)` | Render only part of the grid |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="grid")
|
||||
├── Div (filter bar)
|
||||
│ └── DataGridQuery # Filter/search component
|
||||
├── Div(id="tw_{id}", cls="dt2-table-wrapper")
|
||||
│ ├── Div(id="t_{id}", cls="dt2-table")
|
||||
│ │ ├── Div (dt2-header-container)
|
||||
│ │ │ └── Div(id="th_{id}", cls="dt2-row dt2-header")
|
||||
│ │ │ ├── Div (dt2-cell) # Column 1 header
|
||||
│ │ │ ├── Div (dt2-cell) # Column 2 header
|
||||
│ │ │ └── ...
|
||||
│ │ ├── Div(id="tb_{id}", cls="dt2-body-container")
|
||||
│ │ │ └── Div (dt2-body)
|
||||
│ │ │ ├── Div (dt2-row) # Data row 1
|
||||
│ │ │ ├── Div (dt2-row) # Data row 2
|
||||
│ │ │ └── ...
|
||||
│ │ └── Div (dt2-footer-container)
|
||||
│ │ └── Div (dt2-row dt2-header) # Footer row
|
||||
│ └── Div (dt2-scrollbars)
|
||||
│ ├── Div (dt2-scrollbars-vertical-wrapper)
|
||||
│ │ └── Div (dt2-scrollbars-vertical)
|
||||
│ └── Div (dt2-scrollbars-horizontal-wrapper)
|
||||
│ └── Div (dt2-scrollbars-horizontal)
|
||||
└── Script # Initialization script
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Pattern | Description |
|
||||
|-----------------------|-------------------------------------|
|
||||
| `{id}` | Root grid container |
|
||||
| `tw_{id}` | Table wrapper (scrollbar container) |
|
||||
| `t_{id}` | Table element |
|
||||
| `th_{id}` | Header row |
|
||||
| `tb_{id}` | Body container |
|
||||
| `tf_{id}` | Footer row |
|
||||
| `tsm_{id}` | Selection Manager |
|
||||
| `tr_{id}-{row_index}` | Individual data row |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------------------------|----------------------------------------|
|
||||
| `mk_headers()` | Renders the header row |
|
||||
| `mk_body()` | Renders the body with first page |
|
||||
| `mk_body_container()` | Renders the scrollable body container |
|
||||
| `mk_body_content_page(page_index)` | Renders a specific page of rows |
|
||||
| `mk_body_cell(col_pos, row_index, col_def)` | Renders a single cell |
|
||||
| `mk_body_cell_content(...)` | Renders cell content with highlighting |
|
||||
| `mk_footers()` | Renders the footer row |
|
||||
| `mk_table()` | Renders the complete table structure |
|
||||
| `mk_aggregation_cell(...)` | Renders footer aggregation cell |
|
||||
| `_get_filtered_df()` | Returns filtered and sorted DataFrame |
|
||||
| `_apply_sort(df)` | Applies sort configuration |
|
||||
| `_apply_filter(df)` | Applies filter configuration |
|
||||
|
||||
### DataGridQuery Component
|
||||
|
||||
The filter bar is a separate component (`DataGridQuery`) with its own state:
|
||||
|
||||
| State Property | Type | Description | Default |
|
||||
|----------------|------|-----------------------------------------|------------|
|
||||
| `filter_type` | str | Current mode ("filter", "search", "ai") | `"filter"` |
|
||||
| `query` | str | Current search text | `None` |
|
||||
|
||||
**Commands:**
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|-----------------------------|
|
||||
| `change_filter_type()` | Cycle through filter modes |
|
||||
| `on_filter_changed()` | Handle search input changes |
|
||||
| `on_cancel_query()` | Clear the search query |
|
||||
557
docs/Dropdown.md
557
docs/Dropdown.md
@@ -1,557 +0,0 @@
|
||||
# Dropdown Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Dropdown component provides an interactive dropdown menu that toggles open or closed when clicking a trigger button. It handles positioning, automatic closing behavior, and keyboard navigation out of the box.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Toggle open/close on button click
|
||||
- Automatic close when clicking outside
|
||||
- Keyboard support (ESC to close)
|
||||
- Configurable vertical position (above or below the button)
|
||||
- Configurable horizontal alignment (left, right, or center)
|
||||
- Session-based state management
|
||||
- HTMX-powered updates without page reload
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Navigation menus
|
||||
- User account menus
|
||||
- Action menus (edit, delete, share)
|
||||
- Filter or sort options
|
||||
- Context-sensitive toolbars
|
||||
- Settings quick access
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a dropdown menu with navigation links:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create root instance and dropdown
|
||||
root = RootInstance(session)
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu", cls="btn"),
|
||||
content=Ul(
|
||||
Li(A("Home", href="/")),
|
||||
Li(A("Settings", href="/settings")),
|
||||
Li(A("Logout", href="/logout"))
|
||||
)
|
||||
)
|
||||
|
||||
# Render the dropdown
|
||||
return dropdown
|
||||
```
|
||||
|
||||
This creates a complete dropdown with:
|
||||
|
||||
- A "Menu" button that toggles the dropdown
|
||||
- A list of navigation links displayed below the button
|
||||
- Automatic closing when clicking outside the dropdown
|
||||
- ESC key support to close the dropdown
|
||||
|
||||
**Note:** The dropdown opens below the button and aligns to the left by default. Users can click anywhere outside the dropdown to close it, or press ESC on the keyboard.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The Dropdown component consists of a trigger button and a content panel:
|
||||
|
||||
```
|
||||
Closed state:
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
└──────────────┘
|
||||
|
||||
Open state (position="below", align="left"):
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
├──────────────┴─────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
│ - Option 3 │
|
||||
└────────────────────────┘
|
||||
|
||||
Open state (position="above", align="right"):
|
||||
┌────────────────────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
├──────────────┬─────────┘
|
||||
│ Button ▲ │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|-----------|------------------------------------------------|
|
||||
| Button | Trigger element that toggles the dropdown |
|
||||
| Content | Panel containing the dropdown menu items |
|
||||
| Wrapper | Container with relative positioning for anchor |
|
||||
|
||||
### Creating a Dropdown
|
||||
|
||||
The Dropdown is a `MultipleInstance`, meaning you can create multiple independent dropdowns in your application. Create it by providing a parent instance:
|
||||
|
||||
```python
|
||||
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content)
|
||||
|
||||
# Or with a custom ID
|
||||
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content, _id="my-dropdown")
|
||||
```
|
||||
|
||||
### Button and Content
|
||||
|
||||
The dropdown requires two main elements:
|
||||
|
||||
**Button:** The trigger element that users click to toggle the dropdown.
|
||||
|
||||
```python
|
||||
# Simple text button
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Click me", cls="btn btn-primary"),
|
||||
content=my_content
|
||||
)
|
||||
|
||||
# Button with icon
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Div(
|
||||
icon_svg,
|
||||
Span("Options"),
|
||||
cls="flex items-center gap-2"
|
||||
),
|
||||
content=my_content
|
||||
)
|
||||
|
||||
# Just an icon
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=icon_svg,
|
||||
content=my_content
|
||||
)
|
||||
```
|
||||
|
||||
**Content:** Any FastHTML element to display in the dropdown panel.
|
||||
|
||||
```python
|
||||
# Simple list
|
||||
content = Ul(
|
||||
Li("Option 1"),
|
||||
Li("Option 2"),
|
||||
Li("Option 3"),
|
||||
cls="menu"
|
||||
)
|
||||
|
||||
# Complex content with sections
|
||||
content = Div(
|
||||
Div("User Actions", cls="font-bold p-2"),
|
||||
Hr(),
|
||||
Button("Edit Profile", cls="btn btn-ghost w-full"),
|
||||
Button("Settings", cls="btn btn-ghost w-full"),
|
||||
Hr(),
|
||||
Button("Logout", cls="btn btn-error w-full")
|
||||
)
|
||||
```
|
||||
|
||||
### Positioning Options
|
||||
|
||||
The Dropdown supports two positioning parameters:
|
||||
|
||||
**`position`** - Vertical position relative to the button:
|
||||
- `"below"` (default): Dropdown appears below the button
|
||||
- `"above"`: Dropdown appears above the button
|
||||
|
||||
**`align`** - Horizontal alignment relative to the button:
|
||||
- `"left"` (default): Dropdown aligns to the left edge of the button
|
||||
- `"right"`: Dropdown aligns to the right edge of the button
|
||||
- `"center"`: Dropdown is centered relative to the button
|
||||
|
||||
```python
|
||||
# Default: below + left
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu)
|
||||
|
||||
# Above the button, aligned right
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu, position="above", align="right")
|
||||
|
||||
# Below the button, centered
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu, position="below", align="center")
|
||||
```
|
||||
|
||||
**Visual examples of all combinations:**
|
||||
|
||||
```
|
||||
position="below", align="left" position="below", align="center" position="below", align="right"
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Button │ │ Button │ │ Button │
|
||||
├────────┴────┐ ┌────┴────────┴────┐ ┌────────────┴────────┤
|
||||
│ Content │ │ Content │ │ Content │
|
||||
└─────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
|
||||
position="above", align="left" position="above", align="center" position="above", align="right"
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ Content │ │ Content │ │ Content │
|
||||
├────────┬────┘ └────┬────────┬────┘ └────────────┬────────┤
|
||||
│ Button │ │ Button │ │ Button │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Automatic Close Behavior
|
||||
|
||||
The Dropdown automatically closes in two scenarios:
|
||||
|
||||
**Click outside:** When the user clicks anywhere outside the dropdown, it closes automatically. This is handled by the Mouse component listening for global click events.
|
||||
|
||||
**ESC key:** When the user presses the ESC key, the dropdown closes. This is handled by the Keyboard component.
|
||||
|
||||
```python
|
||||
# Both behaviors are enabled by default - no configuration needed
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu)
|
||||
```
|
||||
|
||||
**How it works internally:**
|
||||
|
||||
- The `Mouse` component detects clicks and sends `is_inside` and `is_button` parameters
|
||||
- If `is_button` is true, the dropdown toggles
|
||||
- If `is_inside` is false (clicked outside), the dropdown closes
|
||||
- The `Keyboard` component listens for ESC and triggers the close command
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
You can control the dropdown programmatically using its methods and commands:
|
||||
|
||||
```python
|
||||
# Toggle the dropdown state
|
||||
dropdown.toggle()
|
||||
|
||||
# Close the dropdown
|
||||
dropdown.close()
|
||||
|
||||
# Access commands for use with other controls
|
||||
close_cmd = dropdown.commands.close()
|
||||
click_cmd = dropdown.commands.click()
|
||||
```
|
||||
|
||||
**Using commands with buttons:**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Create a button that closes the dropdown
|
||||
close_button = mk.button("Close", command=dropdown.commands.close())
|
||||
|
||||
# Add it to the dropdown content
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu"),
|
||||
content=Div(
|
||||
Ul(Li("Option 1"), Li("Option 2")),
|
||||
close_button
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Dropdown uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|-----------------------|---------------------------------------|
|
||||
| `mf-dropdown-wrapper` | Container with relative positioning |
|
||||
| `mf-dropdown-btn` | Button wrapper |
|
||||
| `mf-dropdown` | Dropdown content panel |
|
||||
| `mf-dropdown-below` | Applied when position="below" |
|
||||
| `mf-dropdown-above` | Applied when position="above" |
|
||||
| `mf-dropdown-left` | Applied when align="left" |
|
||||
| `mf-dropdown-right` | Applied when align="right" |
|
||||
| `mf-dropdown-center` | Applied when align="center" |
|
||||
| `is-visible` | Applied when dropdown is open |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change dropdown background and border */
|
||||
.mf-dropdown {
|
||||
background-color: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add animation */
|
||||
.mf-dropdown {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Style for above position */
|
||||
.mf-dropdown-above {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.mf-dropdown-above.is-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Navigation Menu
|
||||
|
||||
A simple navigation dropdown menu:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Navigation", cls="btn btn-ghost"),
|
||||
content=Ul(
|
||||
Li(A("Dashboard", href="/dashboard", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Projects", href="/projects", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Tasks", href="/tasks", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Reports", href="/reports", cls="block p-2 hover:bg-base-200")),
|
||||
cls="menu p-2"
|
||||
)
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 2: User Account Menu
|
||||
|
||||
A user menu aligned to the right, typically placed in a header:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
# User avatar button
|
||||
user_button = Div(
|
||||
Img(src="/avatar.png", cls="w-8 h-8 rounded-full"),
|
||||
Span("John Doe", cls="ml-2"),
|
||||
cls="flex items-center gap-2 cursor-pointer"
|
||||
)
|
||||
|
||||
# Account menu content
|
||||
account_menu = Div(
|
||||
Div(
|
||||
Div("John Doe", cls="font-bold"),
|
||||
Div("john@example.com", cls="text-sm opacity-60"),
|
||||
cls="p-3 border-b"
|
||||
),
|
||||
Ul(
|
||||
Li(A("Profile", href="/profile", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Settings", href="/settings", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Billing", href="/billing", cls="block p-2 hover:bg-base-200")),
|
||||
cls="menu p-2"
|
||||
),
|
||||
Div(
|
||||
A("Sign out", href="/logout", cls="block p-2 text-error hover:bg-base-200"),
|
||||
cls="border-t"
|
||||
),
|
||||
cls="w-56"
|
||||
)
|
||||
|
||||
# Align right so it doesn't overflow the viewport
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=user_button,
|
||||
content=account_menu,
|
||||
align="right"
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 3: Action Menu Above Button
|
||||
|
||||
A dropdown that opens above the trigger, useful when the button is at the bottom of the screen:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
# Action button with icon
|
||||
action_button = Button(
|
||||
Span("+", cls="text-xl"),
|
||||
cls="btn btn-circle btn-primary"
|
||||
)
|
||||
|
||||
# Quick actions menu
|
||||
actions_menu = Div(
|
||||
Button("New Document", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Upload File", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Create Folder", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Import Data", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
cls="flex flex-col p-2 w-40"
|
||||
)
|
||||
|
||||
# Open above and center-aligned
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=action_button,
|
||||
content=actions_menu,
|
||||
position="above",
|
||||
align="center"
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 4: Dropdown with Commands
|
||||
|
||||
A dropdown containing action buttons that execute commands:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Define actions
|
||||
def edit_item():
|
||||
return "Editing..."
|
||||
|
||||
def delete_item():
|
||||
return "Deleted!"
|
||||
|
||||
def share_item():
|
||||
return "Shared!"
|
||||
|
||||
# Create commands
|
||||
edit_cmd = Command("edit", "Edit item", edit_item)
|
||||
delete_cmd = Command("delete", "Delete item", delete_item)
|
||||
share_cmd = Command("share", "Share item", share_item)
|
||||
|
||||
# Build menu with command buttons
|
||||
actions_menu = Div(
|
||||
mk.button("Edit", command=edit_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
mk.button("Share", command=share_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Hr(cls="my-1"),
|
||||
mk.button("Delete", command=delete_cmd, cls="btn btn-ghost btn-sm w-full justify-start text-error"),
|
||||
cls="flex flex-col p-2"
|
||||
)
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Actions", cls="btn btn-sm"),
|
||||
content=actions_menu
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Dropdown component itself.
|
||||
|
||||
### State
|
||||
|
||||
The Dropdown component maintains its state via `DropdownState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
| `click()` | Handles click events (toggle or close behavior) |
|
||||
|
||||
**Command details:**
|
||||
|
||||
- `close()`: Sets `opened` to `False` and returns updated content
|
||||
- `click()`: Receives `combination`, `is_inside`, and `is_button` parameters
|
||||
- If `is_button` is `True`: toggles the dropdown
|
||||
- If `is_inside` is `False`: closes the dropdown
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|------------|----------------------------|----------------------|
|
||||
| `toggle()` | Toggles open/closed state | Content tuple |
|
||||
| `close()` | Closes the dropdown | Content tuple |
|
||||
| `render()` | Renders complete component | `Div` |
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
| `content` | Any | Content to display in dropdown | `None` |
|
||||
| `button` | Any | Trigger element | `None` |
|
||||
| `_id` | str | Custom ID for the instance | `None` |
|
||||
| `position` | str | Vertical position: "below"/"above" | `"below"` |
|
||||
| `align` | str | Horizontal align: "left"/"right"/"center" | `"left"` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}")
|
||||
├── Div(cls="mf-dropdown-wrapper")
|
||||
│ ├── Div(cls="mf-dropdown-btn")
|
||||
│ │ └── [Button content]
|
||||
│ └── Div(id="{id}-content", cls="mf-dropdown mf-dropdown-{position} mf-dropdown-{align} [is-visible]")
|
||||
│ └── [Dropdown content]
|
||||
├── Keyboard(id="{id}-keyboard")
|
||||
│ └── ESC → close command
|
||||
└── Mouse(id="{id}-mouse")
|
||||
└── click → click command
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|--------------------------------|
|
||||
| `{id}` | Root dropdown container |
|
||||
| `{id}-content` | Dropdown content panel |
|
||||
| `{id}-keyboard` | Keyboard handler component |
|
||||
| `{id}-mouse` | Mouse handler component |
|
||||
|
||||
**Note:** `{id}` is the Dropdown instance ID (auto-generated or custom `_id`).
|
||||
|
||||
### Internal Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------|------------------------------------------|
|
||||
| `_mk_content()` | Renders the dropdown content panel |
|
||||
| `on_click()` | Handles click events from Mouse component |
|
||||
|
||||
**Method details:**
|
||||
|
||||
- `_mk_content()`:
|
||||
- Builds CSS classes based on `position` and `align`
|
||||
- Adds `is-visible` class when `opened` is `True`
|
||||
- Returns a tuple containing the content `Div`
|
||||
|
||||
- `on_click(combination, is_inside, is_button)`:
|
||||
- Called by Mouse component on click events
|
||||
- `is_button`: `True` if click was on the button
|
||||
- `is_inside`: `True` if click was inside the dropdown
|
||||
- Returns updated content for HTMX swap
|
||||
@@ -176,55 +176,12 @@ You can use any HTMX attribute in the configuration object:
|
||||
- `hx-target` - Target element selector
|
||||
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
|
||||
- `hx-vals` - Additional values to send (object)
|
||||
- `hx-vals-extra` - Extra values to merge (see below)
|
||||
- `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.
|
||||
|
||||
### Dynamic Values with hx-vals-extra
|
||||
|
||||
The `hx-vals-extra` attribute allows adding dynamic values computed at event time, without overwriting the static `hx-vals`.
|
||||
|
||||
**Format:**
|
||||
```javascript
|
||||
{
|
||||
"hx-vals": {"c_id": "command_id"}, // Static values (preserved)
|
||||
"hx-vals-extra": {
|
||||
"dict": {"key": "value"}, // Additional static values (merged)
|
||||
"js": "functionName" // JS function to call (merged)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How values are merged:**
|
||||
1. `hx-vals` - static values (e.g., `c_id` from Command)
|
||||
2. `hx-vals-extra.dict` - additional static values
|
||||
3. `hx-vals-extra.js` - function called with `(event, element, combinationStr)`, result merged
|
||||
|
||||
**JavaScript function example:**
|
||||
```javascript
|
||||
function getKeyboardContext(event, element, combination) {
|
||||
return {
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration example:**
|
||||
```javascript
|
||||
const combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save",
|
||||
"hx-vals": {"c_id": "save_cmd"},
|
||||
"hx-vals-extra": {"js": "getKeyboardContext"}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every request:
|
||||
|
||||
583
docs/Layout.md
583
docs/Layout.md
@@ -1,583 +0,0 @@
|
||||
# Layout Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
|
||||
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Fixed header and footer that stay visible while scrolling
|
||||
- Collapsible left and right drawers for navigation, tools, or auxiliary content
|
||||
- Resizable drawers with drag handles
|
||||
- Automatic state persistence per session
|
||||
- Single instance per session (singleton pattern)
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Application with navigation sidebar
|
||||
- Dashboard with tools panel
|
||||
- Admin interface with settings drawer
|
||||
- Documentation site with table of contents
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing an application with a navigation sidebar:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create the layout instance
|
||||
layout = Layout(parent=root_instance, app_name="My App")
|
||||
|
||||
# Add navigation items to the left drawer
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Home"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("About"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Contact"), command=Command(...))
|
||||
)
|
||||
|
||||
# Set the main content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Welcome"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the layout
|
||||
return layout
|
||||
```
|
||||
|
||||
This creates a complete application layout with:
|
||||
|
||||
- A header displaying the app name and drawer toggle button
|
||||
- A collapsible left drawer with interactive navigation items
|
||||
- A main content area that updates when navigation items are clicked
|
||||
- An empty footer
|
||||
|
||||
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
|
||||
below for details.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a Layout
|
||||
|
||||
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
|
||||
parent instance and an application name:
|
||||
|
||||
```python
|
||||
layout = Layout(parent=root_instance, app_name="My Application")
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Layout provides six content zones where you can add components:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Header │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ header_left │ │ header_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
├─────────┬────────────────────────────────────┬───────────┤
|
||||
│ │ │ │
|
||||
│ left │ │ right │
|
||||
│ drawer │ Main Content │ drawer │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├─────────┴────────────────────────────────────┴───────────┤
|
||||
│ Footer │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ footer_left │ │ footer_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use |
|
||||
|----------------|-----------------------------------------------|
|
||||
| `header_left` | App logo, menu button, breadcrumbs |
|
||||
| `header_right` | User profile, notifications, settings |
|
||||
| `left_drawer` | Navigation menu, tree view, filters |
|
||||
| `right_drawer` | Tools panel, properties inspector, debug info |
|
||||
| `footer_left` | Copyright, legal links, version |
|
||||
| `footer_right` | Status indicators, connection state |
|
||||
|
||||
### Adding Content to Zones
|
||||
|
||||
Use the `.add()` method to add components to any zone:
|
||||
|
||||
```python
|
||||
# Header
|
||||
layout.header_left.add(Div("Logo"))
|
||||
layout.header_right.add(Div("User: Admin"))
|
||||
|
||||
# Drawers
|
||||
layout.left_drawer.add(Div("Navigation"))
|
||||
layout.right_drawer.add(Div("Tools"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 My App"))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
```
|
||||
|
||||
### Setting Main Content
|
||||
|
||||
The main content area displays your page content and can be updated dynamically:
|
||||
|
||||
```python
|
||||
# Set initial content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("Welcome to your dashboard")
|
||||
)
|
||||
)
|
||||
|
||||
# Update later (typically via commands)
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Settings"),
|
||||
P("Configure your preferences")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Controlling Drawers
|
||||
|
||||
By default, both drawers are visible. The drawer state is managed automatically:
|
||||
|
||||
- Users can toggle drawers using the icon buttons in the header
|
||||
- Users can resize drawers by dragging the handle
|
||||
- Drawer state persists within the session
|
||||
|
||||
The initial drawer widths are:
|
||||
|
||||
- Left drawer: 250px
|
||||
- Right drawer: 250px
|
||||
|
||||
These can be adjusted by users and the state is preserved automatically.
|
||||
|
||||
## Content System
|
||||
|
||||
### Understanding Groups
|
||||
|
||||
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
|
||||
separated visually by dividers and can have optional labels.
|
||||
|
||||
### Adding Content to Groups
|
||||
|
||||
When adding content, you can optionally specify a group name:
|
||||
|
||||
```python
|
||||
# Add items to different groups in the left drawer
|
||||
layout.left_drawer.add(Div("Dashboard"), group="main")
|
||||
layout.left_drawer.add(Div("Analytics"), group="main")
|
||||
layout.left_drawer.add(Div("Settings"), group="preferences")
|
||||
layout.left_drawer.add(Div("Profile"), group="preferences")
|
||||
```
|
||||
|
||||
This creates two groups:
|
||||
|
||||
- **main**: Dashboard, Analytics
|
||||
- **preferences**: Settings, Profile
|
||||
|
||||
A visual divider automatically appears between groups.
|
||||
|
||||
### Custom Group Labels
|
||||
|
||||
You can provide a custom FastHTML element to display as the group header:
|
||||
|
||||
```python
|
||||
# Add a styled group header
|
||||
layout.left_drawer.add_group(
|
||||
"Navigation",
|
||||
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
|
||||
)
|
||||
|
||||
# Then add items to this group
|
||||
layout.left_drawer.add(Div("Home"), group="Navigation")
|
||||
layout.left_drawer.add(Div("About"), group="Navigation")
|
||||
```
|
||||
|
||||
### Ungrouped Content
|
||||
|
||||
If you don't specify a group, content is added to the default (`None`) group:
|
||||
|
||||
```python
|
||||
# These items are in the default group
|
||||
layout.left_drawer.add(Div("Quick Action 1"))
|
||||
layout.left_drawer.add(Div("Quick Action 2"))
|
||||
```
|
||||
|
||||
### Preventing Duplicates
|
||||
|
||||
The Content system automatically prevents adding duplicate items based on their `id` attribute:
|
||||
|
||||
```python
|
||||
item = Div("Unique Item", id="my-item")
|
||||
layout.left_drawer.add(item)
|
||||
layout.left_drawer.add(item) # Ignored - already added
|
||||
```
|
||||
|
||||
### Group Rendering Options
|
||||
|
||||
Groups render differently depending on the zone:
|
||||
|
||||
**In drawers** (vertical layout):
|
||||
|
||||
- Groups stack vertically
|
||||
- Dividers are horizontal lines
|
||||
- Group labels appear above their content
|
||||
|
||||
**In header/footer** (horizontal layout):
|
||||
|
||||
- Groups arrange side-by-side
|
||||
- Dividers are vertical lines
|
||||
- Group labels are typically hidden
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Resizable Drawers
|
||||
|
||||
Both drawers can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left drawer: Right edge
|
||||
- Right drawer: Left edge
|
||||
- **Width constraints**: 150px (minimum) to 600px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in the session state
|
||||
|
||||
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
|
||||
|
||||
### Programmatic Drawer Control
|
||||
|
||||
You can control drawers programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle drawer visibility
|
||||
toggle_left = layout.commands.toggle_drawer("left")
|
||||
toggle_right = layout.commands.toggle_drawer("right")
|
||||
|
||||
# Update drawer width
|
||||
update_left_width = layout.commands.update_drawer_width("left", width=300)
|
||||
update_right_width = layout.commands.update_drawer_width("right", width=350)
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
# Add a button to toggle the right drawer
|
||||
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
|
||||
layout.header_right.add(button)
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Layout automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|----------------------|---------------------------------|---------|
|
||||
| `left_drawer_open` | Whether left drawer is visible | `True` |
|
||||
| `right_drawer_open` | Whether right drawer is visible | `True` |
|
||||
| `left_drawer_width` | Left drawer width in pixels | `250` |
|
||||
| `right_drawer_width` | Right drawer width in pixels | `250` |
|
||||
|
||||
State changes (toggle, resize) are automatically saved and restored within the session.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
Content zones can be updated dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial setup
|
||||
layout.left_drawer.add(Div("Item 1"))
|
||||
|
||||
|
||||
# Later, add more items (e.g., in a command handler)
|
||||
def add_dynamic_content():
|
||||
layout.left_drawer.add(Div("New Item"), group="dynamic")
|
||||
return layout.left_drawer # Return updated drawer for HTMX swap
|
||||
```
|
||||
|
||||
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Layout uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|----------------------------------|
|
||||
| `mf-layout` | Root layout container |
|
||||
| `mf-layout-header` | Header section |
|
||||
| `mf-layout-footer` | Footer section |
|
||||
| `mf-layout-main` | Main content area |
|
||||
| `mf-layout-drawer` | Drawer container |
|
||||
| `mf-layout-left-drawer` | Left drawer specifically |
|
||||
| `mf-layout-right-drawer` | Right drawer specifically |
|
||||
| `mf-layout-drawer-content` | Scrollable content within drawer |
|
||||
| `mf-resizer` | Resize handle |
|
||||
| `mf-layout-group` | Content group wrapper |
|
||||
|
||||
You can override these classes in your custom CSS to change colors, spacing, or behavior.
|
||||
|
||||
### User Profile Integration
|
||||
|
||||
The Layout automatically includes a UserProfile component in the header right area. This component handles user
|
||||
authentication display and logout functionality when auth is enabled.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Dashboard with Navigation Sidebar
|
||||
|
||||
A typical dashboard application with a navigation menu in the left drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
|
||||
|
||||
|
||||
# Navigation menu in left drawer
|
||||
def show_dashboard():
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_reports():
|
||||
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_settings():
|
||||
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Add navigation items with groups
|
||||
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
|
||||
group="main")
|
||||
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
|
||||
group="main")
|
||||
|
||||
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
|
||||
group="config")
|
||||
|
||||
# Header content
|
||||
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Analytics Co."))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
|
||||
# Set initial main content
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
```
|
||||
|
||||
### Example 2: Development Tool with Debug Panel
|
||||
|
||||
An application with development tools in the right drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Dev Tools")
|
||||
|
||||
# Main content: code editor
|
||||
layout.set_main(
|
||||
Div(
|
||||
H2("Code Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
# Right drawer: debug and tools
|
||||
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Div("Console output here..."), group="debug")
|
||||
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
|
||||
|
||||
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Button("Run Code"), group="tools")
|
||||
layout.right_drawer.add(Button("Clear Console"), group="tools")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("DevTools IDE"))
|
||||
layout.header_right.add(Button("Save"))
|
||||
```
|
||||
|
||||
### Example 3: Minimal Layout (Main Content Only)
|
||||
|
||||
A simple layout without drawers, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Simple Blog")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
|
||||
layout.header_right.add(A("About", href="/about"))
|
||||
|
||||
# Main content
|
||||
layout.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog"),
|
||||
P("This is a simple blog layout without side drawers."),
|
||||
P("The focus is on the content in the center.")
|
||||
)
|
||||
)
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Blog Author"))
|
||||
layout.footer_right.add(A("RSS", href="/rss"))
|
||||
|
||||
# Note: Drawers are present but can be collapsed by users if not needed
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Content Loading
|
||||
|
||||
Loading content dynamically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
layout = Layout(parent=root_instance, app_name="Dynamic App")
|
||||
|
||||
|
||||
# Function that loads content dynamically
|
||||
def load_page(page_name):
|
||||
# Simulate loading different content
|
||||
content = {
|
||||
"home": Div(H1("Home"), P("Welcome to the home page")),
|
||||
"profile": Div(H1("Profile"), P("User profile information")),
|
||||
"settings": Div(H1("Settings"), P("Application settings")),
|
||||
}
|
||||
layout.set_main(content.get(page_name, Div("Page not found")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Create navigation commands
|
||||
pages = ["home", "profile", "settings"]
|
||||
for page in pages:
|
||||
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div(page.capitalize()), command=cmd)
|
||||
)
|
||||
|
||||
# Set initial content
|
||||
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Layout component itself.
|
||||
|
||||
### State
|
||||
|
||||
The Layout component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------------------|---------|----------------------------------|---------|
|
||||
| `left_drawer_open` | boolean | True if the left drawer is open | True |
|
||||
| `right_drawer_open` | boolean | True if the right drawer is open | True |
|
||||
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
||||
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
||||
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
|
||||
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------|-----------------------------|
|
||||
| `set_main(content)` | Sets the main content area |
|
||||
| `render()` | Renders the complete layout |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="layout")
|
||||
├── Header
|
||||
│ ├── Div(id="layout_hl")
|
||||
│ │ ├── Icon # Left drawer icon button
|
||||
│ │ └── Div # Left content for the header
|
||||
│ └── Div(id="layout_hr")
|
||||
│ ├── Div # Right content for the header
|
||||
│ └── UserProfile # user profile icon button
|
||||
├── Div # Left Drawer
|
||||
├── Main # Main content
|
||||
├── Div # Right Drawer
|
||||
├── Footer # Footer
|
||||
└── Script # To initialize the resizing
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------|-------------------------------------|
|
||||
| `layout` | Root layout container (singleton) |
|
||||
| `layout_h` | Header section (not currently used) |
|
||||
| `layout_hl` | Header left side |
|
||||
| `layout_hr` | Header right side |
|
||||
| `layout_f` | Footer section (not currently used) |
|
||||
| `layout_fl` | Footer left side |
|
||||
| `layout_fr` | Footer right side |
|
||||
| `layout_ld` | Left drawer |
|
||||
| `layout_rd` | Right drawer |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------|--------------------------------------------------------|
|
||||
| `_mk_header()` | Renders the header component |
|
||||
| `_mk_footer()` | Renders the footer component |
|
||||
| `_mk_main()` | Renders the main content area |
|
||||
| `_mk_left_drawer()` | Renders the left drawer |
|
||||
| `_mk_right_drawer()` | Renders the right drawer |
|
||||
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
|
||||
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
|
||||
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
|
||||
|
||||
### Content Class
|
||||
|
||||
The `Layout.Content` nested class manages content zones:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------|----------------------------------------------------------|
|
||||
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
|
||||
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
|
||||
| `get_content()` | Returns dictionary of groups and their content |
|
||||
| `get_groups()` | Returns list of (group_name, group_ft) tuples |
|
||||
@@ -64,70 +64,6 @@ const combinations = {
|
||||
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||
```
|
||||
|
||||
### Dynamic Values with JavaScript Functions
|
||||
|
||||
You can add dynamic values computed at click time using `hx-vals-extra`. This is useful when combined with a Command (which provides `hx-vals` with `c_id`).
|
||||
|
||||
**Configuration format:**
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/myfasthtml/commands",
|
||||
"hx-vals": {"c_id": "command_id"}, // Static values from Command
|
||||
"hx-vals-extra": {"js": "getClickData"} // Dynamic values via JS function
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. `hx-vals` contains static values (e.g., `c_id` from Command)
|
||||
2. `hx-vals-extra.dict` contains additional static values (merged)
|
||||
3. `hx-vals-extra.js` specifies a function to call for dynamic values (merged)
|
||||
|
||||
**JavaScript function definition:**
|
||||
```javascript
|
||||
// Function receives (event, element, combinationStr)
|
||||
function getClickData(event, element, combination) {
|
||||
return {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target_id: event.target.id,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The function parameters are optional - use what you need:
|
||||
|
||||
```javascript
|
||||
// Full context
|
||||
function getFullContext(event, element, combination) {
|
||||
return { x: event.clientX, elem: element.id, combo: combination };
|
||||
}
|
||||
|
||||
// Just the event
|
||||
function getPosition(event) {
|
||||
return { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
// No parameters needed
|
||||
function getTimestamp() {
|
||||
return { ts: Date.now() };
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in helper function:**
|
||||
```javascript
|
||||
// getCellId() - finds parent with .dt2-cell class and returns its id
|
||||
function getCellId(event) {
|
||||
const cell = event.target.closest('.dt2-cell');
|
||||
if (cell && cell.id) {
|
||||
return { cell_id: cell.id };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_mouse_support(elementId, combinationsJson)
|
||||
@@ -214,155 +150,16 @@ The library automatically adds these parameters to every HTMX request:
|
||||
|
||||
## Python Integration
|
||||
|
||||
### Mouse Class
|
||||
|
||||
The `Mouse` class provides a convenient way to add mouse support to elements.
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create mouse support for an element
|
||||
mouse = Mouse(parent_element)
|
||||
|
||||
# Add combinations
|
||||
mouse.add("click", select_command)
|
||||
mouse.add("ctrl+click", toggle_command)
|
||||
mouse.add("right_click", context_menu_command)
|
||||
```
|
||||
|
||||
### Mouse.add() Method
|
||||
|
||||
```python
|
||||
def add(self, sequence: str, command: Command = None, *,
|
||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||
hx_delete: str = None, hx_patch: str = None,
|
||||
hx_target: str = None, hx_swap: str = None, hx_vals=None)
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click")
|
||||
- `command`: Optional Command object for server-side action
|
||||
- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command)
|
||||
- `hx_target`: HTMX target selector (overrides command)
|
||||
- `hx_swap`: HTMX swap strategy (overrides command)
|
||||
- `hx_vals`: Additional HTMX values - dict or "js:functionName()" for dynamic values
|
||||
|
||||
**Note**:
|
||||
- Named parameters (except `hx_vals`) override the command's parameters.
|
||||
- `hx_vals` is **merged** with command's values (stored in `hx-vals-extra`), preserving `c_id`.
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
**With Command only**:
|
||||
```python
|
||||
mouse.add("click", my_command)
|
||||
```
|
||||
|
||||
**With Command and overrides**:
|
||||
```python
|
||||
# Command provides hx-post, but we override the target
|
||||
mouse.add("ctrl+click", my_command, hx_target="#other-result")
|
||||
```
|
||||
|
||||
**Without Command (direct HTMX)**:
|
||||
```python
|
||||
mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="innerHTML")
|
||||
```
|
||||
|
||||
**With dynamic values**:
|
||||
```python
|
||||
mouse.add("shift+click", my_command, hx_vals="js:getClickPosition()")
|
||||
```
|
||||
|
||||
### Sequences
|
||||
|
||||
```python
|
||||
mouse = Mouse(element)
|
||||
mouse.add("click", single_click_command)
|
||||
mouse.add("click click", double_click_command)
|
||||
mouse.add("click right_click", special_action_command)
|
||||
```
|
||||
|
||||
### Multiple Elements
|
||||
|
||||
```python
|
||||
# Each element gets its own Mouse instance
|
||||
for item in items:
|
||||
mouse = Mouse(item)
|
||||
mouse.add("click", Command("select", "Select item", lambda i=item: select(i)))
|
||||
mouse.add("ctrl+click", Command("toggle", "Toggle item", lambda i=item: toggle(i)))
|
||||
```
|
||||
|
||||
### Dynamic hx-vals with JavaScript
|
||||
|
||||
You can use `"js:functionName()"` to call a client-side JavaScript function that returns additional values to send with the request. The command's `c_id` is preserved.
|
||||
|
||||
**Python**:
|
||||
```python
|
||||
mouse.add("click", my_command, hx_vals="js:getClickContext()")
|
||||
```
|
||||
|
||||
**Generated config** (internally):
|
||||
```json
|
||||
{
|
||||
"hx-post": "/myfasthtml/commands",
|
||||
"hx-vals": {"c_id": "command_id"},
|
||||
"hx-vals-extra": {"js": "getClickContext"}
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript** (client-side):
|
||||
```javascript
|
||||
// Function receives (event, element, combinationStr)
|
||||
function getClickContext(event, element, combination) {
|
||||
return {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
elementId: element.id,
|
||||
combo: combination
|
||||
};
|
||||
}
|
||||
|
||||
// Simple function - parameters are optional
|
||||
function getTimestamp() {
|
||||
return { ts: Date.now() };
|
||||
}
|
||||
```
|
||||
|
||||
**Values sent to server**:
|
||||
```json
|
||||
{
|
||||
"c_id": "command_id",
|
||||
"x": 150,
|
||||
"y": 200,
|
||||
"elementId": "my-element",
|
||||
"combo": "click",
|
||||
"combination": "click",
|
||||
"has_focus": false,
|
||||
"is_inside": true
|
||||
}
|
||||
```
|
||||
|
||||
You can also pass a static dict:
|
||||
```python
|
||||
mouse.add("click", my_command, hx_vals={"extra_key": "extra_value"})
|
||||
```
|
||||
|
||||
### Low-Level Usage (without Mouse class)
|
||||
|
||||
For advanced use cases, you can generate the JavaScript directly:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
combinations = {
|
||||
"click": {
|
||||
"hx-post": "/item/select"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/item/select-multiple",
|
||||
"hx-vals": {"mode": "multi"}
|
||||
"hx-vals": json.dumps({"mode": "multi"})
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/item/context-menu",
|
||||
@@ -371,7 +168,41 @@ combinations = {
|
||||
}
|
||||
}
|
||||
|
||||
Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')")
|
||||
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
|
||||
|
||||
944
docs/Panel.md
944
docs/Panel.md
@@ -1,944 +0,0 @@
|
||||
# Panel Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Panel component provides a flexible three-zone layout with optional collapsible side panels. It's designed to
|
||||
organize content into left panel, main area, and right panel sections, with smooth toggle animations and resizable
|
||||
panels.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Three customizable zones (left panel, main content, right panel)
|
||||
- Configurable panel titles with sticky headers
|
||||
- Toggle visibility with hide/show icons
|
||||
- Resizable panels with drag handles
|
||||
- Smooth CSS animations for show/hide transitions
|
||||
- Automatic state persistence per session
|
||||
- Configurable panel presence (enable/disable left or right)
|
||||
- Session-based width and visibility state
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Code editor with file explorer and properties panel
|
||||
- Data visualization with filters sidebar and details panel
|
||||
- Admin interface with navigation menu and tools panel
|
||||
- Documentation viewer with table of contents and metadata
|
||||
- Dashboard with configuration panel and information sidebar
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a three-panel layout for a code editor:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create the panel instance
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set content for each zone
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Files"),
|
||||
Ul(
|
||||
Li("app.py"),
|
||||
Li("config.py"),
|
||||
Li("utils.py")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_main(
|
||||
Div(
|
||||
H2("Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties"),
|
||||
Div("Language: Python"),
|
||||
Div("Lines: 120"),
|
||||
Div("Size: 3.2 KB")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the panel
|
||||
return panel
|
||||
```
|
||||
|
||||
This creates a complete panel layout with:
|
||||
|
||||
- A left panel displaying a file list with a hide icon (−) at the top right
|
||||
- A main content area with a code editor
|
||||
- A right panel showing file properties with a hide icon (−) at the top right
|
||||
- Show icons (⋯) that appear in the main area when panels are hidden
|
||||
- Drag handles between panels for manual resizing
|
||||
- Automatic state persistence (visibility and width)
|
||||
|
||||
**Note:** Users can hide panels by clicking the hide icon (−) inside each panel. When hidden, a show icon (⋯) appears in
|
||||
the main area (left side for left panel, right side for right panel). Panels can be resized by dragging the handles, and
|
||||
all state is automatically saved in the session.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The Panel component consists of three zones with optional side panels:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┐ │ ┌──────────────────────┐ │ ┌──────────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ Left │ ║ │ │ ║ │ Right │ │
|
||||
│ │ Panel │ │ │ Main Content │ │ │ Panel │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ [−] │ │ │ [⋯] [⋯] │ │ │ [−] │ │
|
||||
│ └──────────┘ │ └──────────────────────┘ │ └──────────┘ │
|
||||
│ ║ ║ │
|
||||
│ Resizer Resizer │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|---------------|-----------------------------------------------|
|
||||
| Left panel | Optional collapsible panel (default: visible) |
|
||||
| Main content | Always-visible central content area |
|
||||
| Right panel | Optional collapsible panel (default: visible) |
|
||||
| Hide icon (−) | Inside each panel header, right side |
|
||||
| Show icon (⋯) | In main area when panel is hidden |
|
||||
| Resizer (║) | Drag handle to resize panels manually |
|
||||
|
||||
**Panel with title (default):**
|
||||
|
||||
When `show_left_title` or `show_right_title` is `True` (default), panels display a sticky header with title and hide icon:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Title [−] │ ← Header (sticky, always visible)
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ Scrollable Content │ ← Content area (scrolls independently)
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Panel without title:**
|
||||
|
||||
When `show_left_title` or `show_right_title` is `False`, panels use the legacy layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [−] │ ← Hide icon at top-right (absolute)
|
||||
│ │
|
||||
│ Content │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
||||
providing a parent instance:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
panel = Panel(parent=root_instance, _id="my-panel")
|
||||
|
||||
# Or with custom configuration
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
conf = PanelConf(left=True, right=False) # Only left panel enabled
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Panel provides three content zones:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Left Panel │ Main Content │ Right Panel │
|
||||
│ (optional) │ (required) │ (optional) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use | Required |
|
||||
|---------|-------------------------------------------------------|----------|
|
||||
| `left` | Navigation, file explorer, filters, table of contents | No |
|
||||
| `main` | Primary content, editor, visualization, results | Yes |
|
||||
| `right` | Properties, tools, metadata, debug info, settings | No |
|
||||
|
||||
### Setting Content
|
||||
|
||||
Use the `set_*()` methods to add content to each zone:
|
||||
|
||||
```python
|
||||
# Main content (always visible)
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Left panel (optional)
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Home"),
|
||||
Li("Settings"),
|
||||
Li("About")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel (optional)
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Tools"),
|
||||
Button("Export"),
|
||||
Button("Refresh")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Method chaining:**
|
||||
|
||||
The `set_main()` method returns `self`, enabling method chaining:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
.set_main(Div("Main content"))
|
||||
.set_left(Div("Left content"))
|
||||
```
|
||||
|
||||
### Panel Configuration
|
||||
|
||||
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
# Only left panel enabled
|
||||
conf = PanelConf(left=True, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only right panel enabled
|
||||
conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Both panels enabled (default)
|
||||
conf = PanelConf(left=True, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# No side panels (main content only)
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Customizing panel titles:**
|
||||
|
||||
```python
|
||||
# Custom titles for panels
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
left_title="Explorer", # Custom title for left panel
|
||||
right_title="Properties" # Custom title for right panel
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Disabling panel titles:**
|
||||
|
||||
When titles are disabled, panels use the legacy layout without a sticky header:
|
||||
|
||||
```python
|
||||
# Disable titles (legacy layout)
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
show_left_title=False,
|
||||
show_right_title=False
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Disabling show icons:**
|
||||
|
||||
You can hide the show icons (⋯) that appear when panels are hidden. This means users can only show panels programmatically:
|
||||
|
||||
```python
|
||||
# Disable show icons (programmatic control only)
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
show_display_left=False, # No show icon for left panel
|
||||
show_display_right=False # No show icon for right panel
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
||||
renders but with zero width and overflow hidden.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Toggling Panel Visibility
|
||||
|
||||
Each visible panel includes a hide icon (−) in its top-right corner. When hidden, a show icon (⋯) appears in the main
|
||||
area:
|
||||
|
||||
**User interaction:**
|
||||
|
||||
- **Hide panel**: Click the − icon inside the panel
|
||||
- **Show panel**: Click the ⋯ icon in the main area
|
||||
|
||||
**Icon positions:**
|
||||
|
||||
- Hide icons (−): Always at top-right of each panel
|
||||
- Show icon for left panel (⋯): Top-left of main area
|
||||
- Show icon for right panel (⋯): Top-right of main area
|
||||
|
||||
**Visual states:**
|
||||
|
||||
```
|
||||
Panel Visible:
|
||||
┌──────────┐
|
||||
│ Content │
|
||||
│ [−] │ ← Hide icon visible
|
||||
└──────────┘
|
||||
|
||||
Panel Hidden:
|
||||
┌──────────────────┐
|
||||
│ [⋯] Main │ ← Show icon visible in main
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**Animation:**
|
||||
|
||||
When toggling visibility:
|
||||
|
||||
- **Hiding**: Panel width animates to 0px over 0.3s
|
||||
- **Showing**: Panel width animates to its saved width over 0.3s
|
||||
- Content remains in DOM (state preserved)
|
||||
- Smooth CSS transition with ease timing
|
||||
|
||||
**Note:** The animation only works when showing (panel appearing). When hiding, the transition currently doesn't apply
|
||||
due to HTMX swap timing. This is a known limitation.
|
||||
|
||||
### Resizable Panels
|
||||
|
||||
Both left and right panels can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left panel: Right edge (vertical bar)
|
||||
- Right panel: Left edge (vertical bar)
|
||||
- **Width constraints**: 150px (minimum) to 500px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in session state
|
||||
- **No transition during resize**: CSS transitions are disabled during manual dragging for smooth performance
|
||||
|
||||
**How to resize:**
|
||||
|
||||
1. Hover over the panel edge (cursor changes to resize cursor)
|
||||
2. Click and drag left/right
|
||||
3. Release to set the new width
|
||||
4. Width is saved automatically and persists in the session
|
||||
|
||||
**Initial widths:**
|
||||
|
||||
- Left panel: 250px
|
||||
- Right panel: 250px
|
||||
|
||||
These defaults can be customized via state after creation if needed.
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Panel automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|-----------------|--------------------------------|---------|
|
||||
| `left_visible` | Whether left panel is visible | `True` |
|
||||
| `right_visible` | Whether right panel is visible | `True` |
|
||||
| `left_width` | Left panel width in pixels | `250` |
|
||||
| `right_width` | Right panel width in pixels | `250` |
|
||||
|
||||
State changes (toggle visibility, resize width) are automatically saved and restored within the session.
|
||||
|
||||
**Accessing state:**
|
||||
|
||||
```python
|
||||
# Check current state
|
||||
is_left_visible = panel._state.left_visible
|
||||
left_panel_width = panel._state.left_width
|
||||
|
||||
# Programmatically update state (not recommended - use commands instead)
|
||||
panel._state.left_visible = False # Better to use toggle_side command
|
||||
```
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
You can control panels programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle panel visibility
|
||||
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
|
||||
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
|
||||
|
||||
# Update panel width
|
||||
update_left_width = panel.commands.update_side_width("left")
|
||||
update_right_width = panel.commands.update_side_width("right")
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Add buttons to toggle panels
|
||||
hide_left_btn = mk.button("Hide Left", command=panel.commands.set_side_visible("left", False))
|
||||
show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
|
||||
|
||||
# Add to your layout
|
||||
panel.set_main(
|
||||
Div(
|
||||
hide_left_btn,
|
||||
show_left_btn,
|
||||
H1("Main Content")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Command details:**
|
||||
|
||||
- `toggle_side(side, visible)`: Sets panel visibility explicitly
|
||||
- `side`: `"left"` or `"right"`
|
||||
- `visible`: `True` (show) or `False` (hide)
|
||||
- Returns: tuple of (panel_element, show_icon_element) for HTMX swap
|
||||
|
||||
- `update_side_width(side)`: Updates panel width from HTMX request
|
||||
- `side`: `"left"` or `"right"`
|
||||
- Width value comes from JavaScript resize handler
|
||||
- Returns: updated panel element for HTMX swap
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Panel uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|--------------------------------------------|
|
||||
| `mf-panel` | Root panel container |
|
||||
| `mf-panel-left` | Left panel container |
|
||||
| `mf-panel-right` | Right panel container |
|
||||
| `mf-panel-main` | Main content area |
|
||||
| `mf-panel-with-title` | Panel using title layout (no padding-top) |
|
||||
| `mf-panel-body` | Grid container for header + content |
|
||||
| `mf-panel-header` | Sticky header with title and hide icon |
|
||||
| `mf-panel-content` | Scrollable content area |
|
||||
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
||||
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
||||
| `mf-panel-show-icon-left` | Show icon for left panel |
|
||||
| `mf-panel-show-icon-right` | Show icon for right panel |
|
||||
| `mf-resizer` | Resize handle base class |
|
||||
| `mf-resizer-left` | Left panel resize handle |
|
||||
| `mf-resizer-right` | Right panel resize handle |
|
||||
| `mf-hidden` | Applied to hidden panels |
|
||||
| `no-transition` | Disables transition during manual resize |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change panel background color */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Customize hide icon appearance */
|
||||
.mf-panel-hide-icon:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Change transition timing */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
transition: width 0.5s ease-in-out; /* Slower animation */
|
||||
}
|
||||
|
||||
/* Style resizer handles */
|
||||
.mf-resizer {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Editor Layout
|
||||
|
||||
A typical code editor with file explorer, editor, and properties panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: File Explorer
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Explorer", cls="font-bold mb-2"),
|
||||
Div(
|
||||
Div("📁 src", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div(" 📄 config.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div("📁 tests", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 test_app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
cls="space-y-1"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Code Editor
|
||||
panel.set_main(
|
||||
Div(
|
||||
Div(
|
||||
Span("app.py", cls="font-bold"),
|
||||
Span("Python", cls="text-sm opacity-60 ml-2"),
|
||||
cls="border-b pb-2 mb-2"
|
||||
),
|
||||
Textarea(
|
||||
"""def main():
|
||||
print("Hello, World!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()""",
|
||||
rows=20,
|
||||
cls="w-full font-mono text-sm p-2 border rounded"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Properties and Tools
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties", cls="font-bold mb-2"),
|
||||
Div("Language: Python", cls="text-sm mb-1"),
|
||||
Div("Lines: 5", cls="text-sm mb-1"),
|
||||
Div("Size: 87 bytes", cls="text-sm mb-4"),
|
||||
|
||||
H3("Tools", cls="font-bold mb-2 mt-4"),
|
||||
Button("Run", cls="btn btn-sm btn-primary w-full mb-2"),
|
||||
Button("Debug", cls="btn btn-sm w-full mb-2"),
|
||||
Button("Format", cls="btn btn-sm w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 2: Dashboard with Filters
|
||||
|
||||
A data dashboard with filters sidebar and details panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: Filters
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Filters", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Label("Date Range", cls="label"),
|
||||
Select(
|
||||
Option("Last 7 days"),
|
||||
Option("Last 30 days"),
|
||||
Option("Last 90 days"),
|
||||
cls="select select-bordered w-full"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Div(
|
||||
Label("Category", cls="label"),
|
||||
Div(
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Sales", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Marketing", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Support", cls="label cursor-pointer"),
|
||||
cls="space-y-2"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Button("Apply Filters", cls="btn btn-primary w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Dashboard Charts
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Analytics Dashboard", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
Div(
|
||||
Div("Total Revenue", cls="stat-title"),
|
||||
Div("$45,231", cls="stat-value"),
|
||||
Div("+12% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
Div(
|
||||
Div("Active Users", cls="stat-title"),
|
||||
Div("2,345", cls="stat-value"),
|
||||
Div("+8% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
cls="stats shadow mb-4"
|
||||
),
|
||||
|
||||
Div("[Chart placeholder - Revenue over time]", cls="border rounded p-8 text-center"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Details and Insights
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Key Insights", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Div("🎯 Top Performing", cls="font-bold mb-1"),
|
||||
Div("Product A: $12,450", cls="text-sm"),
|
||||
Div("Product B: $8,920", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("📊 Trending Up", cls="font-bold mb-1"),
|
||||
Div("Category: Electronics", cls="text-sm"),
|
||||
Div("+23% this week", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("⚠️ Needs Attention", cls="font-bold mb-1"),
|
||||
Div("Low stock: Item X", cls="text-sm"),
|
||||
Div("Response time: +15%", cls="text-sm")
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 3: Simple Layout (Main Content Only)
|
||||
|
||||
A minimal panel with no side panels, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
|
||||
# Create panel with both side panels disabled
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only main content
|
||||
panel.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog", cls="text-3xl font-bold mb-4"),
|
||||
P("This is a simple layout focusing entirely on the main content."),
|
||||
P("No side panels distract from the reading experience."),
|
||||
P("The content takes up the full width of the container."),
|
||||
cls="prose max-w-none p-8"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Panel Updates
|
||||
|
||||
Controlling panels programmatically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set up content
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Dashboard"),
|
||||
Li("Reports"),
|
||||
Li("Settings")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Debug Info"),
|
||||
Div("Session ID: abc123"),
|
||||
Div("User: Admin"),
|
||||
Div("Timestamp: 2024-01-15")
|
||||
)
|
||||
)
|
||||
|
||||
# Create control buttons
|
||||
toggle_left_btn = mk.button(
|
||||
"Toggle Left Panel",
|
||||
command=panel.commands.set_side_visible("left", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
toggle_right_btn = mk.button(
|
||||
"Toggle Right Panel",
|
||||
command=panel.commands.set_side_visible("right", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
show_all_btn = mk.button(
|
||||
"Show All Panels",
|
||||
command=Command(
|
||||
"show_all",
|
||||
"Show all panels",
|
||||
lambda: (
|
||||
panel.toggle_side("left", True),
|
||||
panel.toggle_side("right", True)
|
||||
)
|
||||
),
|
||||
cls="btn btn-sm btn-primary"
|
||||
)
|
||||
|
||||
# Main content with controls
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Panel Controls Demo", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
toggle_left_btn,
|
||||
toggle_right_btn,
|
||||
show_all_btn,
|
||||
cls="space-x-2 mb-4"
|
||||
),
|
||||
|
||||
P("Use the buttons above to toggle panels programmatically."),
|
||||
P("You can also use the hide (−) and show (⋯) icons."),
|
||||
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Panel component itself.
|
||||
|
||||
### Configuration
|
||||
|
||||
The Panel component uses `PanelConf` dataclass for configuration:
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|----------------------|---------|-------------------------------------------|-----------|
|
||||
| `left` | boolean | Enable/disable left panel | `False` |
|
||||
| `right` | boolean | Enable/disable right panel | `True` |
|
||||
| `left_title` | string | Title displayed in left panel header | `"Left"` |
|
||||
| `right_title` | string | Title displayed in right panel header | `"Right"` |
|
||||
| `show_left_title` | boolean | Show title header on left panel | `True` |
|
||||
| `show_right_title` | boolean | Show title header on right panel | `True` |
|
||||
| `show_display_left` | boolean | Show the "show" icon when left is hidden | `True` |
|
||||
| `show_display_right` | boolean | Show the "show" icon when right is hidden | `True` |
|
||||
|
||||
### State
|
||||
|
||||
The Panel component maintains the following state properties via `PanelState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-----------------|---------|------------------------------------|---------|
|
||||
| `left_visible` | boolean | True if the left panel is visible | `True` |
|
||||
| `right_visible` | boolean | True if the right panel is visible | `True` |
|
||||
| `left_width` | integer | Width of the left panel in pixels | `250` |
|
||||
| `right_width` | integer | Width of the right panel in pixels | `250` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------------|-------------------------------------------------------------------|
|
||||
| `toggle_side(side, visible)` | Sets panel visibility (side: "left"/"right", visible: True/False) |
|
||||
| `update_side_width(side)` | Updates panel width from HTMX request (side: "left"/"right") |
|
||||
|
||||
**Note:** The old `toggle_side(side)` command without the `visible` parameter is deprecated but still available in the
|
||||
codebase.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|----------------------|------------------------------|---------|
|
||||
| `set_main(content)` | Sets the main content area | `self` |
|
||||
| `set_left(content)` | Sets the left panel content | `Div` |
|
||||
| `set_right(content)` | Sets the right panel content | `Div` |
|
||||
| `render()` | Renders the complete panel | `Div` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
**With title (default, `show_*_title=True`):**
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-panel")
|
||||
├── Div(id="{id}_pl", cls="mf-panel-left mf-panel-with-title [mf-hidden]")
|
||||
│ ├── Div(cls="mf-panel-body")
|
||||
│ │ ├── Div(cls="mf-panel-header")
|
||||
│ │ │ ├── Div [Title text]
|
||||
│ │ │ └── Div (hide icon)
|
||||
│ │ └── Div(id="{id}_cl", cls="mf-panel-content")
|
||||
│ │ └── [Left content - scrollable]
|
||||
│ └── Div (resizer-left)
|
||||
├── Div(cls="mf-panel-main")
|
||||
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||
│ │ └── [Main content]
|
||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||
├── Div(id="{id}_pr", cls="mf-panel-right mf-panel-with-title [mf-hidden]")
|
||||
│ ├── Div (resizer-right)
|
||||
│ └── Div(cls="mf-panel-body")
|
||||
│ ├── Div(cls="mf-panel-header")
|
||||
│ │ ├── Div [Title text]
|
||||
│ │ └── Div (hide icon)
|
||||
│ └── Div(id="{id}_cr", cls="mf-panel-content")
|
||||
│ └── [Right content - scrollable]
|
||||
└── Script # initResizer('{id}')
|
||||
```
|
||||
|
||||
**Without title (legacy, `show_*_title=False`):**
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-panel")
|
||||
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ ├── Div(id="{id}_cl")
|
||||
│ │ └── [Left content]
|
||||
│ └── Div (resizer-left)
|
||||
├── Div(cls="mf-panel-main")
|
||||
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||
│ │ └── [Main content]
|
||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
||||
│ ├── Div (resizer-right)
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ └── Div(id="{id}_cr")
|
||||
│ └── [Right content]
|
||||
└── Script # initResizer('{id}')
|
||||
```
|
||||
|
||||
**Note:**
|
||||
|
||||
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
|
||||
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
|
||||
- Left panel: body/content then resizer (resizer on right edge)
|
||||
- Right panel: resizer then body/content (resizer on left edge)
|
||||
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
||||
- `mf-panel-with-title` class removes default padding-top when using title layout
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|-------------------------------------|
|
||||
| `{id}` | Root panel container |
|
||||
| `{id}_pl` | Left panel container |
|
||||
| `{id}_pr` | Right panel container |
|
||||
| `{id}_cl` | Left panel content wrapper |
|
||||
| `{id}_cr` | Right panel content wrapper |
|
||||
| `{id}_m` | Main content wrapper |
|
||||
| `{id}_show_left` | Show icon for left panel (in main) |
|
||||
| `{id}_show_right`| Show icon for right panel (in main) |
|
||||
|
||||
**Note:** `{id}` is the Panel instance ID (auto-generated UUID or custom `_id`).
|
||||
|
||||
**ID Management:**
|
||||
|
||||
The Panel component uses the `PanelIds` helper class to manage element IDs consistently. Access IDs programmatically:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Access IDs via get_ids()
|
||||
panel.get_ids().panel("left") # Returns "{id}_pl"
|
||||
panel.get_ids().panel("right") # Returns "{id}_pr"
|
||||
panel.get_ids().left # Returns "{id}_cl"
|
||||
panel.get_ids().right # Returns "{id}_cr"
|
||||
panel.get_ids().main # Returns "{id}_m"
|
||||
panel.get_ids().content("left") # Returns "{id}_cl"
|
||||
```
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------|---------------------------------------------------|
|
||||
| `_mk_panel(side)` | Renders a panel (left or right) with all elements |
|
||||
| `_mk_show_icon(side)` | Renders the show icon for a panel |
|
||||
|
||||
**Method details:**
|
||||
|
||||
- `_mk_panel(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Creates resizer with command and data attributes
|
||||
- Creates hide icon with toggle command
|
||||
- Applies `mf-hidden` class if panel is not visible
|
||||
- Returns None if panel is disabled
|
||||
|
||||
- `_mk_show_icon(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Returns None if panel is disabled or visible
|
||||
- Applies `hidden` (Tailwind) class if panel is visible
|
||||
- Applies positioning class based on side
|
||||
|
||||
### JavaScript Integration
|
||||
|
||||
The Panel component uses JavaScript for manual resizing:
|
||||
|
||||
**initResizer(panelId):**
|
||||
|
||||
- Initializes drag-and-drop resize functionality
|
||||
- Adds/removes `no-transition` class during drag
|
||||
- Sends width updates to server via HTMX
|
||||
- Constrains width between 150px and 500px
|
||||
|
||||
**File:** `src/myfasthtml/assets/myfasthtml.js`
|
||||
@@ -1,648 +0,0 @@
|
||||
# TabsManager Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML
|
||||
application. It handles tab creation, activation, closing, and content management with automatic state persistence and
|
||||
HTMX-powered interactions.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Dynamic tab creation and removal at runtime
|
||||
- Automatic content caching for performance
|
||||
- Session-based state persistence (tabs, order, active tab)
|
||||
- Duplicate tab detection based on component identity
|
||||
- Built-in search menu for quick tab navigation
|
||||
- Auto-increment labels for programmatic tab creation
|
||||
- HTMX-powered updates without page reload
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Multi-document editor (code editor, text editor)
|
||||
- Dashboard with multiple data views
|
||||
- Settings interface with different configuration panels
|
||||
- Developer tools with console, inspector, network tabs
|
||||
- Application with dynamic content sections
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a tabbed interface with three views:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create root instance and tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root)
|
||||
|
||||
# Create three tabs with different content
|
||||
tabs.create_tab("Dashboard", Div(H1("Dashboard"), P("Overview of your data")))
|
||||
tabs.create_tab("Settings", Div(H1("Settings"), P("Configure your preferences")))
|
||||
tabs.create_tab("Profile", Div(H1("Profile"), P("Manage your profile")))
|
||||
|
||||
# Render the tabs manager
|
||||
return tabs
|
||||
```
|
||||
|
||||
This creates a complete tabbed interface with:
|
||||
|
||||
- A header bar displaying three clickable tab buttons ("Dashboard", "Settings", "Profile")
|
||||
- Close buttons (×) on each tab for dynamic removal
|
||||
- A main content area showing the active tab's content
|
||||
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
|
||||
- Automatic HTMX updates when switching or closing tabs
|
||||
|
||||
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove
|
||||
tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX
|
||||
integration.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The TabsManager component consists of a header with tab buttons and a content area:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Tab Header │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
|
||||
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Active Tab Content │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|------------------|-----------------------------------------|
|
||||
| Tab buttons | Clickable labels to switch between tabs |
|
||||
| Close button (×) | Removes the tab and its content |
|
||||
| Search menu (⊞) | Dropdown menu to search and filter tabs |
|
||||
| Content area | Displays the active tab's content |
|
||||
|
||||
### Creating a TabsManager
|
||||
|
||||
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application.
|
||||
Create it by providing a parent instance:
|
||||
|
||||
```python
|
||||
tabs = TabsManager(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
tabs = TabsManager(parent=root_instance, _id="my-tabs")
|
||||
```
|
||||
|
||||
### Creating Tabs
|
||||
|
||||
Use the `create_tab()` method to add a new tab:
|
||||
|
||||
```python
|
||||
# Create a tab with custom content
|
||||
tab_id = tabs.create_tab(
|
||||
label="My Tab",
|
||||
component=Div(H1("Content"), P("Tab content here"))
|
||||
)
|
||||
|
||||
# Create with a MyFastHtml control
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
|
||||
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
|
||||
tab_id = tabs.create_tab("Network View", network)
|
||||
|
||||
# Create without activating immediately
|
||||
tab_id = tabs.create_tab("Background Tab", content, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `label` (str): Display text shown in the tab button
|
||||
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
|
||||
- `activate` (bool): Whether to make this tab active immediately (default: True)
|
||||
|
||||
**Returns:** A unique `tab_id` (UUID string) that identifies the tab
|
||||
|
||||
### Showing Tabs
|
||||
|
||||
Use the `show_tab()` method to activate and display a tab:
|
||||
|
||||
```python
|
||||
# Show a tab (makes it active and sends content to client if needed)
|
||||
tabs.show_tab(tab_id)
|
||||
|
||||
# Show without activating (just send content to client)
|
||||
tabs.show_tab(tab_id, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to show
|
||||
- `activate` (bool): Whether to make this tab active (default: True)
|
||||
|
||||
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just
|
||||
toggle visibility without re-sending content.
|
||||
|
||||
### Closing Tabs
|
||||
|
||||
Use the `close_tab()` method to remove a tab:
|
||||
|
||||
```python
|
||||
# Close a specific tab
|
||||
tabs.close_tab(tab_id)
|
||||
```
|
||||
|
||||
**What happens when closing:**
|
||||
|
||||
1. Tab is removed from the tab list and order
|
||||
2. Content is removed from cache and client
|
||||
3. If the closed tab was active, the first remaining tab becomes active
|
||||
4. If no tabs remain, `active_tab` is set to `None`
|
||||
|
||||
### Changing Tab Content
|
||||
|
||||
Use the `change_tab_content()` method to update an existing tab's content and label:
|
||||
|
||||
```python
|
||||
# Update tab content and label
|
||||
new_content = Div(H1("Updated"), P("New content"))
|
||||
tabs.change_tab_content(
|
||||
tab_id=tab_id,
|
||||
label="Updated Tab",
|
||||
component=new_content,
|
||||
activate=True
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to update
|
||||
- `label` (str): New label for the tab
|
||||
- `component` (Any): New content to display
|
||||
- `activate` (bool): Whether to activate the tab after updating (default: True)
|
||||
|
||||
**Note:** This method forces the new content to be sent to the client, even if the tab was already displayed.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Auto-increment Labels
|
||||
|
||||
When creating multiple tabs programmatically, you can use auto-increment to generate unique labels:
|
||||
|
||||
```python
|
||||
# Using the on_new_tab method with auto_increment
|
||||
def create_multiple_tabs():
|
||||
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The TabsManager maintains an internal counter (`_tab_count`)
|
||||
- When `auto_increment=True`, the counter value is appended to the label
|
||||
- Counter increments with each auto-incremented tab creation
|
||||
- Useful for "New Tab 1", "New Tab 2" patterns in editors or tools
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
The TabsManager automatically detects and reuses tabs with identical content to prevent duplicates:
|
||||
|
||||
```python
|
||||
# Create a control instance
|
||||
network = VisNetwork(parent=tabs, nodes=data, edges=edges)
|
||||
|
||||
# First call creates a new tab
|
||||
tab_id_1 = tabs.create_tab("Network", network)
|
||||
|
||||
# Second call with same label and component returns existing tab_id
|
||||
tab_id_2 = tabs.create_tab("Network", network)
|
||||
|
||||
# tab_id_1 == tab_id_2 (True - same tab!)
|
||||
```
|
||||
|
||||
**Detection criteria:**
|
||||
A tab is considered a duplicate if all three match:
|
||||
|
||||
- Same `label`
|
||||
- Same `component_type` (component class prefix)
|
||||
- Same `component_id` (component instance ID)
|
||||
|
||||
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs
|
||||
and will always create new tabs.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
You can update tabs dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial tab creation
|
||||
tab_id = tabs.create_tab("Data View", Div("Loading..."))
|
||||
|
||||
|
||||
# Later, update with actual data
|
||||
def load_data():
|
||||
data_content = Div(H2("Data"), P("Loaded content"))
|
||||
tabs.change_tab_content(tab_id, "Data View", data_content)
|
||||
# Returns HTMX response to update the UI
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Loading data asynchronously
|
||||
- Refreshing tab content based on user actions
|
||||
- Updating visualizations with new data
|
||||
- Switching between different views in the same tab
|
||||
|
||||
### Tab Search Menu
|
||||
|
||||
The built-in search menu helps users navigate when many tabs are open:
|
||||
|
||||
```python
|
||||
# The search menu is automatically created and includes:
|
||||
# - A Search control for filtering tabs by label
|
||||
# - Live filtering as you type
|
||||
# - Click to activate a tab from search results
|
||||
```
|
||||
|
||||
**How to access:**
|
||||
|
||||
- Click the ⊞ icon in the tab header
|
||||
- Start typing to filter tabs by label
|
||||
- Click a result to activate that tab
|
||||
|
||||
The search menu updates automatically when tabs are added or removed.
|
||||
|
||||
### HTMX Out-of-Band Swaps
|
||||
|
||||
For advanced HTMX control, you can customize swap behavior:
|
||||
|
||||
```python
|
||||
# Standard behavior (out-of-band swap enabled)
|
||||
tabs.show_tab(tab_id, oob=True) # Default
|
||||
|
||||
# Custom target behavior (disable out-of-band)
|
||||
tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
|
||||
```
|
||||
|
||||
**When to use `oob=False`:**
|
||||
|
||||
- When you want to control the exact HTMX target
|
||||
- When combining with other HTMX responses
|
||||
- When the tab activation is triggered by a command with a specific target
|
||||
|
||||
**When to use `oob=True` (default):**
|
||||
|
||||
- Most common use case
|
||||
- Allows other controls to trigger tab changes without caring about targets
|
||||
- Enables automatic UI updates across multiple elements
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The TabsManager uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|--------------------------|---------------------------------|
|
||||
| `mf-tabs-manager` | Root tabs manager container |
|
||||
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
|
||||
| `mf-tabs-header` | Tab buttons container |
|
||||
| `mf-tab-button` | Individual tab button |
|
||||
| `mf-tab-active` | Active tab button (modifier) |
|
||||
| `mf-tab-label` | Tab label text |
|
||||
| `mf-tab-close-btn` | Close button (×) |
|
||||
| `mf-tab-content-wrapper` | Content area container |
|
||||
| `mf-tab-content` | Individual tab content |
|
||||
| `mf-empty-content` | Empty state when no tabs |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change active tab color */
|
||||
.mf-tab-active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Customize close button */
|
||||
.mf-tab-close-btn:hover {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Style the content area */
|
||||
.mf-tab-content-wrapper {
|
||||
padding: 2rem;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Multi-view Application
|
||||
|
||||
A typical application with different views accessible through tabs:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="app-tabs")
|
||||
|
||||
# Dashboard view
|
||||
dashboard = Div(
|
||||
H1("Dashboard"),
|
||||
Div(
|
||||
Div("Total Users: 1,234", cls="stat"),
|
||||
Div("Active Sessions: 56", cls="stat"),
|
||||
Div("Revenue: $12,345", cls="stat"),
|
||||
cls="stats-grid"
|
||||
)
|
||||
)
|
||||
|
||||
# Analytics view
|
||||
analytics = Div(
|
||||
H1("Analytics"),
|
||||
P("Detailed analytics and reports"),
|
||||
Div("Chart placeholder", cls="chart-container")
|
||||
)
|
||||
|
||||
# Settings view
|
||||
settings = Div(
|
||||
H1("Settings"),
|
||||
Form(
|
||||
Label("Username:", Input(name="username", value="admin")),
|
||||
Label("Email:", Input(name="email", value="admin@example.com")),
|
||||
Button("Save", type="submit"),
|
||||
)
|
||||
)
|
||||
|
||||
# Create tabs
|
||||
tabs.create_tab("Dashboard", dashboard)
|
||||
tabs.create_tab("Analytics", analytics)
|
||||
tabs.create_tab("Settings", settings)
|
||||
|
||||
# Render
|
||||
return tabs
|
||||
```
|
||||
|
||||
### Example 2: Dynamic Tabs with VisNetwork
|
||||
|
||||
Creating tabs dynamically with interactive network visualizations:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="network-tabs")
|
||||
|
||||
# Create initial tab with welcome message
|
||||
tabs.create_tab("Welcome", Div(
|
||||
H1("Network Visualizer"),
|
||||
P("Click 'Add Network' to create a new network visualization")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new network tab
|
||||
def add_network_tab():
|
||||
# Define network data
|
||||
nodes = [
|
||||
{"id": 1, "label": "Node 1"},
|
||||
{"id": 2, "label": "Node 2"},
|
||||
{"id": 3, "label": "Node 3"}
|
||||
]
|
||||
edges = [
|
||||
{"from": 1, "to": 2},
|
||||
{"from": 2, "to": 3}
|
||||
]
|
||||
|
||||
# Create network instance
|
||||
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
|
||||
|
||||
# Use auto-increment to create unique labels
|
||||
return tabs.on_new_tab("Network", network, auto_increment=True)
|
||||
|
||||
|
||||
# Create command for adding networks
|
||||
add_cmd = Command("add_network", "Add network tab", add_network_tab)
|
||||
|
||||
# Add button to create new network tabs
|
||||
add_button = mk.button("Add Network", command=add_cmd, cls="btn btn-primary")
|
||||
|
||||
# Return tabs and button
|
||||
return Div(add_button, tabs)
|
||||
```
|
||||
|
||||
### Example 3: Tab Management with Content Updates
|
||||
|
||||
An application that updates tab content based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="editor-tabs")
|
||||
|
||||
# Create initial document tabs
|
||||
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
|
||||
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
|
||||
|
||||
|
||||
# Function to refresh a document's content
|
||||
def refresh_document(tab_id, doc_name):
|
||||
# Simulate loading new content
|
||||
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
|
||||
tabs.change_tab_content(tab_id, doc_name, new_content)
|
||||
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
|
||||
|
||||
|
||||
# Create refresh commands
|
||||
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
|
||||
refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id, "Document 2")
|
||||
|
||||
# Add refresh buttons
|
||||
controls = Div(
|
||||
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
|
||||
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
|
||||
cls="controls-bar"
|
||||
)
|
||||
|
||||
return Div(controls, tabs)
|
||||
```
|
||||
|
||||
### Example 4: Using Auto-increment for Dynamic Tabs
|
||||
|
||||
Creating multiple tabs programmatically with auto-generated labels:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="dynamic-tabs")
|
||||
|
||||
# Create initial placeholder tab
|
||||
tabs.create_tab("Start", Div(
|
||||
H2("Welcome"),
|
||||
P("Click 'New Tab' to create numbered tabs")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new numbered tab
|
||||
def create_numbered_tab():
|
||||
content = Div(
|
||||
H2("New Tab Content"),
|
||||
P(f"This tab was created dynamically"),
|
||||
Input(placeholder="Enter some text...", cls="input")
|
||||
)
|
||||
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
|
||||
return tabs.on_new_tab("Tab", content, auto_increment=True)
|
||||
|
||||
|
||||
# Create command
|
||||
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
||||
|
||||
# Add button
|
||||
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
|
||||
|
||||
return Div(
|
||||
Div(new_tab_button, cls="toolbar"),
|
||||
tabs
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TabsManager component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TabsManager component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|--------------------------|----------------|---------------------------------------------------|---------|
|
||||
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
|
||||
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
|
||||
| `active_tab` | str \| None | ID of the currently active tab | `None` |
|
||||
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
|
||||
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
|
||||
|
||||
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------------------------------|--------------------------------------------|
|
||||
| `show_tab(tab_id)` | Activate or show a specific tab |
|
||||
| `close_tab(tab_id)` | Close a specific tab |
|
||||
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------------------------------------------|-------------------------------------------------|
|
||||
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
|
||||
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
|
||||
| `close_tab(tab_id)` | Close and remove a tab |
|
||||
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
|
||||
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
|
||||
| `add_tab_btn()` | Returns add tab button element |
|
||||
| `get_state()` | Returns the TabsManagerState object |
|
||||
| `render()` | Renders the complete TabsManager component |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-tabs-manager")
|
||||
├── Div(id="{id}-controller") # Controller (hidden, manages active state)
|
||||
├── Div(id="{id}-header-wrapper") # Header wrapper
|
||||
│ ├── Div(id="{id}-header") # Tab buttons container
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 1
|
||||
│ │ │ ├── Span (mf-tab-label) # Label (clickable)
|
||||
│ │ │ └── Span (mf-tab-close-btn) # Close button
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 2
|
||||
│ │ └── ...
|
||||
│ └── Div (dropdown) # Search menu
|
||||
│ ├── Icon (tabs24_regular) # Menu toggle button
|
||||
│ └── Div (dropdown-content) # Search component
|
||||
├── Div(id="{id}-content-wrapper") # Content wrapper
|
||||
│ ├── Div(id="{id}-{tab_id_1}-content") # Tab 1 content
|
||||
│ ├── Div(id="{id}-{tab_id_2}-content") # Tab 2 content
|
||||
│ └── ...
|
||||
└── Script # Initialization script
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------------------|-----------------------------------------|
|
||||
| `{id}` | Root tabs manager container |
|
||||
| `{id}-controller` | Hidden controller managing active state |
|
||||
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
|
||||
| `{id}-header` | Tab buttons container |
|
||||
| `{id}-content-wrapper` | Content area wrapper |
|
||||
| `{id}-{tab_id}-content` | Individual tab content |
|
||||
| `{id}-search` | Search component ID |
|
||||
|
||||
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------------|-----------------------------------------------------|
|
||||
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
|
||||
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
|
||||
| `_mk_tab_button(tab_data)` | Renders a single tab button |
|
||||
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
|
||||
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
|
||||
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
|
||||
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
|
||||
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
|
||||
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
|
||||
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
|
||||
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
|
||||
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
|
||||
| `_get_tab_list()` | Returns list of tab dictionaries in order |
|
||||
| `_get_tab_count()` | Returns and increments internal tab counter |
|
||||
|
||||
### Tab Metadata Structure
|
||||
|
||||
Each tab in the `tabs` dictionary has the following structure:
|
||||
|
||||
```python
|
||||
{
|
||||
'id': 'uuid-string', # Unique tab identifier
|
||||
'label': 'Tab Label', # Display label
|
||||
'component_type': 'prefix', # Component class prefix (or None)
|
||||
'component_id': 'instance-id' # Component instance ID (or None)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from
|
||||
`BaseInstance`.
|
||||
596
docs/TreeView.md
596
docs/TreeView.md
@@ -1,596 +0,0 @@
|
||||
# TreeView Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Expand/collapse nodes with visual indicators
|
||||
- Add child and sibling nodes dynamically
|
||||
- Inline rename with keyboard support (ESC to cancel)
|
||||
- Delete nodes (only leaf nodes without children)
|
||||
- Node selection tracking
|
||||
- Persistent state per session
|
||||
- Configurable icons per node type
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- File/folder browser
|
||||
- Category/subcategory management
|
||||
- Organizational hierarchy viewer
|
||||
- Navigation menu builder
|
||||
- Document outline editor
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a file system tree:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create TreeView instance
|
||||
tree = TreeView(parent=root_instance, _id="file-tree")
|
||||
|
||||
# Add root folder
|
||||
root = TreeNode(id="root", label="Documents", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add some files
|
||||
file1 = TreeNode(id="file1", label="report.pdf", type="file")
|
||||
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
|
||||
tree.add_node(file1, parent_id="root")
|
||||
tree.add_node(file2, parent_id="root")
|
||||
|
||||
# Expand root to show children
|
||||
tree.expand_all()
|
||||
|
||||
# Render the tree
|
||||
return tree
|
||||
```
|
||||
|
||||
This creates an interactive tree where users can:
|
||||
- Click chevrons to expand/collapse folders
|
||||
- Click labels to select items
|
||||
- Use action buttons (visible on hover) to add, rename, or delete nodes
|
||||
|
||||
**Note:** All interactions use commands and update via HTMX without page reload.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a TreeView
|
||||
|
||||
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
|
||||
|
||||
```python
|
||||
tree = TreeView(parent=root_instance, _id="my-tree")
|
||||
```
|
||||
|
||||
### TreeNode Structure
|
||||
|
||||
Nodes are represented by the `TreeNode` dataclass:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.TreeView import TreeNode
|
||||
|
||||
node = TreeNode(
|
||||
id="unique-id", # Auto-generated UUID if not provided
|
||||
label="Node Label", # Display text
|
||||
type="default", # Type for icon mapping
|
||||
parent=None, # Parent node ID (None for root)
|
||||
children=[] # List of child node IDs
|
||||
)
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Add nodes using the `add_node()` method:
|
||||
|
||||
```python
|
||||
# Add root node
|
||||
root = TreeNode(id="root", label="Root", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add child node
|
||||
child = TreeNode(label="Child 1", type="item")
|
||||
tree.add_node(child, parent_id="root")
|
||||
|
||||
# Add with specific position
|
||||
sibling = TreeNode(label="Child 2", type="item")
|
||||
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
|
||||
```
|
||||
|
||||
### Visual Structure
|
||||
|
||||
```
|
||||
TreeView
|
||||
├── Root Node 1
|
||||
│ ├── [>] Child 1-1 # Collapsed node with children
|
||||
│ ├── [ ] Child 1-2 # Leaf node (no children)
|
||||
│ └── [v] Child 1-3 # Expanded node
|
||||
│ ├── [ ] Grandchild
|
||||
│ └── [ ] Grandchild
|
||||
└── Root Node 2
|
||||
└── [>] Child 2-1
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
- `[>]` - Collapsed node (has children)
|
||||
- `[v]` - Expanded node (has children)
|
||||
- `[ ]` - Leaf node (no children)
|
||||
|
||||
### Expanding Nodes
|
||||
|
||||
Control node expansion programmatically:
|
||||
|
||||
```python
|
||||
# Expand all nodes with children
|
||||
tree.expand_all()
|
||||
|
||||
# Expand specific nodes by adding to opened list
|
||||
tree._state.opened.append("node-id")
|
||||
```
|
||||
|
||||
**Note:** Users can also toggle nodes by clicking the chevron icon.
|
||||
|
||||
## Interactive Features
|
||||
|
||||
### Node Selection
|
||||
|
||||
Users can select nodes by clicking on labels. The selected node is visually highlighted:
|
||||
|
||||
```python
|
||||
# Programmatically select a node
|
||||
tree._state.selected = "node-id"
|
||||
|
||||
# Check current selection
|
||||
current = tree._state.selected
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Users can add nodes via action buttons (visible on hover):
|
||||
|
||||
**Add Child:**
|
||||
- Adds a new node as a child of the target node
|
||||
- Automatically expands the parent
|
||||
- Creates node with same type as parent
|
||||
|
||||
**Add Sibling:**
|
||||
- Adds a new node next to the target node (same parent)
|
||||
- Inserts after the target node
|
||||
- Cannot add sibling to root nodes
|
||||
|
||||
```python
|
||||
# Programmatically add child
|
||||
tree._add_child(parent_id="root", new_label="New Child")
|
||||
|
||||
# Programmatically add sibling
|
||||
tree._add_sibling(node_id="child1", new_label="New Sibling")
|
||||
```
|
||||
|
||||
### Renaming Nodes
|
||||
|
||||
Users can rename nodes via the edit button:
|
||||
|
||||
1. Click the edit icon (visible on hover)
|
||||
2. Input field appears with current label
|
||||
3. Press Enter to save (triggers command)
|
||||
4. Press ESC to cancel (keyboard shortcut)
|
||||
|
||||
```python
|
||||
# Programmatically start rename
|
||||
tree._start_rename("node-id")
|
||||
|
||||
# Save rename
|
||||
tree._save_rename("node-id", "New Label")
|
||||
|
||||
# Cancel rename
|
||||
tree._cancel_rename()
|
||||
```
|
||||
|
||||
### Deleting Nodes
|
||||
|
||||
Users can delete nodes via the delete button:
|
||||
|
||||
**Restrictions:**
|
||||
- Can only delete leaf nodes (no children)
|
||||
- Attempting to delete a node with children raises an error
|
||||
- Deleted node is removed from parent's children list
|
||||
|
||||
```python
|
||||
# Programmatically delete node
|
||||
tree._delete_node("node-id") # Raises ValueError if node has children
|
||||
```
|
||||
|
||||
## Content System
|
||||
|
||||
### Node Types and Icons
|
||||
|
||||
Assign types to nodes for semantic grouping and custom icon display:
|
||||
|
||||
```python
|
||||
# Define node types
|
||||
root = TreeNode(label="Project", type="project")
|
||||
folder = TreeNode(label="src", type="folder")
|
||||
file = TreeNode(label="main.py", type="python-file")
|
||||
|
||||
# Configure icons for types
|
||||
tree.set_icon_config({
|
||||
"project": "fluent.folder_open",
|
||||
"folder": "fluent.folder",
|
||||
"python-file": "fluent.document_python"
|
||||
})
|
||||
```
|
||||
|
||||
**Note:** Icon configuration is stored in state and persists within the session.
|
||||
|
||||
### Hierarchical Organization
|
||||
|
||||
Nodes automatically maintain parent-child relationships:
|
||||
|
||||
```python
|
||||
# Get node's children
|
||||
node = tree._state.items["node-id"]
|
||||
child_ids = node.children
|
||||
|
||||
# Get node's parent
|
||||
parent_id = node.parent
|
||||
|
||||
# Navigate tree programmatically
|
||||
for child_id in node.children:
|
||||
child_node = tree._state.items[child_id]
|
||||
print(child_node.label)
|
||||
```
|
||||
|
||||
### Finding Root Nodes
|
||||
|
||||
Root nodes are nodes without a parent:
|
||||
|
||||
```python
|
||||
root_nodes = [
|
||||
node_id for node_id, node in tree._state.items.items()
|
||||
if node.parent is None
|
||||
]
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
TreeView includes keyboard support for common operations:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `ESC` | Cancel rename operation |
|
||||
|
||||
Additional shortcuts can be added via the Keyboard component:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
|
||||
tree = TreeView(parent=root_instance)
|
||||
# ESC handler is automatically included for cancel rename
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
TreeView maintains persistent state within the session:
|
||||
|
||||
| State Property | Type | Description |
|
||||
|----------------|------|-------------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
|
||||
| `opened` | `list[str]` | IDs of expanded nodes |
|
||||
| `selected` | `str \| None` | Currently selected node ID |
|
||||
| `editing` | `str \| None` | Node being renamed (if any) |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
|
||||
|
||||
### Dynamic Updates
|
||||
|
||||
TreeView updates are handled via commands that return the updated tree:
|
||||
|
||||
```python
|
||||
# Commands automatically target the tree for HTMX swap
|
||||
cmd = tree.commands.toggle_node("node-id")
|
||||
# When executed, returns updated TreeView with new state
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
TreeView uses CSS classes for styling:
|
||||
|
||||
| Class | Element |
|
||||
|-------|---------|
|
||||
| `mf-treeview` | Root container |
|
||||
| `mf-treenode-container` | Container for node and its children |
|
||||
| `mf-treenode` | Individual node row |
|
||||
| `mf-treenode.selected` | Selected node highlight |
|
||||
| `mf-treenode-label` | Node label text |
|
||||
| `mf-treenode-input` | Input field during rename |
|
||||
| `mf-treenode-actions` | Action buttons container (hover) |
|
||||
|
||||
You can override these classes to customize appearance:
|
||||
|
||||
```css
|
||||
.mf-treenode.selected {
|
||||
background-color: #e0f2fe;
|
||||
border-left: 3px solid #0284c7;
|
||||
}
|
||||
|
||||
.mf-treenode-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: File System Browser
|
||||
|
||||
A file/folder browser with different node types:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create tree
|
||||
tree = TreeView(parent=root_instance, _id="file-browser")
|
||||
|
||||
# Configure icons
|
||||
tree.set_icon_config({
|
||||
"folder": "fluent.folder",
|
||||
"python": "fluent.document_python",
|
||||
"text": "fluent.document_text"
|
||||
})
|
||||
|
||||
# Build file structure
|
||||
root = TreeNode(id="root", label="my-project", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
src = TreeNode(id="src", label="src", type="folder")
|
||||
tree.add_node(src, parent_id="root")
|
||||
|
||||
main = TreeNode(label="main.py", type="python")
|
||||
utils = TreeNode(label="utils.py", type="python")
|
||||
tree.add_node(main, parent_id="src")
|
||||
tree.add_node(utils, parent_id="src")
|
||||
|
||||
readme = TreeNode(label="README.md", type="text")
|
||||
tree.add_node(readme, parent_id="root")
|
||||
|
||||
# Expand to show structure
|
||||
tree.expand_all()
|
||||
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 2: Category Management
|
||||
|
||||
Managing product categories with inline editing:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="categories")
|
||||
|
||||
# Root categories
|
||||
electronics = TreeNode(id="elec", label="Electronics", type="category")
|
||||
tree.add_node(electronics)
|
||||
|
||||
# Subcategories
|
||||
computers = TreeNode(label="Computers", type="subcategory")
|
||||
phones = TreeNode(label="Phones", type="subcategory")
|
||||
tree.add_node(computers, parent_id="elec")
|
||||
tree.add_node(phones, parent_id="elec")
|
||||
|
||||
# Products (leaf nodes)
|
||||
laptop = TreeNode(label="Laptops", type="product")
|
||||
desktop = TreeNode(label="Desktops", type="product")
|
||||
tree.add_node(laptop, parent_id=computers.id)
|
||||
tree.add_node(desktop, parent_id=computers.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 3: Document Outline Editor
|
||||
|
||||
Building a document outline with headings:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="outline")
|
||||
|
||||
# Document structure
|
||||
doc = TreeNode(id="doc", label="My Document", type="document")
|
||||
tree.add_node(doc)
|
||||
|
||||
# Chapters
|
||||
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
|
||||
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
|
||||
tree.add_node(ch1, parent_id="doc")
|
||||
tree.add_node(ch2, parent_id="doc")
|
||||
|
||||
# Sections
|
||||
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
|
||||
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
|
||||
tree.add_node(sec1_1, parent_id="ch1")
|
||||
tree.add_node(sec1_2, parent_id="ch1")
|
||||
|
||||
# Subsections
|
||||
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
|
||||
tree.add_node(subsec, parent_id=sec1_1.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Tree with Event Handling
|
||||
|
||||
Responding to tree events with custom logic:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||
|
||||
# Initial structure
|
||||
root = TreeNode(id="root", label="Tasks", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Function to handle selection
|
||||
def on_node_selected(node_id):
|
||||
# Custom logic when node is selected
|
||||
node = tree._state.items[node_id]
|
||||
tree._select_node(node_id)
|
||||
|
||||
# Update a detail panel elsewhere in the UI
|
||||
return Div(
|
||||
H3(f"Selected: {node.label}"),
|
||||
P(f"Type: {node.type}"),
|
||||
P(f"Children: {len(node.children)}")
|
||||
)
|
||||
|
||||
# Override select command with custom handler
|
||||
# (In practice, you'd extend the Commands class or use event callbacks)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TreeView component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TreeView component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|------|------|-------------|---------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
|
||||
| `opened` | `list[str]` | Expanded node IDs | `[]` |
|
||||
| `selected` | `str \| None` | Selected node ID | `None` |
|
||||
| `editing` | `str \| None` | Node being renamed | `None` |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `add_child(parent_id)` | Add child node to parent |
|
||||
| `add_sibling(node_id)` | Add sibling node after target |
|
||||
| `start_rename(node_id)` | Enter rename mode for node |
|
||||
| `save_rename(node_id)` | Save renamed node label |
|
||||
| `cancel_rename()` | Cancel rename operation |
|
||||
| `delete_node(node_id)` | Delete node (if no children) |
|
||||
| `select_node(node_id)` | Select a node |
|
||||
|
||||
All commands automatically target the TreeView component for HTMX updates.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
|
||||
| `expand_all()` | Expand all nodes with children |
|
||||
| `set_icon_config(config)` | Configure icons for node types |
|
||||
| `render()` | Render the complete TreeView |
|
||||
|
||||
### TreeNode Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
id: str # Unique identifier (auto-generated UUID)
|
||||
label: str = "" # Display text
|
||||
type: str = "default" # Node type for icon mapping
|
||||
parent: Optional[str] = None # Parent node ID
|
||||
children: list[str] = [] # Child node IDs
|
||||
```
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="treeview", cls="mf-treeview")
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root1")
|
||||
│ ├── Div(cls="mf-treenode")
|
||||
│ │ ├── Icon # Toggle chevron
|
||||
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
|
||||
│ │ └── Div(cls="mf-treenode-actions")
|
||||
│ │ ├── Icon # Add child
|
||||
│ │ ├── Icon # Rename
|
||||
│ │ └── Icon # Delete
|
||||
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
|
||||
│ └── ...
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root2")
|
||||
│ └── ...
|
||||
└── Keyboard # ESC handler
|
||||
```
|
||||
|
||||
### Element IDs and Attributes
|
||||
|
||||
| Attribute | Element | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `id` | Root Div | TreeView component ID |
|
||||
| `data-node-id` | Node container | Node's unique ID |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering and state management:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `_toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `_add_child(parent_id, new_label)` | Add child node implementation |
|
||||
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
|
||||
| `_start_rename(node_id)` | Enter rename mode |
|
||||
| `_save_rename(node_id, node_label)` | Save renamed node |
|
||||
| `_cancel_rename()` | Cancel rename operation |
|
||||
| `_delete_node(node_id)` | Delete node if no children |
|
||||
| `_select_node(node_id)` | Select a node |
|
||||
| `_render_action_buttons(node_id)` | Render hover action buttons |
|
||||
| `_render_node(node_id, level)` | Recursively render node and children |
|
||||
|
||||
### Commands Class
|
||||
|
||||
The `Commands` nested class provides command factory methods:
|
||||
|
||||
| Method | Returns |
|
||||
|--------|---------|
|
||||
| `toggle_node(node_id)` | Command to toggle node |
|
||||
| `add_child(parent_id)` | Command to add child |
|
||||
| `add_sibling(node_id)` | Command to add sibling |
|
||||
| `start_rename(node_id)` | Command to start rename |
|
||||
| `save_rename(node_id)` | Command to save rename |
|
||||
| `cancel_rename()` | Command to cancel rename |
|
||||
| `delete_node(node_id)` | Command to delete node |
|
||||
| `select_node(node_id)` | Command to select node |
|
||||
|
||||
All commands are automatically configured with HTMX targeting.
|
||||
|
||||
### Integration with Keyboard Component
|
||||
|
||||
TreeView includes a Keyboard component for ESC key handling:
|
||||
|
||||
```python
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
|
||||
```
|
||||
|
||||
This enables users to press ESC to cancel rename operations without clicking.
|
||||
@@ -1,895 +0,0 @@
|
||||
# Testing Rendered Components with Matcher
|
||||
|
||||
## Introduction
|
||||
|
||||
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
|
||||
|
||||
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
|
||||
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
|
||||
|
||||
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
|
||||
|
||||
### Why use matcher?
|
||||
|
||||
**Without matcher:**
|
||||
```python
|
||||
# Fragile - breaks if whitespace or attribute order changes
|
||||
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
|
||||
```
|
||||
|
||||
**With matcher:**
|
||||
```python
|
||||
# Robust - tests only what matters
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
actual = component.render()
|
||||
expected = Div(P("Text"))
|
||||
matches(actual, expected) # Passes - ignores id and class
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Function Reference
|
||||
|
||||
### matches() - Validate Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
|
||||
|
||||
**When to use it:**
|
||||
- Verifying component output in tests
|
||||
- Checking HTML structure
|
||||
- Validating attributes and content
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
- **`actual`**: The element to test (usually from `component.render()`)
|
||||
- **`expected`**: The pattern to match against (only include what you want to test)
|
||||
- **Returns**: `True` if matches, raises `AssertionError` if not
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Basic structure matching**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
# The actual rendered element
|
||||
actual = Div(P("Hello World"), id="container", cls="main")
|
||||
|
||||
# Expected pattern - tests only the structure
|
||||
expected = Div(P("Hello World"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - id and cls are ignored
|
||||
```
|
||||
|
||||
**Example 2: Testing specific attributes**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me",
|
||||
id="btn-1",
|
||||
cls="btn btn-primary",
|
||||
hx_post="/submit",
|
||||
hx_target="#result")
|
||||
|
||||
# Test only the HTMX attribute we care about
|
||||
expected = Button("Click me", hx_post="/submit")
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**Example 3: Nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, Form, Input, Button
|
||||
|
||||
actual = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
),
|
||||
id="page",
|
||||
cls="container"
|
||||
)
|
||||
|
||||
# Test only the important parts
|
||||
expected = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email"),
|
||||
Button("Submit")
|
||||
)
|
||||
)
|
||||
|
||||
matches(actual, expected) # ✅ Passes - ignores password field and attributes
|
||||
```
|
||||
|
||||
#### Predicates Reference
|
||||
|
||||
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
|
||||
|
||||
##### AttrPredicate - For attribute values
|
||||
|
||||
**Contains(value)** - Attribute contains the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container main-content active")
|
||||
expected = Div(cls=Contains("main-content"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**StartsWith(value)** - Attribute starts with the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
from fasthtml.common import Input
|
||||
|
||||
actual = Input(id="input-username-12345")
|
||||
expected = Input(id=StartsWith("input-username"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**DoesNotContain(value)** - Attribute does not contain the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import DoesNotContain
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container active")
|
||||
expected = Div(cls=DoesNotContain("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**AnyValue()** - Attribute exists with any non-None value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AnyValue
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click", data_action="submit-form", data_id="123")
|
||||
expected = Button("Click", data_action=AnyValue())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - just checks data_action exists
|
||||
```
|
||||
|
||||
##### ChildrenPredicate - For element children
|
||||
|
||||
**Empty()** - Element has no children and no attributes
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**NoChildren()** - Element has no children (but can have attributes)
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(id="container", cls="empty")
|
||||
expected = Div(NoChildren())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - has attributes but no children
|
||||
```
|
||||
|
||||
**AttributeForbidden(attr_name)** - Attribute must not be present
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me")
|
||||
expected = Button("Click me", AttributeForbidden("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - disabled attribute is not present
|
||||
```
|
||||
|
||||
#### Error Messages Explained
|
||||
|
||||
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
|
||||
actual = Div(Button("Submit", cls="btn-primary"), id="form")
|
||||
expected = Div(Button("Cancel", cls="btn-secondary"))
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Error output:**
|
||||
```
|
||||
Path : 'div.button'
|
||||
Error : The values are different
|
||||
|
||||
(div "id"="form" | (div
|
||||
(button "cls"="btn-prim | (button "cls"="btn-seco
|
||||
^^^^^^^^^^^^^^^^ |
|
||||
"Submit") | "Cancel")
|
||||
^^^^^^^ |
|
||||
) | )
|
||||
```
|
||||
|
||||
**Reading the error:**
|
||||
- **Left side**: Actual element
|
||||
- **Right side**: Expected pattern
|
||||
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
|
||||
- **Path**: Shows location in the tree (`div.button` = button inside div)
|
||||
|
||||
---
|
||||
|
||||
### find() - Search Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
|
||||
|
||||
**When to use it:**
|
||||
- Finding elements by attributes
|
||||
- Verifying element count
|
||||
- Extracting elements for further validation
|
||||
- Testing without strict hierarchy requirements
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
results = find(ft, expected)
|
||||
```
|
||||
|
||||
- **`ft`**: Element or list of elements to search in
|
||||
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
|
||||
- **Returns**: List of all matching elements
|
||||
- **Raises**: `AssertionError` if no matches found
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Find all elements of a type**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
page = Div(
|
||||
Div(P("First paragraph")),
|
||||
Div(P("Second paragraph")),
|
||||
P("Third paragraph")
|
||||
)
|
||||
|
||||
# Find all paragraphs
|
||||
paragraphs = find(page, P())
|
||||
|
||||
assert len(paragraphs) == 3
|
||||
```
|
||||
|
||||
**Example 2: Find by attribute**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
page = Div(
|
||||
Button("Cancel", cls="btn-secondary"),
|
||||
Div(Button("Submit", cls="btn-primary", id="submit"))
|
||||
)
|
||||
|
||||
# Find the primary button
|
||||
primary_buttons = find(page, Button(cls="btn-primary"))
|
||||
|
||||
assert len(primary_buttons) == 1
|
||||
assert primary_buttons[0].attrs["id"] == "submit"
|
||||
```
|
||||
|
||||
**Example 3: Find nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Form, Input
|
||||
|
||||
page = Div(
|
||||
Div(Input(name="search")),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password")
|
||||
)
|
||||
)
|
||||
|
||||
# Find all email inputs
|
||||
email_inputs = find(page, Input(type="email"))
|
||||
|
||||
assert len(email_inputs) == 1
|
||||
assert email_inputs[0].attrs["name"] == "email"
|
||||
```
|
||||
|
||||
**Example 4: Search in a list**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, P, Span
|
||||
|
||||
elements = [
|
||||
Div(P("First")),
|
||||
Div(P("Second")),
|
||||
Span(P("Third"))
|
||||
]
|
||||
|
||||
# Find all paragraphs across all elements
|
||||
all_paragraphs = find(elements, P())
|
||||
|
||||
assert len(all_paragraphs) == 3
|
||||
```
|
||||
|
||||
#### Common Patterns
|
||||
|
||||
**Verify element count:**
|
||||
|
||||
```python
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
```
|
||||
|
||||
**Check element exists:**
|
||||
|
||||
```python
|
||||
submit_buttons = find(page, Button(type="submit"))
|
||||
assert len(submit_buttons) > 0, "No submit button found"
|
||||
```
|
||||
|
||||
**Extract for further testing:**
|
||||
|
||||
```python
|
||||
form = find(page, Form())[0] # Get first form
|
||||
inputs = find(form, Input()) # Find inputs within form
|
||||
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
|
||||
```
|
||||
|
||||
**Handle missing elements:**
|
||||
|
||||
```python
|
||||
try:
|
||||
admin_section = find(page, Div(id="admin"))
|
||||
print("Admin section found")
|
||||
except AssertionError:
|
||||
print("Admin section not present")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Testing Rendered Components Guide
|
||||
|
||||
This section provides practical patterns for testing different aspects of rendered components.
|
||||
|
||||
### 1. Testing Element Structure
|
||||
|
||||
**Goal**: Verify the hierarchy and organization of elements.
|
||||
|
||||
**Pattern**: Use `matches()` with only the structural elements you care about.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, Header, Nav, Main, Footer
|
||||
|
||||
# Your component renders a page layout
|
||||
actual = page_component.render()
|
||||
|
||||
# Test only the main structure
|
||||
expected = Div(
|
||||
Header(Nav()),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Tip**: Don't include every single child element. Focus on the important structural elements.
|
||||
|
||||
### 2. Testing Attributes
|
||||
|
||||
**Goal**: Verify that specific attributes are set correctly.
|
||||
|
||||
**Pattern**: Include only the attributes you want to test.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
# Component renders a button with HTMX
|
||||
actual = button_component.render()
|
||||
|
||||
# Test only HTMX attributes
|
||||
expected = Button(
|
||||
hx_post="/api/submit",
|
||||
hx_target="#result",
|
||||
hx_swap="innerHTML"
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**With predicates for dynamic values:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
|
||||
# Test that id follows a pattern
|
||||
expected = Button(id=StartsWith("btn-"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 3. Testing Content
|
||||
|
||||
**Goal**: Verify text content in elements.
|
||||
|
||||
**Pattern**: Match elements with their text content.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, P
|
||||
|
||||
actual = article_component.render()
|
||||
|
||||
expected = Div(
|
||||
H1("Article Title"),
|
||||
P("First paragraph content")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Partial content matching:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
|
||||
# Check that paragraph contains key phrase
|
||||
expected = P(Contains("important information"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 4. Testing with Predicates
|
||||
|
||||
**Goal**: Validate patterns rather than exact values.
|
||||
|
||||
**Pattern**: Use predicates for flexibility.
|
||||
|
||||
**Example: Testing generated IDs**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith, AnyValue
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Component generates unique IDs
|
||||
actual = widget_component.render()
|
||||
|
||||
expected = Div(
|
||||
id=StartsWith("widget-"),
|
||||
data_timestamp=AnyValue() # Just check it exists
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Testing CSS classes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = dynamic_button.render()
|
||||
|
||||
# Check button has 'active' class among others
|
||||
expected = Button(cls=Contains("active"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Forbidden attributes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Input
|
||||
|
||||
# Verify input is NOT disabled
|
||||
actual = input_component.render()
|
||||
|
||||
expected = Input(
|
||||
name="username",
|
||||
AttributeForbidden("disabled")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 5. Combining matches() and find()
|
||||
|
||||
**Goal**: First find elements, then validate them in detail.
|
||||
|
||||
**Pattern**: Use `find()` to locate, then `matches()` to validate.
|
||||
|
||||
**Example: Testing a form**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, matches
|
||||
from fasthtml.common import Form, Input, Button
|
||||
|
||||
# Render a complex page
|
||||
actual = page_component.render()
|
||||
|
||||
# Step 1: Find the registration form
|
||||
forms = find(actual, Form(id="registration"))
|
||||
assert len(forms) == 1
|
||||
|
||||
# Step 2: Validate the form structure
|
||||
registration_form = forms[0]
|
||||
expected_form = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Register", type="submit")
|
||||
)
|
||||
|
||||
matches(registration_form, expected_form)
|
||||
```
|
||||
|
||||
**Example: Testing multiple similar elements**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Card
|
||||
|
||||
actual = dashboard.render()
|
||||
|
||||
# Find all cards
|
||||
cards = find(actual, Card())
|
||||
|
||||
# Verify we have the right number
|
||||
assert len(cards) == 3
|
||||
|
||||
# Validate each card has required structure
|
||||
for card in cards:
|
||||
expected_card = Card(
|
||||
Div(cls="card-header"),
|
||||
Div(cls="card-body")
|
||||
)
|
||||
matches(card, expected_card)
|
||||
```
|
||||
|
||||
### 6. Testing Edge Cases
|
||||
|
||||
**Testing empty elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty, NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Test completely empty element
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
matches(actual, expected)
|
||||
|
||||
# Test element with attributes but no children
|
||||
actual = Div(id="container", cls="empty-state")
|
||||
expected = Div(NoChildren())
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Testing absence of elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = basic_view.render()
|
||||
|
||||
# Verify admin section is not present
|
||||
try:
|
||||
find(actual, Div(id="admin-section"))
|
||||
assert False, "Admin section should not be present"
|
||||
except AssertionError as e:
|
||||
if "No element found" in str(e):
|
||||
pass # Expected - admin section absent
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: How It Works (Technical Overview)
|
||||
|
||||
Understanding how the matcher works helps you write better tests and debug failures more effectively.
|
||||
|
||||
### The Matching Algorithm
|
||||
|
||||
When you call `matches(actual, expected)`, here's what happens:
|
||||
|
||||
1. **Type Comparison**: Checks if elements have the same type/tag
|
||||
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
|
||||
- For Python objects: compares class types
|
||||
|
||||
2. **Attribute Validation**: For each attribute in `expected`:
|
||||
- Checks if attribute exists in `actual`
|
||||
- If it's a `Predicate`: calls `.validate(actual_value)`
|
||||
- Otherwise: checks for exact equality
|
||||
- **Key point**: Only attributes in `expected` are checked
|
||||
|
||||
3. **Children Validation**:
|
||||
- Applies `ChildrenPredicate` validators if present
|
||||
- Recursively matches children in order
|
||||
- **Key point**: Only checks as many children as defined in `expected`
|
||||
|
||||
4. **Path Tracking**: Maintains a path through the tree for error reporting
|
||||
|
||||
### Understanding the Path
|
||||
|
||||
The matcher builds a path as it traverses your element tree:
|
||||
|
||||
```
|
||||
div#container.form.input[name=email]
|
||||
```
|
||||
|
||||
This path means:
|
||||
- Started at a `div` with `id="container"`
|
||||
- Went to a `form` element
|
||||
- Then to an `input` with `name="email"`
|
||||
|
||||
The path appears in error messages to help you locate problems:
|
||||
|
||||
```
|
||||
Path : 'div#form.input[name=email]'
|
||||
Error : 'type' is not found in Actual
|
||||
```
|
||||
|
||||
### How Predicates Work
|
||||
|
||||
Predicates are objects that implement a `validate()` method:
|
||||
|
||||
```python
|
||||
class Contains(AttrPredicate):
|
||||
def validate(self, actual):
|
||||
return self.value in actual
|
||||
```
|
||||
|
||||
When matching encounters a predicate:
|
||||
1. Gets the actual attribute value
|
||||
2. Calls `predicate.validate(actual_value)`
|
||||
3. If returns `False`, reports validation error
|
||||
|
||||
This allows flexible matching without hardcoding exact values.
|
||||
|
||||
### Error Output Generation
|
||||
|
||||
When a test fails, the matcher generates a side-by-side comparison:
|
||||
|
||||
**Process:**
|
||||
1. Renders both `actual` and `expected` as tree structures
|
||||
2. Compares them element by element
|
||||
3. Marks differences with `^^^` characters
|
||||
4. Aligns output for easy visual comparison
|
||||
|
||||
**Example:**
|
||||
```
|
||||
(div "id"="old" | (div "id"="new"
|
||||
^^^^ |
|
||||
```
|
||||
|
||||
The `^^^` appears under attributes or content that don't match.
|
||||
|
||||
### The find() Algorithm
|
||||
|
||||
`find()` uses depth-first search:
|
||||
|
||||
1. **Check current element**: Does it match the pattern?
|
||||
- If yes: add to results
|
||||
|
||||
2. **Search children**: Recursively search all children
|
||||
|
||||
3. **Return all matches**: Collects matches from entire tree
|
||||
|
||||
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
**Test only what matters**
|
||||
```python
|
||||
# ✅ Good - tests only the submit action
|
||||
expected = Button("Submit", hx_post="/api/save")
|
||||
|
||||
# ❌ Bad - tests irrelevant details
|
||||
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
|
||||
```
|
||||
|
||||
**Use predicates for dynamic values**
|
||||
```python
|
||||
# ✅ Good - flexible for generated IDs
|
||||
expected = Div(id=StartsWith("generated-"))
|
||||
|
||||
# ❌ Bad - brittle, will break on regeneration
|
||||
expected = Div(id="generated-12345")
|
||||
```
|
||||
|
||||
**Structure tests in layers**
|
||||
```python
|
||||
# ✅ Good - separate concerns
|
||||
# Test 1: Overall structure
|
||||
matches(page, Div(Header(), Main(), Footer()))
|
||||
|
||||
# Test 2: Header details
|
||||
header = find(page, Header())[0]
|
||||
matches(header, Header(Nav(), Div(cls="user-menu")))
|
||||
|
||||
# ❌ Bad - everything in one giant test
|
||||
matches(page, Div(
|
||||
Header(Nav(...), Div(...)),
|
||||
Main(...),
|
||||
Footer(...)
|
||||
))
|
||||
```
|
||||
|
||||
**Verify element counts with find()**
|
||||
```python
|
||||
# ✅ Good - explicit count check
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
|
||||
# ❌ Bad - implicit assumption
|
||||
button = find(page, Button())[0] # Fails if 0 or multiple buttons
|
||||
```
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
**Don't test implementation details**
|
||||
```python
|
||||
# ❌ Bad - internal div structure might change
|
||||
expected = Div(
|
||||
Div(
|
||||
Div(Button("Click"))
|
||||
)
|
||||
)
|
||||
|
||||
# ✅ Good - test the important element
|
||||
expected = Div(Button("Click"))
|
||||
```
|
||||
|
||||
**Don't use exact string matching**
|
||||
```python
|
||||
# ❌ Bad - fragile
|
||||
assert str(component.render()) == '<div><p>Text</p></div>'
|
||||
|
||||
# ✅ Good - structural matching
|
||||
matches(component.render(), Div(P("Text")))
|
||||
```
|
||||
|
||||
**Don't over-specify**
|
||||
```python
|
||||
# ❌ Bad - tests too much
|
||||
expected = Form(
|
||||
Input(name="email", type="email", id="email-input", cls="form-control"),
|
||||
Input(name="password", type="password", id="pwd-input", cls="form-control"),
|
||||
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
|
||||
)
|
||||
|
||||
# ✅ Good - tests what matters
|
||||
expected = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
)
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
**Reuse patterns**
|
||||
```python
|
||||
# Define reusable patterns
|
||||
STANDARD_BUTTON = Button(cls=Contains("btn"))
|
||||
|
||||
# Use in multiple tests
|
||||
matches(actual, Div(STANDARD_BUTTON))
|
||||
```
|
||||
|
||||
**Use find() efficiently**
|
||||
```python
|
||||
# ✅ Good - specific pattern
|
||||
buttons = find(page, Button(cls="primary"))
|
||||
|
||||
# ❌ Inefficient - too broad then filter
|
||||
all_buttons = find(page, Button())
|
||||
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Import Statements
|
||||
|
||||
```python
|
||||
# Core functions
|
||||
from myfasthtml.test.matcher import matches, find
|
||||
|
||||
# AttrPredicates
|
||||
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
|
||||
|
||||
# ChildrenPredicates
|
||||
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
|
||||
|
||||
# For custom test objects
|
||||
from myfasthtml.test.matcher import TestObject
|
||||
```
|
||||
|
||||
### Common Patterns Cheatsheet
|
||||
|
||||
```python
|
||||
# Basic validation
|
||||
matches(actual, expected)
|
||||
|
||||
# Find and validate
|
||||
elements = find(tree, pattern)
|
||||
assert len(elements) == 1
|
||||
matches(elements[0], detailed_pattern)
|
||||
|
||||
# Flexible attribute matching
|
||||
expected = Div(
|
||||
id=StartsWith("prefix-"),
|
||||
cls=Contains("active"),
|
||||
data_value=AnyValue()
|
||||
)
|
||||
|
||||
# Empty element validation
|
||||
expected = Div(Empty()) # No children, no attributes
|
||||
expected = Div(NoChildren()) # No children, attributes OK
|
||||
|
||||
# Forbidden attribute
|
||||
expected = Button(AttributeForbidden("disabled"))
|
||||
|
||||
# Multiple children
|
||||
expected = Div(
|
||||
Header(),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The matcher module provides powerful tools for testing FastHTML components:
|
||||
|
||||
- **`matches()`** validates structure with precise, readable error messages
|
||||
- **`find()`** locates elements anywhere in your HTML tree
|
||||
- **Predicates** enable flexible, maintainable tests
|
||||
|
||||
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
|
||||
|
||||
**Next steps:**
|
||||
- Practice with simple components first
|
||||
- Gradually introduce predicates as needed
|
||||
- Review error messages carefully - they guide you to the problem
|
||||
- Refactor tests to remove duplication
|
||||
|
||||
Happy testing! 🧪
|
||||
@@ -43,7 +43,6 @@ dependencies = [
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
"lark",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -33,25 +33,21 @@ jaraco.context==6.0.1
|
||||
jaraco.functools==4.3.0
|
||||
jeepney==0.9.0
|
||||
keyring==25.6.0
|
||||
lark==1.3.1
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.1
|
||||
mydbengine==0.2.1
|
||||
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
|
||||
myutils==0.5.1
|
||||
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
|
||||
pandas-stubs==2.3.3.251201
|
||||
passlib==1.7.4
|
||||
pipdeptree==2.29.0
|
||||
pluggy==1.6.0
|
||||
pyarrow==22.0.0
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.3
|
||||
@@ -81,7 +77,6 @@ soupsieve==2.8
|
||||
starlette==0.48.0
|
||||
twine==6.2.0
|
||||
typer==0.20.0
|
||||
types-pytz==2025.2.0.20251108
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.2
|
||||
|
||||
50
src/app.py
50
src/app.py
@@ -1,14 +1,9 @@
|
||||
import json
|
||||
import logging.config
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from dbengine.handlers import BaseRefHandler, handlers
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||
@@ -17,7 +12,6 @@ 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.dbengine_utils import DataFrameHandler
|
||||
from myfasthtml.core.instances import UniqueInstance
|
||||
from myfasthtml.icons.carbon import volume_object_storage
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
@@ -39,7 +33,6 @@ app, rt = create_app(protect_routes=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
@@ -51,48 +44,46 @@ def create_sample_treeview(parent):
|
||||
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()
|
||||
|
||||
#tree_view.expand_all()
|
||||
|
||||
return tree_view
|
||||
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session,
|
||||
_id=Ids.UserSession,
|
||||
on_init=lambda: handlers.register_handler(DataFrameHandler()))
|
||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||
layout = Layout(session_instance, "Testing Layout")
|
||||
layout.footer_left.add("Goodbye World")
|
||||
layout.set_footer("Goodbye World")
|
||||
|
||||
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||
add_tab = tabs_manager.commands.add_tab
|
||||
@@ -119,10 +110,10 @@ def index(session):
|
||||
|
||||
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")
|
||||
@@ -130,16 +121,7 @@ def index(session):
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
layout.left_drawer.add(btn_popup, "Test")
|
||||
layout.left_drawer.add(tree_view, "TreeView")
|
||||
|
||||
# data grids
|
||||
dgs_manager = DataGridsManager(layout, _id="-datagrids")
|
||||
layout.left_drawer.add_group("Documents", Div("Documents",
|
||||
dgs_manager.mk_main_icons(),
|
||||
cls="mf-layout-group flex gap-3"))
|
||||
layout.left_drawer.add(dgs_manager, "Documents")
|
||||
layout.set_main(tabs_manager)
|
||||
|
||||
# keyboard shortcuts
|
||||
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")))
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Commands used
|
||||
```
|
||||
cd src/myfasthtml/assets
|
||||
|
||||
# Url to get codemirror resources : https://cdnjs.com/libraries/codemirror
|
||||
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.js
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.css
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.css
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/addon/lint/lint.min.css
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/addon/lint/lint.min.js
|
||||
```
|
||||
1
src/myfasthtml/assets/codemirror.min.css
vendored
1
src/myfasthtml/assets/codemirror.min.css
vendored
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror.min.js
vendored
1
src/myfasthtml/assets/codemirror.min.js
vendored
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/lint.min.css
vendored
1
src/myfasthtml/assets/lint.min.css
vendored
@@ -1 +0,0 @@
|
||||
.CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:#ffd;border:1px solid #000;border-radius:4px 4px 4px 4px;color:#000;font-family:monospace;font-size:10pt;overflow:hidden;padding:2px 5px;position:fixed;white-space:pre;white-space:pre-wrap;z-index:100;max-width:600px;opacity:0;transition:opacity .4s;-moz-transition:opacity .4s;-webkit-transition:opacity .4s;-o-transition:opacity .4s;-ms-transition:opacity .4s}.CodeMirror-lint-mark{background-position:left bottom;background-repeat:repeat-x}.CodeMirror-lint-mark-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=)}.CodeMirror-lint-mark-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==)}.CodeMirror-lint-marker{background-position:center center;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;width:16px;vertical-align:middle;position:relative}.CodeMirror-lint-message{padding-left:18px;background-position:top left;background-repeat:no-repeat}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=)}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=)}.CodeMirror-lint-marker-multiple{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:right bottom;width:100%;height:100%}.CodeMirror-lint-line-error{background-color:rgba(183,76,81,.08)}.CodeMirror-lint-line-warning{background-color:rgba(255,211,0,.1)}
|
||||
1
src/myfasthtml/assets/lint.min.js
vendored
1
src/myfasthtml/assets/lint.min.js
vendored
@@ -1 +0,0 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(h){"use strict";var g="CodeMirror-lint-markers",v="CodeMirror-lint-line-";function u(t){t.parentNode&&t.parentNode.removeChild(t)}function C(t,e,n,o){t=t,e=e,n=n,(i=document.createElement("div")).className="CodeMirror-lint-tooltip cm-s-"+t.options.theme,i.appendChild(n.cloneNode(!0)),(t.state.lint.options.selfContain?t.getWrapperElement():document.body).appendChild(i),h.on(document,"mousemove",a),a(e),null!=i.style.opacity&&(i.style.opacity=1);var i,r=i;function a(t){if(!i.parentNode)return h.off(document,"mousemove",a);i.style.top=Math.max(0,t.clientY-i.offsetHeight-5)+"px",i.style.left=t.clientX+5+"px"}function s(){var t;h.off(o,"mouseout",s),r&&((t=r).parentNode&&(null==t.style.opacity&&u(t),t.style.opacity=0,setTimeout(function(){u(t)},600)),r=null)}var l=setInterval(function(){if(r)for(var t=o;;t=t.parentNode){if((t=t&&11==t.nodeType?t.host:t)==document.body)return;if(!t){s();break}}if(!r)return clearInterval(l)},400);h.on(o,"mouseout",s)}function a(l,t,e){for(var n in this.marked=[],(t=t instanceof Function?{getAnnotations:t}:t)&&!0!==t||(t={}),this.options={},this.linterOptions=t.options||{},o)this.options[n]=o[n];for(var n in t)o.hasOwnProperty(n)?null!=t[n]&&(this.options[n]=t[n]):t.options||(this.linterOptions[n]=t[n]);this.timeout=null,this.hasGutter=e,this.onMouseOver=function(t){var e=l,n=t.target||t.srcElement;if(/\bCodeMirror-lint-mark-/.test(n.className)){for(var n=n.getBoundingClientRect(),o=(n.left+n.right)/2,n=(n.top+n.bottom)/2,i=e.findMarksAt(e.coordsChar({left:o,top:n},"client")),r=[],a=0;a<i.length;++a){var s=i[a].__annotation;s&&r.push(s)}r.length&&!function(t,e,n){for(var o=n.target||n.srcElement,i=document.createDocumentFragment(),r=0;r<e.length;r++){var a=e[r];i.appendChild(M(a))}C(t,n,i,o)}(e,r,t)}},this.waitingFor=0}var o={highlightLines:!1,tooltips:!0,delay:500,lintOnChange:!0,getAnnotations:null,async:!1,selfContain:null,formatAnnotation:null,onUpdateLinting:null};function y(t){var n,e=t.state.lint;e.hasGutter&&t.clearGutter(g),e.options.highlightLines&&(n=t).eachLine(function(t){var e=t.wrapClass&&/\bCodeMirror-lint-line-\w+\b/.exec(t.wrapClass);e&&n.removeLineClass(t,"wrap",e[0])});for(var o=0;o<e.marked.length;++o)e.marked[o].clear();e.marked.length=0}function M(t){var e=(e=t.severity)||"error",n=document.createElement("div");return n.className="CodeMirror-lint-message CodeMirror-lint-message-"+e,void 0!==t.messageHTML?n.innerHTML=t.messageHTML:n.appendChild(document.createTextNode(t.message)),n}function s(e){var t,n,o,i,r,a,s=e.state.lint;function l(){a=-1,o.off("change",l)}!s||(t=(i=s.options).getAnnotations||e.getHelper(h.Pos(0,0),"lint"))&&(i.async||t.async?(i=t,r=(o=e).state.lint,a=++r.waitingFor,o.on("change",l),i(o.getValue(),function(t,e){o.off("change",l),r.waitingFor==a&&(e&&t instanceof h&&(t=e),o.operation(function(){c(o,t)}))},r.linterOptions,o)):(n=t(e.getValue(),s.linterOptions,e))&&(n.then?n.then(function(t){e.operation(function(){c(e,t)})}):e.operation(function(){c(e,n)})))}function c(t,e){var n=t.state.lint;if(n){for(var o,i,r=n.options,a=(y(t),function(t){for(var e=[],n=0;n<t.length;++n){var o=t[n],i=o.from.line;(e[i]||(e[i]=[])).push(o)}return e}(e)),s=0;s<a.length;++s)if(u=a[s]){for(var l=[],u=u.filter(function(t){return!(-1<l.indexOf(t.message))&&l.push(t.message)}),c=null,f=n.hasGutter&&document.createDocumentFragment(),m=0;m<u.length;++m){var p=u[m],d=p.severity;i=d=d||"error",c="error"==(o=c)?o:i,r.formatAnnotation&&(p=r.formatAnnotation(p)),n.hasGutter&&f.appendChild(M(p)),p.to&&n.marked.push(t.markText(p.from,p.to,{className:"CodeMirror-lint-mark CodeMirror-lint-mark-"+d,__annotation:p}))}n.hasGutter&&t.setGutterMarker(s,g,function(e,n,t,o,i){var r=document.createElement("div"),a=r;return r.className="CodeMirror-lint-marker CodeMirror-lint-marker-"+t,o&&((a=r.appendChild(document.createElement("div"))).className="CodeMirror-lint-marker CodeMirror-lint-marker-multiple"),0!=i&&h.on(a,"mouseover",function(t){C(e,t,n,a)}),r}(t,f,c,1<a[s].length,r.tooltips)),r.highlightLines&&t.addLineClass(s,"wrap",v+c)}r.onUpdateLinting&&r.onUpdateLinting(e,a,t)}}function l(t){var e=t.state.lint;e&&(clearTimeout(e.timeout),e.timeout=setTimeout(function(){s(t)},e.options.delay))}h.defineOption("lint",!1,function(t,e,n){if(n&&n!=h.Init&&(y(t),!1!==t.state.lint.options.lintOnChange&&t.off("change",l),h.off(t.getWrapperElement(),"mouseover",t.state.lint.onMouseOver),clearTimeout(t.state.lint.timeout),delete t.state.lint),e){for(var o=t.getOption("gutters"),i=!1,r=0;r<o.length;++r)o[r]==g&&(i=!0);n=t.state.lint=new a(t,e,i);n.options.lintOnChange&&t.on("change",l),0!=n.options.tooltips&&"gutter"!=n.options.tooltips&&h.on(t.getWrapperElement(),"mouseover",n.onMouseOver),s(t)}}),h.defineExtension("performLint",function(){s(this)})});
|
||||
@@ -1,14 +1,8 @@
|
||||
:root {
|
||||
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
--color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000);
|
||||
|
||||
--datagrid-resize-zindex: 1;
|
||||
--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-xs: 0.6875rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
@@ -17,8 +11,6 @@
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--properties-font-size: var(--text-xs);
|
||||
--mf-tooltip-zindex: 10;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,18 +18,21 @@
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
@@ -45,12 +40,14 @@
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-32 {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -59,36 +56,6 @@
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
.mf-button {
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mf-button:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
|
||||
.mf-tooltip-container {
|
||||
background: var(--color-base-200);
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none; /* Prevent interfering with mouse events */
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0; /* Default to invisible */
|
||||
visibility: hidden; /* Prevent interaction when invisible */
|
||||
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
||||
position: fixed; /* Keep it above other content and adjust position */
|
||||
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
|
||||
}
|
||||
|
||||
.mf-tooltip-container[data-visible="true"] {
|
||||
opacity: 1;
|
||||
visibility: visible; /* Show tooltip */
|
||||
transition: opacity 0.3s ease; /* No delay when becoming visible */
|
||||
}
|
||||
|
||||
/* Main layout container using CSS Grid */
|
||||
.mf-layout {
|
||||
display: grid;
|
||||
@@ -449,12 +416,13 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
.mf-empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
@@ -469,12 +437,13 @@
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
/*max-height: 400px;*/
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-dropdown-wrapper {
|
||||
position: relative; /* CRUCIAL for the anchor */
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
@@ -482,19 +451,15 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem;
|
||||
left: 0px;
|
||||
z-index: 1;
|
||||
width: 200px;
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
|
||||
/* DaisyUI styling */
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000),
|
||||
0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000);
|
||||
/*opacity: 0;*/
|
||||
/*transition: opacity 0.2s ease-in-out;*/
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
@@ -502,36 +467,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dropdown vertical positioning */
|
||||
.mf-dropdown-below {
|
||||
top: 100%;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.mf-dropdown-above {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
/* Dropdown horizontal alignment */
|
||||
.mf-dropdown-left {
|
||||
left: 0;
|
||||
right: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mf-dropdown-right {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mf-dropdown-center {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************** TreeView Component ************* */
|
||||
/* *********************************************** */
|
||||
@@ -697,6 +632,7 @@
|
||||
/* *************** Panel Component *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Container principal du panel */
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -705,485 +641,34 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Common properties for side panels */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
/* Panel gauche */
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
|
||||
padding-top: 25px;
|
||||
}
|
||||
|
||||
/* Left panel specific */
|
||||
.mf-panel-left {
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right panel specific */
|
||||
.mf-panel-right {
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panel principal (centre) */
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||
}
|
||||
|
||||
/* Hidden state - common for both panels */
|
||||
.mf-panel-left.mf-hidden,
|
||||
.mf-panel-right.mf-hidden {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* No transition during manual resize - common for both panels */
|
||||
.mf-panel-left.no-transition,
|
||||
.mf-panel-right.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Common properties for panel toggle icons */
|
||||
.mf-panel-hide-icon,
|
||||
.mf-panel-show-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.mf-panel-hide-icon:hover,
|
||||
.mf-panel-show-icon:hover {
|
||||
background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* Show icon positioning */
|
||||
.mf-panel-show-icon-left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-show-icon-right {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panel with title - grid layout for header + scrollable content */
|
||||
.mf-panel-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Override absolute positioning for hide icon when inside header */
|
||||
.mf-panel-header .mf-panel-hide-icon {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.mf-panel-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Remove padding-top when using title layout */
|
||||
.mf-panel-left.mf-panel-with-title,
|
||||
.mf-panel-right.mf-panel-with-title {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/*!* Properties container *!*/
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/*!* Group card - using DaisyUI card styling *!*/
|
||||
.mf-properties-group-card {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: auto;
|
||||
min-width: 0; /* Important pour permettre le shrink du flexbox */
|
||||
}
|
||||
|
||||
.mf-properties-group-container {
|
||||
display: inline-block; /* important */
|
||||
min-width: max-content; /* important */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/*!* Group header - gradient using DaisyUI primary color *!*/
|
||||
.mf-properties-group-header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||
color: var(--color-primary-content);
|
||||
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
||||
font-weight: 700;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/*!* Group content area *!*/
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/*!* Property row *!*/
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
|
||||
align-items: center;
|
||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
gap: calc(var(--properties-font-size) * 0.75);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/*!* Property key - normal font *!*/
|
||||
.mf-properties-key {
|
||||
align-items: start;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
flex: 0 0 40%;
|
||||
font-size: var(--properties-font-size);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/*!* Property value - monospace font *!*/
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: var(--properties-font-size);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ********************************************* */
|
||||
/* ************* Datagrid Component ************ */
|
||||
/* ********************************************* */
|
||||
|
||||
/* Header and Footer */
|
||||
.dt2-header,
|
||||
.dt2-footer {
|
||||
background-color: var(--color-base-200);
|
||||
border-radius: 10px 10px 0 0;
|
||||
min-width: max-content; /* Content width propagates to scrollable parent */
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.dt2-body {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Content width propagates to scrollable parent */
|
||||
}
|
||||
|
||||
/* Row */
|
||||
.dt2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Cell */
|
||||
.dt2-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 2px 8px;
|
||||
/* Panel droit */
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Cell content types */
|
||||
.dt2-cell-content-text {
|
||||
text-align: inherit;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.dt2-cell-content-checkbox {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dt2-cell-content-number {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* Footer cell */
|
||||
.dt2-footer-cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.dt2-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.dt2-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: var(--datagrid-resize-zindex);
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
top: calc(50% - 60% * 0.5);
|
||||
background-color: var(--color-resize);
|
||||
}
|
||||
|
||||
/* Hidden column */
|
||||
.dt2-col-hidden {
|
||||
width: 5px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.dt2-highlight-1 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
|
||||
.dt2-selected-focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -3px; /* Ensure the outline is snug to the cell */
|
||||
}
|
||||
|
||||
.dt2-cell:hover,
|
||||
.dt2-selected-cell {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-selected-row {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-selected-column {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-hover-row {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-hover-column {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Fixed Header/Footer ******** */
|
||||
/* *********************************************** */
|
||||
|
||||
/*
|
||||
* DataGrid with CSS Grid + Custom Scrollbars
|
||||
* - Wrapper takes 100% of parent height
|
||||
* - Table uses Grid: header (auto) + body (1fr) + footer (auto)
|
||||
* - Native scrollbars hidden, custom scrollbars overlaid
|
||||
* - Vertical scrollbar: right side of entire table
|
||||
* - Horizontal scrollbar: bottom, under footer
|
||||
*/
|
||||
|
||||
/* Main wrapper - takes full parent height, contains table + scrollbars */
|
||||
.dt2-table-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Table with Grid layout - horizontal scroll enabled, scrollbars hidden */
|
||||
.dt2-table {
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto; /* header, body, footer */
|
||||
overflow-x: auto; /* Enable horizontal scroll */
|
||||
overflow-y: hidden; /* No vertical scroll on table */
|
||||
scrollbar-width: none; /* Firefox: hide scrollbar */
|
||||
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Chrome/Safari: hide scrollbar */
|
||||
.dt2-table::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header - no scroll, takes natural height */
|
||||
.dt2-header-container {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Body - scrollable vertically via JS, scrollbars hidden */
|
||||
.dt2-body-container {
|
||||
overflow: hidden; /* Scrollbars hidden, scroll via JS */
|
||||
min-height: 0; /* Important for Grid to allow shrinking */
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Footer - no scroll, takes natural height */
|
||||
.dt2-footer-container {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Custom scrollbars container - overlaid on table */
|
||||
.dt2-scrollbars {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none; /* Let clicks pass through */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Scrollbar wrappers - clickable/draggable */
|
||||
.dt2-scrollbars-vertical-wrapper,
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
position: absolute;
|
||||
background-color: var(--color-base-200);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
}
|
||||
|
||||
/* Vertical scrollbar wrapper - right side, full table height */
|
||||
.dt2-scrollbars-vertical-wrapper {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Horizontal scrollbar wrapper - bottom, full width minus vertical scrollbar */
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
left: 0;
|
||||
right: 8px; /* Leave space for vertical scrollbar */
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbar thumbs */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Vertical scrollbar thumb */
|
||||
.dt2-scrollbars-vertical {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Horizontal scrollbar thumb */
|
||||
.dt2-scrollbars-horizontal {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hover and dragging states */
|
||||
.dt2-scrollbars-vertical:hover,
|
||||
.dt2-scrollbars-horizontal:hover,
|
||||
.dt2-scrollbars-vertical.dt2-dragging,
|
||||
.dt2-scrollbars-horizontal.dt2-dragging {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Column Drag & Drop ********** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Column being dragged - visual feedback */
|
||||
.dt2-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Column animation during swap */
|
||||
.dt2-moving {
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Column Manager ********** */
|
||||
/* *********************************************** */
|
||||
.dt2-column-manager-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dt2-column-manager-label:hover {
|
||||
background-color: var(--color-base-300);
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
src/myfasthtml/assets/placeholder.min.js
vendored
1
src/myfasthtml/assets/placeholder.min.js
vendored
@@ -1 +0,0 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(r){function n(e){e.state.placeholder&&(e.state.placeholder.parentNode.removeChild(e.state.placeholder),e.state.placeholder=null)}function i(e){n(e);var o=e.state.placeholder=document.createElement("pre"),t=(o.style.cssText="height: 0; overflow: visible",o.style.direction=e.getOption("direction"),o.className="CodeMirror-placeholder CodeMirror-line-like",e.getOption("placeholder"));"string"==typeof t&&(t=document.createTextNode(t)),o.appendChild(t),e.display.lineSpace.insertBefore(o,e.display.lineSpace.firstChild)}function l(e){c(e)&&i(e)}function a(e){var o=e.getWrapperElement(),t=c(e);o.className=o.className.replace(" CodeMirror-empty","")+(t?" CodeMirror-empty":""),(t?i:n)(e)}function c(e){return 1===e.lineCount()&&""===e.getLine(0)}r.defineOption("placeholder","",function(e,o,t){var t=t&&t!=r.Init;o&&!t?(e.on("blur",l),e.on("change",a),e.on("swapDoc",a),r.on(e.getInputField(),"compositionupdate",e.state.placeholderCompose=function(){var t;t=e,setTimeout(function(){var e,o=!1;((o=1==t.lineCount()?"TEXTAREA"==(e=t.getInputField()).nodeName?!t.getLine(0).length:!/[^\u200b]/.test(e.querySelector(".CodeMirror-line").textContent):o)?i:n)(t)},20)}),a(e)):!o&&t&&(e.off("blur",l),e.off("change",a),e.off("swapDoc",a),r.off(e.getInputField(),"compositionupdate",e.state.placeholderCompose),n(e),(t=e.getWrapperElement()).className=t.className.replace(" CodeMirror-empty","")),o&&!e.hasFocus()&&l(e)})});
|
||||
1
src/myfasthtml/assets/show-hint.min.css
vendored
1
src/myfasthtml/assets/show-hint.min.css
vendored
@@ -1 +0,0 @@
|
||||
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}
|
||||
1
src/myfasthtml/assets/show-hint.min.js
vendored
1
src/myfasthtml/assets/show-hint.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -16,7 +17,6 @@ class Commands(BaseCommands):
|
||||
def update_boundaries(self):
|
||||
return Command(f"{self._prefix}UpdateBoundaries",
|
||||
"Update component boundaries",
|
||||
self._owner,
|
||||
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
"""
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
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_from_ft(command):
|
||||
def get_command_parent(command):
|
||||
if (ft := command.get_ft()) is None:
|
||||
return None
|
||||
if hasattr(ft, "get_id") and callable(ft.get_id):
|
||||
@@ -32,30 +32,6 @@ class CommandsDebugger(SingleInstance):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent_from_instance(command):
|
||||
if command.owner is None:
|
||||
return None
|
||||
|
||||
return command.owner.get_full_id()
|
||||
|
||||
def _get_nodes_and_edges(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_from_instance(x)),
|
||||
ghost_label_getter=lambda x: InstancesManager.get(*x.split("#")).get_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "blue"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
return nodes, edges
|
||||
|
||||
def _get_commands(self):
|
||||
return list(CommandsManager.commands.values())
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class CycleState(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
self.state = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def cycle_state(self):
|
||||
return Command("CycleState",
|
||||
"Cycle state",
|
||||
self._owner,
|
||||
self._owner.cycle_state).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class CycleStateControl(MultipleInstance):
|
||||
def __init__(self, parent, controls: dict, _id=None, save_state=True):
|
||||
super().__init__(parent, _id)
|
||||
self._state = CycleState(self, save_state)
|
||||
self.controls_by_states = controls
|
||||
self.commands = Commands(self)
|
||||
|
||||
# init the state if required
|
||||
if self._state.state is None and controls:
|
||||
self._state.state = next(iter(controls.keys()))
|
||||
|
||||
def cycle_state(self):
|
||||
keys = list(self.controls_by_states.keys())
|
||||
current_idx = keys.index(self._state.state)
|
||||
self._state.state = keys[(current_idx + 1) % len(keys)]
|
||||
return self
|
||||
|
||||
def get_state(self):
|
||||
return self._state.state
|
||||
|
||||
def render(self):
|
||||
return mk.mk(
|
||||
Div(self.controls_by_states[self._state.state], id=self._id),
|
||||
command=self.commands.cycle_state()
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,865 +0,0 @@
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.common import NotStr
|
||||
from fasthtml.components import *
|
||||
from pandas import DataFrame
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
||||
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
||||
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
||||
from myfasthtml.controls.DslEditor import DslEditorConf
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||
from myfasthtml.controls.helpers import mk, icons
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter
|
||||
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||
from myfasthtml.core.formatting.engine import FormattingEngine
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.core.optimized_ft import OptimizedDiv
|
||||
from myfasthtml.core.utils import make_safe_id
|
||||
from myfasthtml.icons.carbon import row, column, grid
|
||||
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
|
||||
from myfasthtml.icons.fluent_p1 import settings16_regular
|
||||
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
|
||||
|
||||
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
|
||||
_HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
|
||||
|
||||
logger = logging.getLogger("Datagrid")
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _mk_bool_cached(_value):
|
||||
"""
|
||||
OPTIMIZED: Cached boolean checkbox HTML generator.
|
||||
Since there are only 2 possible values (True/False), this will only generate HTML twice.
|
||||
"""
|
||||
return NotStr(str(
|
||||
Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False),
|
||||
cls="dt2-cell-content-checkbox")
|
||||
))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridConf:
|
||||
namespace: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
|
||||
|
||||
class DatagridState(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
with self.initializing():
|
||||
super().__init__(owner, name=f"{owner.get_id()}#state", save_state=save_state)
|
||||
self.sidebar_visible: bool = False
|
||||
self.selected_view: str = None
|
||||
self.row_index: bool = True
|
||||
self.columns: list[DataGridColumnState] = []
|
||||
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
|
||||
self.headers: list[DataGridHeaderFooterConf] = []
|
||||
self.footers: list[DataGridHeaderFooterConf] = []
|
||||
self.sorted: list = []
|
||||
self.filtered: dict = {}
|
||||
self.edition: DatagridEditionState = DatagridEditionState()
|
||||
self.selection: DatagridSelectionState = DatagridSelectionState()
|
||||
self.cell_formats: dict = {}
|
||||
self.ne_df = None
|
||||
|
||||
self.ns_fast_access = None
|
||||
self.ns_row_data = None
|
||||
self.ns_total_rows = None
|
||||
|
||||
|
||||
class DatagridSettings(DbObject):
|
||||
def __init__(self, owner, save_state, name, namespace):
|
||||
with self.initializing():
|
||||
super().__init__(owner, name=f"{owner.get_id()}#settings", save_state=save_state)
|
||||
self.save_state = save_state is True
|
||||
self.namespace: Optional[str] = namespace
|
||||
self.name: Optional[str] = name
|
||||
self.file_name: Optional[str] = None
|
||||
self.selected_sheet_name: Optional[str] = None
|
||||
self.header_visible: bool = True
|
||||
self.filter_all_visible: bool = True
|
||||
self.views_visible: bool = True
|
||||
self.open_file_visible: bool = True
|
||||
self.open_settings_visible: bool = True
|
||||
self.text_size: str = "sm"
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def get_page(self, page_index: int):
|
||||
return Command("GetPage",
|
||||
"Get a specific page of data",
|
||||
self._owner,
|
||||
self._owner.mk_body_content_page,
|
||||
kwargs={"page_index": page_index}
|
||||
).htmx(target=f"#tb_{self._id}",
|
||||
swap="beforeend",
|
||||
trigger=f"intersect root:#tb_{self._id} once",
|
||||
auto_swap_oob=False
|
||||
)
|
||||
|
||||
def set_column_width(self):
|
||||
return Command("SetColumnWidth",
|
||||
"Set column width after resize",
|
||||
self._owner,
|
||||
self._owner.set_column_width
|
||||
).htmx(target=None)
|
||||
|
||||
def move_column(self):
|
||||
return Command("MoveColumn",
|
||||
"Move column to new position",
|
||||
self._owner,
|
||||
self._owner.move_column
|
||||
).htmx(target=None)
|
||||
|
||||
def filter(self):
|
||||
return Command("Filter",
|
||||
"Filter Grid",
|
||||
self._owner,
|
||||
self._owner.filter
|
||||
)
|
||||
|
||||
def change_selection_mode(self):
|
||||
return Command("ChangeSelectionMode",
|
||||
"Change selection mode",
|
||||
self._owner,
|
||||
self._owner.change_selection_mode
|
||||
)
|
||||
|
||||
def on_click(self):
|
||||
return Command("OnClick",
|
||||
"Click on the table",
|
||||
self._owner,
|
||||
self._owner.on_click
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def toggle_columns_manager(self):
|
||||
return Command("ToggleColumnsManager",
|
||||
"Hide/Show Columns Manager",
|
||||
self._owner,
|
||||
self._owner.toggle_columns_manager
|
||||
).htmx(target=None)
|
||||
|
||||
def toggle_formatting_editor(self):
|
||||
return Command("ToggleFormattingEditor",
|
||||
"Hide/Show Formatting Editor",
|
||||
self._owner,
|
||||
self._owner.toggle_formatting_editor
|
||||
).htmx(target=None)
|
||||
|
||||
def on_column_changed(self):
|
||||
return Command("OnColumnChanged",
|
||||
"Column definition changed",
|
||||
self._owner,
|
||||
self._owner.on_column_changed
|
||||
)
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
def __init__(self, parent, conf=None, save_state=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
|
||||
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
|
||||
self._state = DatagridState(self, save_state=self._settings.save_state)
|
||||
self._formatting_engine = FormattingEngine()
|
||||
self.commands = Commands(self)
|
||||
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
||||
|
||||
# add Panel
|
||||
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="#panel")
|
||||
self._panel.set_side_visible("right", False) # the right Panel always starts closed
|
||||
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
||||
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
||||
|
||||
# add DataGridQuery
|
||||
self._datagrid_filter = DataGridQuery(self)
|
||||
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
||||
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
|
||||
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
|
||||
# add Selection Selector
|
||||
selection_types = {
|
||||
"row": mk.icon(row, tooltip="Row selection"),
|
||||
"column": mk.icon(column, tooltip="Column selection"),
|
||||
"cell": mk.icon(grid, tooltip="Cell selection")
|
||||
}
|
||||
self._selection_mode_selector = CycleStateControl(self, controls=selection_types, save_state=False)
|
||||
self._selection_mode_selector.bind_command("CycleState", self.commands.change_selection_mode())
|
||||
|
||||
# add columns manager
|
||||
self._columns_manager = DataGridColumnsManager(self)
|
||||
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
|
||||
|
||||
editor_conf = DslEditorConf()
|
||||
self._formatting_editor = DataGridFormattingEditor(self,
|
||||
conf=editor_conf,
|
||||
dsl=FormattingDSL(),
|
||||
save_state=self._settings.save_state,
|
||||
_id="#formatting_editor")
|
||||
|
||||
# other definitions
|
||||
self._mouse_support = {
|
||||
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
}
|
||||
|
||||
logger.debug(f"DataGrid '{self._get_full_name()}' with id='{self._id}' created.")
|
||||
|
||||
@property
|
||||
def _df(self):
|
||||
return self._state.ne_df
|
||||
|
||||
def _apply_sort(self, df):
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
sorted_columns = []
|
||||
sorted_asc = []
|
||||
for sort_def in self._state.sorted:
|
||||
if sort_def.direction != 0:
|
||||
sorted_columns.append(sort_def.column_id)
|
||||
asc = sort_def.direction == 1
|
||||
sorted_asc.append(asc)
|
||||
|
||||
if sorted_columns:
|
||||
df = df.sort_values(by=sorted_columns, ascending=sorted_asc)
|
||||
|
||||
return df
|
||||
|
||||
def _apply_filter(self, df):
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
for col_id, values in self._state.filtered.items():
|
||||
if col_id == FILTER_INPUT_CID:
|
||||
if values is not None:
|
||||
if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER:
|
||||
visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns]
|
||||
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
|
||||
else:
|
||||
pass # we return all the row (but we will keep the highlight)
|
||||
|
||||
else:
|
||||
df = df[df[col_id].astype(str).isin(values)]
|
||||
return df
|
||||
|
||||
def _get_filtered_df(self):
|
||||
if self._df is None:
|
||||
return DataFrame()
|
||||
|
||||
df = self._df.copy()
|
||||
df = self._apply_sort(df) # need to keep the real type to sort
|
||||
df = self._apply_filter(df)
|
||||
self._state.ns_total_rows = len(df)
|
||||
|
||||
return df
|
||||
|
||||
def _get_element_id_from_pos(self, selection_mode, pos):
|
||||
# pos => (column, row)
|
||||
|
||||
if pos is None or pos == (None, None):
|
||||
return None
|
||||
elif selection_mode == "row":
|
||||
return f"trow_{self._id}-{pos[1]}"
|
||||
elif selection_mode == "column":
|
||||
return f"tcol_{self._id}-{pos[0]}"
|
||||
else:
|
||||
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
|
||||
|
||||
def _get_pos_from_element_id(self, element_id):
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
if element_id.startswith("tcell_"):
|
||||
parts = element_id.split("-")
|
||||
return int(parts[-2]), int(parts[-1])
|
||||
|
||||
return None
|
||||
|
||||
def _update_current_position(self, pos):
|
||||
self._state.selection.last_selected = self._state.selection.selected
|
||||
self._state.selection.selected = pos
|
||||
self._state.save()
|
||||
|
||||
def _get_full_name(self):
|
||||
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
|
||||
|
||||
def init_from_dataframe(self, df, init_state=True):
|
||||
|
||||
def _get_column_type(dtype):
|
||||
if pd.api.types.is_integer_dtype(dtype):
|
||||
return ColumnType.Number
|
||||
elif pd.api.types.is_float_dtype(dtype):
|
||||
return ColumnType.Number
|
||||
elif pd.api.types.is_bool_dtype(dtype):
|
||||
return ColumnType.Bool
|
||||
elif pd.api.types.is_datetime64_any_dtype(dtype):
|
||||
return ColumnType.Datetime
|
||||
else:
|
||||
return ColumnType.Text # Default to Text if no match
|
||||
|
||||
def _init_columns(_df):
|
||||
columns = [DataGridColumnState(make_safe_id(col_id),
|
||||
col_index,
|
||||
col_id,
|
||||
_get_column_type(self._df[make_safe_id(col_id)].dtype))
|
||||
for col_index, col_id in enumerate(_df.columns)]
|
||||
if self._state.row_index:
|
||||
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
|
||||
|
||||
return columns
|
||||
|
||||
def _init_fast_access(_df):
|
||||
"""
|
||||
Generates a fast-access dictionary for a DataFrame.
|
||||
|
||||
This method converts the columns of the provided DataFrame into NumPy arrays
|
||||
and stores them as values in a dictionary, using the column names as keys.
|
||||
This allows for efficient access to the data stored in the DataFrame.
|
||||
|
||||
Args:
|
||||
_df (DataFrame): The input pandas DataFrame whose columns are to be converted
|
||||
into a dictionary of NumPy arrays.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary where the keys are the column names of the input DataFrame
|
||||
and the values are the corresponding column values as NumPy arrays.
|
||||
"""
|
||||
if _df is None:
|
||||
return {}
|
||||
|
||||
res = {col: _df[col].to_numpy() for col in _df.columns}
|
||||
res[ROW_INDEX_ID] = _df.index.to_numpy()
|
||||
return res
|
||||
|
||||
def _init_row_data(_df):
|
||||
"""
|
||||
Generates a list of row data dictionaries for column references in formatting conditions.
|
||||
|
||||
Each dict contains {col_id: value} for a single row, used by FormattingEngine
|
||||
to evaluate conditions that reference other columns (e.g., {"col": "budget"}).
|
||||
|
||||
Args:
|
||||
_df (DataFrame): The input pandas DataFrame.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list where each element is a dict of column values for that row.
|
||||
"""
|
||||
if _df is None:
|
||||
return []
|
||||
|
||||
return _df.to_dict(orient='records')
|
||||
|
||||
if df is not None:
|
||||
self._state.ne_df = df
|
||||
if init_state:
|
||||
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
|
||||
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
|
||||
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
|
||||
self._state.ns_fast_access = _init_fast_access(self._df)
|
||||
self._state.ns_row_data = _init_row_data(self._df)
|
||||
self._state.ns_total_rows = len(self._df) if self._df is not None else 0
|
||||
|
||||
return self
|
||||
|
||||
def _get_format_rules(self, col_pos, row_index, col_def):
|
||||
"""
|
||||
Get format rules for a cell, returning only the most specific level defined.
|
||||
|
||||
Priority (most specific wins):
|
||||
1. Cell-level: self._state.cell_formats[cell_id]
|
||||
2. Row-level: row_state.format (if row has specific state)
|
||||
3. Column-level: col_def.format
|
||||
|
||||
Args:
|
||||
col_pos: Column position index
|
||||
row_index: Row index
|
||||
col_def: DataGridColumnState for the column
|
||||
|
||||
Returns:
|
||||
list[FormatRule] or None if no formatting defined
|
||||
"""
|
||||
|
||||
# hack to test
|
||||
if col_def.col_id == "age":
|
||||
return [
|
||||
FormatRule(style=Style(color="green")),
|
||||
FormatRule(condition=Condition(operator=">", value=18), style=Style(color="red")),
|
||||
]
|
||||
|
||||
return [
|
||||
FormatRule(condition=Condition(operator="isnan"), formatter=ConstantFormatter(value="-")),
|
||||
]
|
||||
|
||||
cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index))
|
||||
|
||||
if cell_id in self._state.cell_formats:
|
||||
return self._state.cell_formats[cell_id]
|
||||
|
||||
if row_index < len(self._state.rows):
|
||||
row_state = self._state.rows[row_index]
|
||||
if row_state.format:
|
||||
return row_state.format
|
||||
|
||||
if col_def.format:
|
||||
return col_def.format
|
||||
|
||||
return None
|
||||
|
||||
def set_column_width(self, col_id: str, width: str):
|
||||
"""Update column width after resize. Called via Command from JS."""
|
||||
logger.debug(f"set_column_width: {col_id=} {width=}")
|
||||
for col in self._state.columns:
|
||||
if col.col_id == col_id:
|
||||
col.width = int(width)
|
||||
break
|
||||
|
||||
self._state.save()
|
||||
|
||||
def move_column(self, source_col_id: str, target_col_id: str):
|
||||
"""Move column to new position. Called via Command from JS."""
|
||||
logger.debug(f"move_column: {source_col_id=} {target_col_id=}")
|
||||
|
||||
# Find indices
|
||||
source_idx = None
|
||||
target_idx = None
|
||||
for i, col in enumerate(self._state.columns):
|
||||
if col.col_id == source_col_id:
|
||||
source_idx = i
|
||||
if col.col_id == target_col_id:
|
||||
target_idx = i
|
||||
|
||||
if source_idx is None or target_idx is None:
|
||||
logger.warning(f"move_column: column not found {source_col_id=} {target_col_id=}")
|
||||
return
|
||||
|
||||
if source_idx == target_idx:
|
||||
return
|
||||
|
||||
# Remove source column and insert at target position
|
||||
col = self._state.columns.pop(source_idx)
|
||||
# Adjust target index if source was before target
|
||||
if source_idx < target_idx:
|
||||
self._state.columns.insert(target_idx, col)
|
||||
else:
|
||||
self._state.columns.insert(target_idx, col)
|
||||
|
||||
self._state.save()
|
||||
|
||||
def filter(self):
|
||||
logger.debug("filter")
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
return self.render_partial("body")
|
||||
|
||||
def on_click(self, combination, is_inside, cell_id):
|
||||
logger.debug(f"on_click {combination=} {is_inside=} {cell_id=}")
|
||||
if is_inside and cell_id:
|
||||
if cell_id.startswith("tcell_"):
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
self._update_current_position(pos)
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
def on_column_changed(self):
|
||||
logger.debug("on_column_changed")
|
||||
return self.render_partial("table")
|
||||
|
||||
def change_selection_mode(self):
|
||||
logger.debug(f"change_selection_mode")
|
||||
new_state = self._selection_mode_selector.get_state()
|
||||
logger.debug(f" {new_state=}")
|
||||
self._state.selection.selection_mode = new_state
|
||||
self._state.save()
|
||||
return self.render_partial()
|
||||
|
||||
def toggle_columns_manager(self):
|
||||
logger.debug(f"toggle_columns_manager")
|
||||
self._panel.set_title(side="right", title="Columns")
|
||||
self._panel.set_right(self._columns_manager)
|
||||
|
||||
def toggle_formatting_editor(self):
|
||||
logger.debug(f"toggle_formatting_editor")
|
||||
self._panel.set_title(side="right", title="Formatting")
|
||||
self._panel.set_right(self._formatting_editor)
|
||||
|
||||
def save_state(self):
|
||||
self._state.save()
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def get_settings(self):
|
||||
return self._settings
|
||||
|
||||
def mk_headers(self):
|
||||
resize_cmd = self.commands.set_column_width()
|
||||
move_cmd = self.commands.move_column()
|
||||
|
||||
def _mk_header_name(col_def: DataGridColumnState):
|
||||
return Div(
|
||||
mk.label(col_def.title, icon=icons.get(col_def.type, None)),
|
||||
# make room for sort and filter indicators
|
||||
cls="flex truncate cursor-default",
|
||||
)
|
||||
|
||||
def _mk_header(col_def: DataGridColumnState):
|
||||
if not col_def.visible:
|
||||
return None
|
||||
|
||||
return Div(
|
||||
_mk_header_name(col_def),
|
||||
Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
|
||||
style=f"width:{col_def.width}px;",
|
||||
data_col=col_def.col_id,
|
||||
data_tooltip=col_def.title,
|
||||
cls="dt2-cell dt2-resizable flex",
|
||||
)
|
||||
|
||||
header_class = "dt2-row dt2-header"
|
||||
return Div(
|
||||
*[_mk_header(col_def) for col_def in self._state.columns],
|
||||
cls=header_class,
|
||||
id=f"th_{self._id}",
|
||||
data_move_command_id=move_cmd.id
|
||||
)
|
||||
|
||||
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
|
||||
"""
|
||||
Generate cell content with formatting and optional search highlighting.
|
||||
|
||||
Processing order:
|
||||
1. Apply formatter (transforms value for display)
|
||||
2. Apply style (CSS inline style)
|
||||
3. Apply search highlighting (on top of formatted value)
|
||||
"""
|
||||
|
||||
def mk_highlighted_text(value_str, css_class, style=None):
|
||||
"""Return highlighted text as raw HTML string or tuple of Spans."""
|
||||
style_attr = f' style="{style}"' if style else ''
|
||||
|
||||
if not filter_keyword_lower:
|
||||
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
|
||||
|
||||
index = value_str.lower().find(filter_keyword_lower)
|
||||
if index < 0:
|
||||
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
|
||||
|
||||
# Has highlighting - need to use Span objects
|
||||
len_keyword = len(filter_keyword_lower)
|
||||
res = []
|
||||
if index > 0:
|
||||
res.append(Span(value_str[:index], cls=f"{css_class}"))
|
||||
res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1"))
|
||||
if index + len_keyword < len(value_str):
|
||||
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}"))
|
||||
|
||||
return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0]
|
||||
|
||||
column_type = col_def.type
|
||||
value = self._state.ns_fast_access[col_def.col_id][row_index]
|
||||
|
||||
# Boolean type - uses cached HTML (only 2 possible values)
|
||||
if column_type == ColumnType.Bool:
|
||||
return _mk_bool_cached(value)
|
||||
|
||||
# RowIndex - simplest case, just return the number as plain HTML
|
||||
if column_type == ColumnType.RowIndex:
|
||||
return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>')
|
||||
|
||||
# Get format rules and apply formatting
|
||||
css_string = None
|
||||
formatted_value = None
|
||||
rules = self._get_format_rules(col_pos, row_index, col_def)
|
||||
if rules:
|
||||
row_data = self._state.ns_row_data[row_index] if row_index < len(self._state.ns_row_data) else None
|
||||
css_string, formatted_value = self._formatting_engine.apply_format(rules, value, row_data)
|
||||
|
||||
# Use formatted value or convert to string
|
||||
value_str = formatted_value if formatted_value is not None else str(value)
|
||||
|
||||
# OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
|
||||
if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
|
||||
value_str = html.escape(value_str)
|
||||
|
||||
# Number or Text type
|
||||
if column_type == ColumnType.Number:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-number", css_string)
|
||||
else:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-text", css_string)
|
||||
|
||||
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
|
||||
"""
|
||||
OPTIMIZED: Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups.
|
||||
OPTIMIZED: Uses OptimizedDiv instead of Div for faster rendering.
|
||||
"""
|
||||
if not col_def.visible:
|
||||
return None
|
||||
|
||||
value = self._state.ns_fast_access[col_def.col_id][row_index]
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
|
||||
return OptimizedDiv(content,
|
||||
data_col=col_def.col_id,
|
||||
data_tooltip=str(value),
|
||||
style=f"width:{col_def.width}px;",
|
||||
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
|
||||
cls="dt2-cell")
|
||||
|
||||
def mk_body_content_page(self, page_index: int):
|
||||
"""
|
||||
OPTIMIZED: Extract filter keyword once instead of 10,000 times.
|
||||
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
|
||||
"""
|
||||
df = self._get_filtered_df()
|
||||
start = page_index * DATAGRID_PAGE_SIZE
|
||||
end = start + DATAGRID_PAGE_SIZE
|
||||
if self._state.ns_total_rows > end:
|
||||
last_row = df.index[end - 1]
|
||||
else:
|
||||
last_row = None
|
||||
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
|
||||
rows = [OptimizedDiv(
|
||||
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
for col_pos, col_def in enumerate(self._state.columns)],
|
||||
cls="dt2-row",
|
||||
data_row=f"{row_index}",
|
||||
id=f"tr_{self._id}-{row_index}",
|
||||
**self.commands.get_page(page_index + 1).get_htmx_params(escaped=True) if row_index == last_row else {}
|
||||
) for row_index in df.index[start:end]]
|
||||
|
||||
return rows
|
||||
|
||||
def mk_body_wrapper(self):
|
||||
return Div(
|
||||
self.mk_body(),
|
||||
cls="dt2-body-container",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def mk_body(self):
|
||||
return Div(
|
||||
*self.mk_body_content_page(0),
|
||||
cls=f"dt2-body text-{self._settings.text_size}",
|
||||
)
|
||||
|
||||
def mk_footers(self):
|
||||
return self.mk_headers()
|
||||
return Div(
|
||||
*[Div(
|
||||
*[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns],
|
||||
id=f"tf_{self._id}",
|
||||
data_row=f"{row_index}",
|
||||
cls="dt2-row dt2-row-footer",
|
||||
) for row_index, footer in enumerate(self._state.footers)],
|
||||
cls="dt2-footer",
|
||||
id=f"tf_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table_wrapper(self):
|
||||
return Div(
|
||||
self.mk_selection_manager(),
|
||||
|
||||
self.mk_table(),
|
||||
|
||||
# Custom scrollbars overlaid
|
||||
Div(
|
||||
# Vertical scrollbar wrapper (right side)
|
||||
Div(
|
||||
Div(cls="dt2-scrollbars-vertical"),
|
||||
cls="dt2-scrollbars-vertical-wrapper"
|
||||
),
|
||||
# Horizontal scrollbar wrapper (bottom)
|
||||
Div(
|
||||
Div(cls="dt2-scrollbars-horizontal"),
|
||||
cls="dt2-scrollbars-horizontal-wrapper"
|
||||
),
|
||||
cls="dt2-scrollbars"
|
||||
),
|
||||
cls="dt2-table-wrapper",
|
||||
id=f"tw_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table(self):
|
||||
# Grid table with header, body, footer
|
||||
return Div(
|
||||
# Header container - no scroll
|
||||
Div(
|
||||
self.mk_headers(),
|
||||
cls="dt2-header-container"
|
||||
),
|
||||
|
||||
self.mk_body_wrapper(), # Body container - scroll via JS, scrollbars hidden
|
||||
|
||||
# Footer container - no scroll
|
||||
Div(
|
||||
self.mk_footers(),
|
||||
cls="dt2-footer-container"
|
||||
),
|
||||
cls="dt2-table",
|
||||
id=f"t_{self._id}"
|
||||
)
|
||||
|
||||
def mk_selection_manager(self):
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"updateDatagridSelection('{self._id}');",
|
||||
}
|
||||
|
||||
selected = []
|
||||
|
||||
if self._state.selection.selected:
|
||||
# selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected)))
|
||||
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
|
||||
|
||||
return Div(
|
||||
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
|
||||
id=f"tsm_{self._id}",
|
||||
selection_mode=f"{self._state.selection.selection_mode}",
|
||||
**extra_attr,
|
||||
)
|
||||
|
||||
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
|
||||
"""
|
||||
Generates a footer cell for a data table based on the provided column definition,
|
||||
row index, footer configuration, and optional out-of-bound setting. This method
|
||||
applies appropriate aggregation functions, determines visibility, and structures
|
||||
the cell's elements accordingly.
|
||||
|
||||
:param col_def: Details of the column state, including its usability, visibility,
|
||||
and column ID, which are necessary to determine how the footer
|
||||
cell should be created.
|
||||
:type col_def: DataGridColumnState
|
||||
:param row_index: The specific index of the footer row where this cell will be
|
||||
added. This parameter is used to uniquely identify the cell
|
||||
within the footer.
|
||||
:type row_index: int
|
||||
:param footer_conf: Configuration for the footer that contains mapping of column
|
||||
IDs to their corresponding aggregation functions. This is
|
||||
critical for calculating aggregated values for the cell content.
|
||||
:type footer_conf: DataGridFooterConf
|
||||
:param oob: A boolean flag indicating whether the configuration involves any
|
||||
out-of-bound parameters that must be handled specifically. This
|
||||
parameter is optional and defaults to False.
|
||||
:type oob: bool
|
||||
:return: Returns an instance of `Div`, containing the visually structured footer
|
||||
cell content, including the calculated aggregation if applicable. If
|
||||
the column is not usable, it returns None. For non-visible columns, it
|
||||
returns a hidden cell `Div`. The aggregation value is displayed for valid
|
||||
aggregations. If none is applicable or the configuration is invalid,
|
||||
appropriate default content or styling is applied.
|
||||
:rtype: Div | None
|
||||
"""
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
if col_def.col_id in footer_conf.conf:
|
||||
agg_function = footer_conf.conf[col_def.col_id]
|
||||
if agg_function == FooterAggregation.Sum.value:
|
||||
value = self._df[col_def.col_id].sum()
|
||||
elif agg_function == FooterAggregation.Min.value:
|
||||
value = self._df[col_def.col_id].min()
|
||||
elif agg_function == FooterAggregation.Max.value:
|
||||
value = self._df[col_def.col_id].max()
|
||||
elif agg_function == FooterAggregation.Mean.value:
|
||||
value = self._df[col_def.col_id].mean()
|
||||
elif agg_function == FooterAggregation.Count.value:
|
||||
value = self._df[col_def.col_id].count()
|
||||
else:
|
||||
value = "** Invalid aggregation function **"
|
||||
else:
|
||||
value = None
|
||||
|
||||
return Div(mk.label(value, cls="dt2-cell-content-number"),
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls="dt2-cell dt2-footer-cell",
|
||||
id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
|
||||
hx_swap_oob='true' if oob else None,
|
||||
)
|
||||
|
||||
def render(self):
|
||||
if self._state.ne_df is None:
|
||||
return Div("No data to display !")
|
||||
|
||||
return Div(
|
||||
Div(self._datagrid_filter,
|
||||
Div(
|
||||
self._selection_mode_selector,
|
||||
mk.icon(settings16_regular,
|
||||
command=self.commands.toggle_columns_manager(),
|
||||
tooltip="Show column manager"),
|
||||
mk.icon(settings16_regular,
|
||||
command=self.commands.toggle_formatting_editor(),
|
||||
tooltip="Show formatting editor"),
|
||||
cls="flex"),
|
||||
cls="flex items-center justify-between mb-2"),
|
||||
self._panel.set_main(self.mk_table_wrapper()),
|
||||
Script(f"initDataGrid('{self._id}');"),
|
||||
Mouse(self, combinations=self._mouse_support),
|
||||
id=self._id,
|
||||
cls="grid",
|
||||
style="height: 100%; grid-template-rows: auto 1fr;"
|
||||
)
|
||||
|
||||
def render_partial(self, fragment="cell"):
|
||||
"""
|
||||
|
||||
:param fragment: cell | body
|
||||
:param redraw_scrollbars:
|
||||
:return:
|
||||
"""
|
||||
res = []
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initDataGridScrollbars('{self._id}');",
|
||||
}
|
||||
|
||||
if fragment == "body":
|
||||
body_container = self.mk_body_wrapper()
|
||||
body_container.attrs.update(extra_attr)
|
||||
res.append(body_container)
|
||||
|
||||
elif fragment == "table":
|
||||
table = self.mk_table()
|
||||
table.attrs.update(extra_attr)
|
||||
res.append(table)
|
||||
|
||||
res.append(self.mk_selection_manager())
|
||||
|
||||
return tuple(res)
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
remove DBEngine entries
|
||||
:return:
|
||||
"""
|
||||
# self._state.delete()
|
||||
# self._settings.delete()
|
||||
pass
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,196 +0,0 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.controls.helpers import icons, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
|
||||
|
||||
logger = logging.getLogger("DataGridColumnsManager")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_column(self, col_id):
|
||||
return Command(f"ToggleColumn",
|
||||
f"Toggle column {col_id}",
|
||||
self._owner,
|
||||
self._owner.toggle_column,
|
||||
kwargs={"col_id": col_id}).htmx(swap="outerHTML", target=f"#tcolman_{self._id}-{col_id}")
|
||||
|
||||
def show_column_details(self, col_id):
|
||||
return Command(f"ShowColumnDetails",
|
||||
f"Show column details {col_id}",
|
||||
self._owner,
|
||||
self._owner.show_column_details,
|
||||
kwargs={"col_id": col_id}).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def show_all_columns(self):
|
||||
return Command(f"ShowAllColumns",
|
||||
f"Show all columns",
|
||||
self._owner,
|
||||
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def update_column(self, col_id):
|
||||
return Command(f"UpdateColumn",
|
||||
f"Update column {col_id}",
|
||||
self._owner,
|
||||
self._owner.update_column,
|
||||
kwargs={"col_id": col_id}
|
||||
).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
|
||||
class DataGridColumnsManager(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return self._parent.get_state().columns
|
||||
|
||||
def _get_col_def_from_col_id(self, col_id):
|
||||
cols_defs = [c for c in self.columns if c.col_id == col_id]
|
||||
if not cols_defs:
|
||||
return None
|
||||
else:
|
||||
return cols_defs[0]
|
||||
|
||||
def toggle_column(self, col_id):
|
||||
logger.debug(f"toggle_column {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
return Div(f"Column '{col_id}' not found")
|
||||
|
||||
col_def.visible = not col_def.visible
|
||||
self._parent.save_state()
|
||||
return self.mk_column_label(col_def)
|
||||
|
||||
def show_column_details(self, col_id):
|
||||
logger.debug(f"show_column_details {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
return Div(f"Column '{col_id}' not found")
|
||||
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
def show_all_columns(self):
|
||||
return self.mk_all_columns()
|
||||
|
||||
def update_column(self, col_id, client_response):
|
||||
logger.debug(f"update_column {col_id=}, {client_response=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
else:
|
||||
for k, v in client_response.items():
|
||||
if not hasattr(col_def, k):
|
||||
continue
|
||||
|
||||
if k == "visible":
|
||||
col_def.visible = v == "on"
|
||||
elif k == "type":
|
||||
col_def.type = ColumnType(v)
|
||||
elif k == "width":
|
||||
col_def.width = int(v)
|
||||
else:
|
||||
setattr(col_def, k, v)
|
||||
|
||||
# save the new values
|
||||
self._parent.save_state()
|
||||
|
||||
return self.mk_all_columns()
|
||||
|
||||
def mk_column_label(self, col_def: DataGridColumnState):
|
||||
return Div(
|
||||
mk.mk(
|
||||
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
|
||||
command=self.commands.toggle_column(col_def.col_id)
|
||||
),
|
||||
mk.mk(
|
||||
Div(
|
||||
Div(mk.label(col_def.col_id, icon=icons.get(col_def.type, None), cls="ml-2")),
|
||||
Div(mk.icon(chevron_right20_regular), cls="mr-2"),
|
||||
cls="dt2-column-manager-label"
|
||||
),
|
||||
command=self.commands.show_column_details(col_def.col_id)
|
||||
),
|
||||
cls="flex mb-1 items-center",
|
||||
id=f"tcolman_{self._id}-{col_def.col_id}"
|
||||
)
|
||||
|
||||
def mk_column_details(self, col_def: DataGridColumnState):
|
||||
size = "sm"
|
||||
return Div(
|
||||
mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()),
|
||||
Form(
|
||||
Fieldset(
|
||||
Label("Column Id"),
|
||||
Input(name="col_id",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.col_id,
|
||||
readonly=True),
|
||||
|
||||
Div(
|
||||
Div(
|
||||
Label("Visible"),
|
||||
Input(name="visible",
|
||||
type="checkbox",
|
||||
cls=f"checkbox checkbox-{size}",
|
||||
checked="true" if col_def.visible else None),
|
||||
),
|
||||
Div(
|
||||
Label("Width"),
|
||||
Input(name="width",
|
||||
type="number",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.width),
|
||||
),
|
||||
cls="flex",
|
||||
),
|
||||
|
||||
Label("Title"),
|
||||
Input(name="title",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.title),
|
||||
|
||||
Label("type"),
|
||||
Select(
|
||||
*[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType],
|
||||
name="type",
|
||||
cls=f"select select-{size}",
|
||||
value=col_def.title,
|
||||
),
|
||||
|
||||
legend="Column details",
|
||||
cls="fieldset border-base-300 rounded-box"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
|
||||
on_cancel=self.commands.show_all_columns()),
|
||||
cls="mb-1",
|
||||
),
|
||||
)
|
||||
|
||||
def mk_all_columns(self):
|
||||
return Search(self,
|
||||
items_names="Columns",
|
||||
items=self.columns,
|
||||
get_attr=lambda x: x.col_id,
|
||||
template=self.mk_column_label,
|
||||
max_height=None
|
||||
)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self.mk_all_columns(),
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,7 +0,0 @@
|
||||
from myfasthtml.controls.DslEditor import DslEditor
|
||||
|
||||
|
||||
class DataGridFormattingEditor(DslEditor):
|
||||
|
||||
def on_dsl_change(self, dsl):
|
||||
pass
|
||||
@@ -1,97 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
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.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent import brain_circuit20_regular
|
||||
from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
|
||||
from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
|
||||
|
||||
logger = logging.getLogger("DataGridFilter")
|
||||
|
||||
DG_QUERY_FILTER = "filter"
|
||||
DG_QUERY_SEARCH = "search"
|
||||
DG_QUERY_AI = "ai"
|
||||
|
||||
query_type = {
|
||||
DG_QUERY_FILTER: filter20_regular,
|
||||
DG_QUERY_SEARCH: search20_regular,
|
||||
DG_QUERY_AI: brain_circuit20_regular
|
||||
}
|
||||
|
||||
|
||||
class DataGridFilterState(DbObject):
|
||||
def __init__(self, owner):
|
||||
with self.initializing():
|
||||
super().__init__(owner)
|
||||
self.filter_type: str = "filter"
|
||||
self.query: Optional[str] = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def change_filter_type(self):
|
||||
return Command("ChangeFilterType",
|
||||
"Change filter type",
|
||||
self._owner,
|
||||
self._owner.change_query_type).htmx(target=f"#{self._id}")
|
||||
|
||||
def on_filter_changed(self):
|
||||
return Command("QueryChanged",
|
||||
"Query changed",
|
||||
self._owner,
|
||||
self._owner.query_changed).htmx(target=None)
|
||||
|
||||
def on_cancel_query(self):
|
||||
return Command("CancelQuery",
|
||||
"Cancel query",
|
||||
self._owner,
|
||||
self._owner.query_changed,
|
||||
kwargs={"query": ""}
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class DataGridQuery(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridFilterState(self)
|
||||
|
||||
def get_query(self):
|
||||
return self._state.query
|
||||
|
||||
def get_query_type(self):
|
||||
return self._state.filter_type
|
||||
|
||||
def change_query_type(self):
|
||||
keys = list(query_type.keys()) # ["filter", "search", "ai"]
|
||||
current_idx = keys.index(self._state.filter_type)
|
||||
self._state.filter_type = keys[(current_idx + 1) % len(keys)]
|
||||
return self
|
||||
|
||||
def query_changed(self, query):
|
||||
logger.debug(f"query_changed {query=}")
|
||||
self._state.query = query.strip() if query is not None else None
|
||||
return self
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.label(
|
||||
Input(name="query",
|
||||
value=self._state.query if self._state.query is not None else "",
|
||||
placeholder="Search...",
|
||||
**self.commands.on_filter_changed().get_htmx_params(values_encode="json")),
|
||||
icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()),
|
||||
cls="input input-xs flex gap-3"
|
||||
),
|
||||
mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()),
|
||||
cls="flex",
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,255 +0,0 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.dsls import DslsManager
|
||||
from myfasthtml.core.formatting.dsl.completion.engine import FormattingCompletionEngine
|
||||
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
|
||||
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentDefinition:
|
||||
document_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
type: str # table, card,
|
||||
tab_id: str
|
||||
datagrid_id: str
|
||||
|
||||
|
||||
class DataGridsState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.elements: list[DocumentDefinition] = []
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def upload_from_source(self):
|
||||
return Command("UploadFromSource",
|
||||
"Upload from source",
|
||||
self._owner,
|
||||
self._owner.upload_from_source).htmx(target=None)
|
||||
|
||||
def new_grid(self):
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.new_grid)
|
||||
|
||||
def open_from_excel(self, tab_id, file_upload):
|
||||
return Command("OpenFromExcel",
|
||||
"Open from Excel",
|
||||
self._owner,
|
||||
self._owner.open_from_excel,
|
||||
args=[tab_id,
|
||||
file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def clear_tree(self):
|
||||
return Command("ClearTree",
|
||||
"Clear tree",
|
||||
self._owner,
|
||||
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def show_document(self):
|
||||
return Command("ShowDocument",
|
||||
"Show document",
|
||||
self._owner,
|
||||
self._owner.select_document,
|
||||
key="SelectNode")
|
||||
|
||||
|
||||
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
if not getattr(self, "_is_new_instance", False):
|
||||
# Skip __init__ if instance already existed
|
||||
return
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridsState(self)
|
||||
self._tree = self._mk_tree()
|
||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
self._registry = DataGridsRegistry(parent)
|
||||
|
||||
# Global presets shared across all DataGrids
|
||||
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
||||
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
||||
|
||||
# register the auto-completion for the formatter DSL
|
||||
DslsManager.register(FormattingDSL().get_id(),
|
||||
FormattingCompletionEngine(self),
|
||||
DSLParser())
|
||||
|
||||
def upload_from_source(self):
|
||||
file_upload = FileUpload(self)
|
||||
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
|
||||
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
|
||||
return self._tabs_manager.show_tab(tab_id)
|
||||
|
||||
def open_from_excel(self, tab_id, file_upload: FileUpload):
|
||||
excel_content = file_upload.get_content()
|
||||
df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
|
||||
namespace = file_upload.get_file_basename()
|
||||
name = file_upload.get_sheet_name()
|
||||
dg_conf = DatagridConf(namespace=namespace, name=name)
|
||||
dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
|
||||
dg.init_from_dataframe(df)
|
||||
self._registry.put(namespace, name, dg.get_id())
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=dg.get_id()
|
||||
)
|
||||
self._state.elements = self._state.elements + [document] # do not use append() other it won't be saved
|
||||
parent_id = self._tree.ensure_path(document.namespace)
|
||||
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, dg)
|
||||
|
||||
def select_document(self, node_id):
|
||||
document_id = self._tree.get_bag(node_id)
|
||||
try:
|
||||
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
|
||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
|
||||
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
|
||||
except StopIteration:
|
||||
# the selected node is not a document (it's a folder)
|
||||
return None
|
||||
|
||||
def create_tab_content(self, tab_id):
|
||||
"""
|
||||
Recreate the content for a tab managed by this DataGridsManager.
|
||||
Called by TabsManager when the content is not in cache (e.g., after restart).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab to recreate content for
|
||||
|
||||
Returns:
|
||||
The recreated component (Panel with DataGrid)
|
||||
"""
|
||||
# Find the document associated with this tab
|
||||
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
|
||||
|
||||
if document is None:
|
||||
raise ValueError(f"No document found for tab {tab_id}")
|
||||
|
||||
# Recreate the DataGrid with its saved state
|
||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
|
||||
return dg
|
||||
|
||||
def clear_tree(self):
|
||||
self._state.elements = []
|
||||
self._tree.clear()
|
||||
return self._tree
|
||||
|
||||
# === DatagridMetadataProvider ===
|
||||
|
||||
def list_tables(self):
|
||||
return self._registry.get_all_tables()
|
||||
|
||||
def list_columns(self, table_name):
|
||||
return self._registry.get_columns(table_name)
|
||||
|
||||
def list_column_values(self, table_name, column_name):
|
||||
return self._registry.get_column_values(table_name, column_name)
|
||||
|
||||
def get_row_count(self, table_name):
|
||||
return self._registry.get_row_count(table_name)
|
||||
|
||||
def list_style_presets(self) -> list[str]:
|
||||
return list(self.style_presets.keys())
|
||||
|
||||
def list_format_presets(self) -> list[str]:
|
||||
return list(self.formatter_presets.keys())
|
||||
|
||||
# === Presets Management ===
|
||||
|
||||
def get_style_presets(self) -> dict:
|
||||
"""Get the global style presets."""
|
||||
return self.style_presets
|
||||
|
||||
def get_formatter_presets(self) -> dict:
|
||||
"""Get the global formatter presets."""
|
||||
return self.formatter_presets
|
||||
|
||||
def add_style_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a style preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_highlight")
|
||||
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
|
||||
"""
|
||||
self.style_presets[name] = preset
|
||||
|
||||
def add_formatter_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a formatter preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_currency")
|
||||
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||
"""
|
||||
self.formatter_presets[name] = preset
|
||||
|
||||
def remove_style_preset(self, name: str):
|
||||
"""Remove a style preset."""
|
||||
if name in self.style_presets:
|
||||
del self.style_presets[name]
|
||||
|
||||
def remove_formatter_preset(self, name: str):
|
||||
"""Remove a formatter preset."""
|
||||
if name in self.formatter_presets:
|
||||
del self.formatter_presets[name]
|
||||
|
||||
# === UI ===
|
||||
|
||||
def mk_main_icons(self):
|
||||
return Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()),
|
||||
cls="flex"
|
||||
)
|
||||
|
||||
def _mk_tree(self):
|
||||
tree = TreeView(self, _id="-treeview")
|
||||
for element in self._state.elements:
|
||||
parent_id = tree.ensure_path(element.namespace)
|
||||
tree.add_node(TreeNode(id=element.document_id,
|
||||
label=element.name,
|
||||
type=element.type,
|
||||
parent=parent_id,
|
||||
bag=element.document_id))
|
||||
return tree
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._tree,
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -10,16 +10,10 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def close(self):
|
||||
return Command("Close",
|
||||
"Close Dropdown",
|
||||
self._owner,
|
||||
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
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,
|
||||
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
|
||||
class DropdownState:
|
||||
@@ -28,44 +22,12 @@ class DropdownState:
|
||||
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
"""
|
||||
Interactive dropdown component that toggles open/closed on button click.
|
||||
|
||||
Provides automatic close behavior when clicking outside or pressing ESC.
|
||||
Supports configurable positioning relative to the trigger button.
|
||||
|
||||
Args:
|
||||
parent: Parent instance (required).
|
||||
content: Content to display in the dropdown panel.
|
||||
button: Trigger element that toggles the dropdown.
|
||||
_id: Custom ID for the instance.
|
||||
position: Vertical position relative to button.
|
||||
- "below" (default): Dropdown appears below the button.
|
||||
- "above": Dropdown appears above the button.
|
||||
align: Horizontal alignment relative to button.
|
||||
- "left" (default): Aligns to the left edge of the button.
|
||||
- "right": Aligns to the right edge of the button.
|
||||
- "center": Centers relative to the button.
|
||||
|
||||
Example:
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu"),
|
||||
content=Ul(Li("Option 1"), Li("Option 2")),
|
||||
position="below",
|
||||
align="right"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, parent, content=None, button=None, _id=None,
|
||||
position="below", align="left"):
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = Div(button) if not isinstance(button, FT) else button
|
||||
self.content = content
|
||||
self.commands = Commands(self)
|
||||
self._state = DropdownState()
|
||||
self._position = position
|
||||
self._align = align
|
||||
|
||||
def toggle(self):
|
||||
self._state.opened = not self._state.opened
|
||||
@@ -75,32 +37,57 @@ class Dropdown(MultipleInstance):
|
||||
self._state.opened = False
|
||||
return self._mk_content()
|
||||
|
||||
def on_click(self, combination, is_inside: bool, is_button: bool = False):
|
||||
def on_click(self, combination, is_inside: bool):
|
||||
if combination == "click":
|
||||
if is_button:
|
||||
self._state.opened = not self._state.opened
|
||||
else:
|
||||
self._state.opened = is_inside
|
||||
self._state.opened = is_inside
|
||||
return self._mk_content()
|
||||
|
||||
def _mk_content(self):
|
||||
position_cls = f"mf-dropdown-{self._position}"
|
||||
align_cls = f"mf-dropdown-{self._align}"
|
||||
return Div(self.content,
|
||||
cls=f"mf-dropdown {position_cls} {align_cls} {'is-visible' if self._state.opened else ''}",
|
||||
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 "None", cls="mf-dropdown-btn"),
|
||||
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(), hx_vals="js:getDropdownExtra()"),
|
||||
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
# document.addEventListener('htmx:afterSwap', function(event) {
|
||||
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
|
||||
#
|
||||
# // Vérifie si c'est bien notre popup
|
||||
# if (targetElement.classList.contains('mf-popup-container')) {
|
||||
#
|
||||
# // Trouver l'élément déclencheur HTMX (le bouton existant)
|
||||
# // HTMX stocke l'élément déclencheur dans event.detail.elt
|
||||
# const trigger = document.querySelector('#mon-bouton-existant');
|
||||
#
|
||||
# if (trigger) {
|
||||
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
|
||||
# const rect = trigger.getBoundingClientRect();
|
||||
#
|
||||
# // L'élément du popup à positionner
|
||||
# const popup = targetElement;
|
||||
#
|
||||
# // Appliquer la position au conteneur du popup
|
||||
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
|
||||
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
|
||||
#
|
||||
# // Top: Juste en dessous de l'élément déclencheur
|
||||
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
|
||||
#
|
||||
# // Left: Aligner avec le côté gauche de l'élément déclencheur
|
||||
# popup.style.left = (rect.left + window.scrollX) + 'px';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
"""
|
||||
DslEditor control - A CodeMirror wrapper for DSL editing.
|
||||
|
||||
Provides syntax highlighting, line numbers, and autocompletion
|
||||
for domain-specific languages defined with Lark grammars.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.common import Script
|
||||
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.dbmanager import DbObject
|
||||
from myfasthtml.core.dsl.base import DSLDefinition
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("DslEditor")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DslEditorConf:
|
||||
"""Configuration for DslEditor."""
|
||||
name: str = None
|
||||
line_numbers: bool = True
|
||||
autocompletion: bool = True
|
||||
linting: bool = True
|
||||
placeholder: str = ""
|
||||
readonly: bool = False
|
||||
|
||||
|
||||
class DslEditorState(DbObject):
|
||||
"""Non-persisted state for DslEditor."""
|
||||
|
||||
def __init__(self, owner, name, save_state):
|
||||
with self.initializing():
|
||||
super().__init__(owner, name=name, save_state=save_state)
|
||||
self.content: str = ""
|
||||
self.auto_save: bool = True
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
"""Commands for DslEditor interactions."""
|
||||
|
||||
def update_content(self):
|
||||
"""Command to update content from CodeMirror."""
|
||||
return Command(
|
||||
"UpdateContent",
|
||||
"Update editor content",
|
||||
self._owner,
|
||||
self._owner.update_content,
|
||||
).htmx(target=f"#{self._id}", swap="none")
|
||||
|
||||
def toggle_auto_save(self):
|
||||
return Command("ToggleAutoSave",
|
||||
"Toggle auto save",
|
||||
self._owner,
|
||||
self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click")
|
||||
|
||||
def on_content_changed(self):
|
||||
return Command("OnContentChanged",
|
||||
"On content changed",
|
||||
self._owner,
|
||||
self._owner.on_content_changed
|
||||
).htmx(target=None)
|
||||
|
||||
|
||||
class DslEditor(MultipleInstance):
|
||||
"""
|
||||
CodeMirror wrapper for editing DSL code.
|
||||
|
||||
Provides:
|
||||
- Syntax highlighting based on DSL grammar
|
||||
- Line numbers
|
||||
- Autocompletion from grammar keywords/operators
|
||||
|
||||
Args:
|
||||
parent: Parent instance.
|
||||
dsl: DSL definition providing grammar and completions.
|
||||
conf: Editor configuration.
|
||||
_id: Optional custom ID.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
dsl: DSLDefinition,
|
||||
conf: Optional[DslEditorConf] = None,
|
||||
save_state: bool = True,
|
||||
_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
self._dsl = dsl
|
||||
self.conf = conf or DslEditorConf()
|
||||
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
|
||||
self.commands = Commands(self)
|
||||
|
||||
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
|
||||
|
||||
def set_content(self, content: str):
|
||||
"""Set the editor content programmatically."""
|
||||
self._state.content = content
|
||||
return self
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""Get the current editor content."""
|
||||
return self._state.content
|
||||
|
||||
def update_content(self, content: str = "") -> None:
|
||||
"""Handler for content update from CodeMirror."""
|
||||
self._state.content = content
|
||||
if self._state.auto_save:
|
||||
self.on_content_changed()
|
||||
logger.debug(f"Content updated: {len(content)} chars")
|
||||
|
||||
def toggle_auto_save(self):
|
||||
self._state.auto_save = not self._state.auto_save
|
||||
return self._mk_auto_save()
|
||||
|
||||
def on_content_changed(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_editor_config(self) -> dict:
|
||||
"""Build the JavaScript configuration object."""
|
||||
config = {
|
||||
"elementId": str(self._id),
|
||||
"textareaId": f"ta_{self._id}",
|
||||
"lineNumbers": self.conf.line_numbers,
|
||||
"autocompletion": self.conf.autocompletion,
|
||||
"linting": self.conf.linting,
|
||||
"placeholder": self.conf.placeholder,
|
||||
"readonly": self.conf.readonly,
|
||||
"updateCommandId": str(self.commands.update_content().id),
|
||||
"dslId": self._dsl.get_id(),
|
||||
"dsl": {
|
||||
"name": self._dsl.name,
|
||||
"completions": self._dsl.completions,
|
||||
},
|
||||
}
|
||||
return config
|
||||
|
||||
def _mk_textarea(self):
|
||||
"""Create the hidden textarea for form submission."""
|
||||
return Textarea(
|
||||
self._state.content,
|
||||
id=f"ta_{self._id}",
|
||||
name=f"ta_{self._id}",
|
||||
cls="hidden",
|
||||
)
|
||||
|
||||
def _mk_editor_container(self):
|
||||
"""Create the container where CodeMirror will be mounted."""
|
||||
return Div(
|
||||
id=f"cm_{self._id}",
|
||||
cls="mf-dsl-editor",
|
||||
)
|
||||
|
||||
def _mk_init_script(self):
|
||||
"""Create the initialization script."""
|
||||
config = self._get_editor_config()
|
||||
config_json = json.dumps(config)
|
||||
return Script(f"initDslEditor({config_json});")
|
||||
|
||||
def _mk_auto_save(self):
|
||||
return Div(
|
||||
Label(
|
||||
mk.mk(
|
||||
Input(type="checkbox",
|
||||
checked="on" if self._state.auto_save else None,
|
||||
cls="toggle toggle-xs"),
|
||||
command=self.commands.toggle_auto_save()
|
||||
),
|
||||
"Auto Save",
|
||||
cls="text-xs",
|
||||
),
|
||||
mk.button("Save",
|
||||
cls="btn btn-xs btn-primary",
|
||||
disabled="disabled" if self._state.auto_save else None,
|
||||
command=self.commands.update_content()),
|
||||
cls="flex justify-between items-center p-2",
|
||||
id=f"as_{self._id}",
|
||||
),
|
||||
|
||||
def render(self):
|
||||
"""Render the DslEditor component."""
|
||||
return Div(
|
||||
self._mk_auto_save(),
|
||||
self._mk_textarea(),
|
||||
self._mk_editor_container(),
|
||||
self._mk_init_script(),
|
||||
id=self._id,
|
||||
cls="mf-dsl-editor-wrapper",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
@@ -6,7 +6,7 @@ from fastapi import UploadFile
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
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
|
||||
@@ -24,62 +24,32 @@ class FileUploadState(DbObject):
|
||||
self.ns_file_name: str | None = None
|
||||
self.ns_sheets_names: list | None = None
|
||||
self.ns_selected_sheet_name: str | None = None
|
||||
self.ns_file_content: bytes | None = None
|
||||
self.ns_on_ok = None
|
||||
self.ns_on_cancel = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def on_file_uploaded(self):
|
||||
return Command("UploadFile",
|
||||
"Upload file",
|
||||
self._owner,
|
||||
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
def on_sheet_selected(self):
|
||||
return Command("SheetSelected",
|
||||
"Sheet selected",
|
||||
self._owner,
|
||||
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
|
||||
def upload_file(self):
|
||||
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
"""
|
||||
Represents a file upload component.
|
||||
|
||||
This class provides functionality to handle the uploading process of a file,
|
||||
extract sheet names from an Excel file, and enables users to select a specific
|
||||
sheet for further processing. It integrates commands and state management
|
||||
to ensure smooth operation within a parent application.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None, **kwargs):
|
||||
super().__init__(parent, _id=_id, **kwargs)
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = FileUploadState(self)
|
||||
self._state.ns_on_ok = None
|
||||
|
||||
def set_on_ok(self, callback):
|
||||
self._state.ns_on_ok = callback
|
||||
|
||||
def upload_file(self, file: UploadFile):
|
||||
logger.debug(f"upload_file: {file=}")
|
||||
if file:
|
||||
self._state.ns_file_content = file.file.read()
|
||||
self._state.ns_file_name = file.filename
|
||||
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
|
||||
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 select_sheet(self, sheet_name: str):
|
||||
logger.debug(f"select_sheet: {sheet_name=}")
|
||||
self._state.ns_selected_sheet_name = sheet_name
|
||||
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(
|
||||
@@ -87,27 +57,12 @@ class FileUpload(MultipleInstance):
|
||||
selected=True if name == self._state.ns_selected_sheet_name else None,
|
||||
) for name in self._state.ns_sheets_names]
|
||||
|
||||
return mk.mk(Select(
|
||||
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"
|
||||
), command=self.commands.on_sheet_selected())
|
||||
|
||||
def get_content(self):
|
||||
return self._state.ns_file_content
|
||||
|
||||
def get_file_name(self):
|
||||
return self._state.ns_file_name
|
||||
|
||||
def get_file_basename(self):
|
||||
if self._state.ns_file_name is None:
|
||||
return None
|
||||
|
||||
return self._state.ns_file_name.split(".")[0]
|
||||
|
||||
def get_sheet_name(self):
|
||||
return self._state.ns_selected_sheet_name
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_sheets_names(file_content):
|
||||
@@ -131,12 +86,12 @@ class FileUpload(MultipleInstance):
|
||||
hx_encoding='multipart/form-data',
|
||||
cls="file-input file-input-bordered file-input-sm w-full",
|
||||
),
|
||||
command=self.commands.on_file_uploaded()
|
||||
command=self.commands.upload_file()
|
||||
),
|
||||
self.mk_sheet_selector(),
|
||||
cls="flex"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self._state.ns_on_ok, on_cancel=self._state.ns_on_cancel),
|
||||
mk.dialog_buttons(),
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
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")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
session, instance_id = event_data["nodes"][0].split("#")
|
||||
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
|
||||
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_parent_full_id()
|
||||
parent_getter=lambda x: x.get_full_parent_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
|
||||
@@ -2,22 +2,16 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
"""
|
||||
Represents a keyboard with customizable key combinations support.
|
||||
|
||||
The Keyboard class allows managing key combinations and their corresponding
|
||||
actions for a given parent object.
|
||||
"""
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: Command):
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
|
||||
@@ -17,17 +17,15 @@ 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.icons.fluent import panel_left_contract20_regular as left_drawer_contract
|
||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
|
||||
from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
|
||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
|
||||
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
|
||||
|
||||
logger = logging.getLogger("LayoutControl")
|
||||
|
||||
|
||||
class LayoutState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
self.left_drawer_open: bool = True
|
||||
self.right_drawer_open: bool = True
|
||||
@@ -37,28 +35,24 @@ class LayoutState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer",
|
||||
f"Toggle {side} layout drawer",
|
||||
self._owner,
|
||||
self._owner.toggle_drawer,
|
||||
args=[side])
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
|
||||
def update_drawer_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create a command to update drawer width.
|
||||
|
||||
Args:
|
||||
side: Which drawer to update ("left" or "right")
|
||||
width: New width in pixels. Given by the HTMX request
|
||||
|
||||
Returns:
|
||||
Command: Command object for updating drawer width
|
||||
"""
|
||||
return Command(f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner,
|
||||
self._owner.update_drawer_width,
|
||||
args=[side])
|
||||
return Command(
|
||||
f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner.update_drawer_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Layout(SingleInstance):
|
||||
@@ -120,7 +114,7 @@ class Layout(SingleInstance):
|
||||
|
||||
# Content storage
|
||||
self._main_content = None
|
||||
self._state = LayoutState(self, "default_layout")
|
||||
self._state = LayoutState(self)
|
||||
self._boundaries = Boundaries(self)
|
||||
self.commands = Commands(self)
|
||||
self.left_drawer = self.Content(self)
|
||||
@@ -129,6 +123,16 @@ class Layout(SingleInstance):
|
||||
self.header_right = self.Content(self)
|
||||
self.footer_left = self.Content(self)
|
||||
self.footer_right = self.Content(self)
|
||||
self._footer_content = None
|
||||
|
||||
def set_footer(self, content):
|
||||
"""
|
||||
Set the footer content.
|
||||
|
||||
Args:
|
||||
content: FastHTML component(s) or content for the footer
|
||||
"""
|
||||
self._footer_content = content
|
||||
|
||||
def set_main(self, content):
|
||||
"""
|
||||
@@ -141,18 +145,6 @@ class Layout(SingleInstance):
|
||||
return self
|
||||
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Toggle the state of a drawer (open or close) based on the specified side. This
|
||||
method also generates the corresponding icon and drawer elements for the
|
||||
selected side.
|
||||
|
||||
:param side: The side of the drawer to toggle. Must be either "left" or "right".
|
||||
:type side: Literal["left", "right"]
|
||||
:return: A tuple containing the updated drawer icon and drawer elements for
|
||||
the specified side.
|
||||
:rtype: Tuple[Any, Any]
|
||||
:raises ValueError: If the provided `side` is not "left" or "right".
|
||||
"""
|
||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||
if side == "left":
|
||||
self._state.left_drawer_open = not self._state.left_drawer_open
|
||||
@@ -198,17 +190,15 @@ class Layout(SingleInstance):
|
||||
return Header(
|
||||
Div( # left
|
||||
self._mk_left_drawer_icon(),
|
||||
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_hl"
|
||||
*self.header_left.get_content(),
|
||||
cls="flex gap-1"
|
||||
),
|
||||
Div( # right
|
||||
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
|
||||
*self.header_right.get_content()[None],
|
||||
UserProfile(self),
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_hr"
|
||||
cls="flex gap-1"
|
||||
),
|
||||
cls="mf-layout-header",
|
||||
cls="mf-layout-header"
|
||||
)
|
||||
|
||||
def _mk_footer(self):
|
||||
@@ -218,17 +208,9 @@ class Layout(SingleInstance):
|
||||
Returns:
|
||||
Footer: FastHTML Footer component
|
||||
"""
|
||||
footer_content = self._footer_content if self._footer_content else ""
|
||||
return Footer(
|
||||
Div( # left
|
||||
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_fl"
|
||||
),
|
||||
Div( # right
|
||||
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_fr"
|
||||
),
|
||||
footer_content,
|
||||
cls="mf-layout-footer footer sm:footer-horizontal"
|
||||
)
|
||||
|
||||
@@ -295,14 +277,7 @@ class Layout(SingleInstance):
|
||||
|
||||
# Wrap content in scrollable container
|
||||
content_wrapper = Div(
|
||||
*[
|
||||
(
|
||||
Div(cls="divider") if index > 0 else None,
|
||||
group_ft,
|
||||
*[item for item in self.right_drawer.get_content()[group_name]]
|
||||
)
|
||||
for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
|
||||
],
|
||||
*self.right_drawer.get_content(),
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
@@ -315,29 +290,15 @@ class Layout(SingleInstance):
|
||||
)
|
||||
|
||||
def _mk_left_drawer_icon(self):
|
||||
return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
|
||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||
id=f"{self._id}_ldi",
|
||||
command=self.commands.toggle_drawer("left"))
|
||||
|
||||
def _mk_right_drawer_icon(self):
|
||||
return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
|
||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||
id=f"{self._id}_rdi",
|
||||
command=self.commands.toggle_drawer("right"))
|
||||
|
||||
@staticmethod
|
||||
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
|
||||
return Div(
|
||||
*[
|
||||
(
|
||||
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
|
||||
group_ft if show_group_name else None,
|
||||
*[item for item in content.get_content()[group_name]]
|
||||
)
|
||||
for index, (group_name, group_ft) in enumerate(content.get_groups())
|
||||
],
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete layout.
|
||||
@@ -348,13 +309,12 @@ class Layout(SingleInstance):
|
||||
|
||||
# Wrap everything in a container div
|
||||
return Div(
|
||||
Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
|
||||
self._mk_header(),
|
||||
self._mk_left_drawer(),
|
||||
self._mk_main(),
|
||||
self._mk_right_drawer(),
|
||||
self._mk_footer(),
|
||||
Script(f"initLayout('{self._id}');"),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-layout",
|
||||
)
|
||||
|
||||
@@ -2,122 +2,21 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
"""
|
||||
Represents a mechanism to manage mouse event combinations and their associated commands.
|
||||
|
||||
This class is used to add, manage, and render mouse event sequences with corresponding
|
||||
commands, providing a flexible way to handle mouse interactions programmatically.
|
||||
|
||||
Combinations can be defined with:
|
||||
- A Command object: mouse.add("click", command)
|
||||
- HTMX parameters: mouse.add("click", hx_post="/url", hx_vals={...})
|
||||
- Both (named params override command): mouse.add("click", command, hx_target="#other")
|
||||
|
||||
For dynamic hx_vals, use "js:functionName()" to call a client-side function.
|
||||
"""
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: Command = None, *,
|
||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||
hx_delete: str = None, hx_patch: str = None,
|
||||
hx_target: str = None, hx_swap: str = None, hx_vals=None):
|
||||
"""
|
||||
Add a mouse combination with optional command and HTMX parameters.
|
||||
|
||||
Args:
|
||||
sequence: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click")
|
||||
command: Optional Command object for server-side action
|
||||
hx_post: HTMX post URL (overrides command)
|
||||
hx_get: HTMX get URL (overrides command)
|
||||
hx_put: HTMX put URL (overrides command)
|
||||
hx_delete: HTMX delete URL (overrides command)
|
||||
hx_patch: HTMX patch URL (overrides command)
|
||||
hx_target: HTMX target selector (overrides command)
|
||||
hx_swap: HTMX swap strategy (overrides command)
|
||||
hx_vals: HTMX values dict or "js:functionName()" for dynamic values
|
||||
|
||||
Returns:
|
||||
self for method chaining
|
||||
"""
|
||||
self.combinations[sequence] = {
|
||||
"command": command,
|
||||
"hx_post": hx_post,
|
||||
"hx_get": hx_get,
|
||||
"hx_put": hx_put,
|
||||
"hx_delete": hx_delete,
|
||||
"hx_patch": hx_patch,
|
||||
"hx_target": hx_target,
|
||||
"hx_swap": hx_swap,
|
||||
"hx_vals": hx_vals,
|
||||
}
|
||||
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
def _build_htmx_params(self, combination_data: dict) -> dict:
|
||||
"""
|
||||
Build HTMX parameters by merging command params with named overrides.
|
||||
|
||||
Named parameters take precedence over command parameters.
|
||||
hx_vals is handled separately via hx-vals-extra to preserve command's hx-vals.
|
||||
"""
|
||||
command = combination_data.get("command")
|
||||
|
||||
# Start with command params if available
|
||||
if command is not None:
|
||||
params = command.get_htmx_params().copy()
|
||||
else:
|
||||
params = {}
|
||||
|
||||
# Override with named parameters (only if explicitly set)
|
||||
# Note: hx_vals is handled separately below
|
||||
param_mapping = {
|
||||
"hx_post": "hx-post",
|
||||
"hx_get": "hx-get",
|
||||
"hx_put": "hx-put",
|
||||
"hx_delete": "hx-delete",
|
||||
"hx_patch": "hx-patch",
|
||||
"hx_target": "hx-target",
|
||||
"hx_swap": "hx-swap",
|
||||
}
|
||||
|
||||
for py_name, htmx_name in param_mapping.items():
|
||||
value = combination_data.get(py_name)
|
||||
if value is not None:
|
||||
params[htmx_name] = value
|
||||
|
||||
# Handle hx_vals separately - store in hx-vals-extra to not overwrite command's hx-vals
|
||||
hx_vals = combination_data.get("hx_vals")
|
||||
if hx_vals is not None:
|
||||
if isinstance(hx_vals, str) and hx_vals.startswith("js:"):
|
||||
# Dynamic values: extract function name
|
||||
func_name = hx_vals[3:].rstrip("()")
|
||||
params["hx-vals-extra"] = {"js": func_name}
|
||||
elif isinstance(hx_vals, dict):
|
||||
# Static dict values
|
||||
params["hx-vals-extra"] = {"dict": hx_vals}
|
||||
else:
|
||||
# Other string values - try to parse as JSON
|
||||
try:
|
||||
parsed = json.loads(hx_vals)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"hx_vals must be a dict, got {type(parsed).__name__}")
|
||||
params["hx-vals-extra"] = {"dict": parsed}
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}")
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def render(self):
|
||||
str_combinations = {
|
||||
sequence: self._build_htmx_params(data)
|
||||
for sequence, data in self.combinations.items()
|
||||
}
|
||||
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):
|
||||
|
||||
@@ -1,79 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
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 more_horizontal20_regular
|
||||
from myfasthtml.icons.fluent_p2 import subtract20_regular
|
||||
|
||||
|
||||
class PanelIds:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
return f"{self._owner.get_id()}_m"
|
||||
|
||||
@property
|
||||
def right(self):
|
||||
""" Right panel's content"""
|
||||
return f"{self._owner.get_id()}_cr"
|
||||
|
||||
@property
|
||||
def left(self):
|
||||
""" Left panel's content"""
|
||||
return f"{self._owner.get_id()}_cl"
|
||||
|
||||
def panel(self, side: Literal["left", "right"]):
|
||||
return f"{self._owner.get_id()}_pl" if side == "left" else f"{self._owner.get_id()}_pr"
|
||||
|
||||
def content(self, side: Literal["left", "right"]):
|
||||
return self.left if side == "left" else self.right
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelConf:
|
||||
left: bool = False
|
||||
right: bool = True
|
||||
left_title: str = "Left"
|
||||
right_title: str = "Right"
|
||||
show_left_title: bool = True
|
||||
show_right_title: bool = True
|
||||
show_display_left: bool = True
|
||||
show_display_right: bool = True
|
||||
|
||||
|
||||
class PanelState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.left_visible: bool = True
|
||||
self.right_visible: bool = True
|
||||
self.left_width: int = 250
|
||||
self.right_width: int = 250
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
self._owner,
|
||||
self._owner.set_side_visible,
|
||||
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
|
||||
def toggle_side(self, side: Literal["left", "right"]):
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
self._owner,
|
||||
self._owner.toggle_side,
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
@@ -85,55 +29,28 @@ class Commands(BaseCommands):
|
||||
Returns:
|
||||
Command: Command object for updating panel's side width
|
||||
"""
|
||||
return Command(f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner,
|
||||
self._owner.update_side_width,
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
return Command(
|
||||
f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner.update_side_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
"""
|
||||
Represents a user interface panel that supports customizable left, main, and right components.
|
||||
|
||||
The `Panel` class is used to create and manage a panel layout with optional left, main,
|
||||
and right sections. It provides functionality to set the components of the panel, toggle
|
||||
sides, and adjust the width of the sides dynamically. The class also handles rendering
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
self._state = PanelState(self)
|
||||
self._main = None
|
||||
self._right = None
|
||||
self._left = None
|
||||
self._ids = PanelIds(self)
|
||||
|
||||
def get_ids(self):
|
||||
return self._ids
|
||||
|
||||
def update_side_width(self, side, width):
|
||||
if side == "left":
|
||||
self._state.left_width = width
|
||||
else:
|
||||
self._state.right_width = width
|
||||
|
||||
return self._mk_panel(side)
|
||||
|
||||
def set_side_visible(self, side, visible):
|
||||
if side == "left":
|
||||
self._state.left_visible = visible
|
||||
else:
|
||||
self._state.right_visible = visible
|
||||
|
||||
return self._mk_panel(side), self._mk_show_icon(side)
|
||||
pass
|
||||
|
||||
def toggle_side(self, side):
|
||||
current_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
return self.set_side_visible(side, not current_visible)
|
||||
pass
|
||||
|
||||
def set_main(self, main):
|
||||
self._main = main
|
||||
@@ -141,139 +58,41 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return Div(self._right, id=self._ids.right)
|
||||
return self
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return Div(self._left, id=self._ids.left)
|
||||
return self
|
||||
|
||||
def set_title(self, side, title):
|
||||
if side == "left":
|
||||
self.conf.left_title = title
|
||||
else:
|
||||
self.conf.right_title = title
|
||||
|
||||
return self._mk_panel(side)
|
||||
|
||||
def _mk_panel(self, side: Literal["left", "right"]):
|
||||
enabled = self.conf.left if side == "left" else self.conf.right
|
||||
if not enabled:
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
content = self._right if side == "right" else self._left
|
||||
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
|
||||
title = self.conf.left_title if side == "left" else self.conf.right_title
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls=f"mf-resizer mf-resizer-{side}",
|
||||
data_command_id=self.commands.update_side_width(side).id,
|
||||
data_side=side
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
hide_icon = mk.icon(
|
||||
subtract20_regular,
|
||||
size=20,
|
||||
command=self.commands.set_side_visible(side, False),
|
||||
cls="mf-panel-hide-icon"
|
||||
)
|
||||
|
||||
panel_cls = f"mf-panel-{side}"
|
||||
if not visible:
|
||||
panel_cls += " mf-hidden"
|
||||
if show_title:
|
||||
panel_cls += " mf-panel-with-title"
|
||||
|
||||
# Left panel: content then resizer (resizer on the right)
|
||||
# Right panel: resizer then content (resizer on the left)
|
||||
if show_title:
|
||||
header = Div(
|
||||
Div(title),
|
||||
hide_icon,
|
||||
cls="mf-panel-header"
|
||||
)
|
||||
body = Div(
|
||||
header,
|
||||
Div(content, id=self._ids.content(side), cls="mf-panel-content"),
|
||||
cls="mf-panel-body"
|
||||
)
|
||||
if side == "left":
|
||||
return Div(
|
||||
body,
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
body,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.right_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
if side == "left":
|
||||
return Div(
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
def _mk_main(self):
|
||||
return Div(
|
||||
self._mk_show_icon("left"),
|
||||
Div(self._main, id=self._ids.main, cls="mf-panel-main"),
|
||||
self._mk_show_icon("right"),
|
||||
cls="mf-panel-main"
|
||||
),
|
||||
|
||||
def _mk_show_icon(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create show icon for a panel side if it's hidden.
|
||||
|
||||
Args:
|
||||
side: Which panel side ("left" or "right")
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
Returns:
|
||||
Div with icon if panel is hidden, None otherwise
|
||||
"""
|
||||
enabled = self.conf.left if side == "left" else self.conf.right
|
||||
if not enabled:
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
|
||||
if not show_display:
|
||||
return None
|
||||
|
||||
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||
|
||||
return mk.icon(
|
||||
more_horizontal20_regular,
|
||||
command=self.commands.set_side_visible(side, True),
|
||||
cls=icon_cls,
|
||||
id=f"{self._id}_show_{side}"
|
||||
|
||||
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_panel("left"),
|
||||
self._mk_main(),
|
||||
self._mk_panel("right"),
|
||||
self._mk_left(),
|
||||
Div(self._main, cls="mf-panel-main"),
|
||||
self._mk_right(),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
cls="mf-panel",
|
||||
id=self._id,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key", data_tooltip=f"{k}"),
|
||||
self._mk_property_value(v),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in properties.items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -14,39 +14,20 @@ logger = logging.getLogger("Search")
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
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):
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items.
|
||||
It uses fuzzy matching and subsequence matching to filter items.
|
||||
|
||||
:ivar items_names: The name of the items used to filter.
|
||||
:type items_names: str
|
||||
:ivar items: The first set of items to filter.
|
||||
:type items: list
|
||||
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
||||
:type filtered: list
|
||||
:ivar get_attr: Callable function to extract string values from items for filtering.
|
||||
:type get_attr: Callable[[Any], str]
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
|
||||
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 ?
|
||||
max_height: int = 400):
|
||||
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.
|
||||
|
||||
@@ -65,21 +46,14 @@ class Search(MultipleInstance):
|
||||
self.items = items or []
|
||||
self.filtered = self.items.copy()
|
||||
self.get_attr = get_attr or (lambda x: x)
|
||||
self.template = template or (lambda x: Div(self.get_attr(x)))
|
||||
self.template = template or Div
|
||||
self.commands = Commands(self)
|
||||
self.max_height = max_height
|
||||
|
||||
def set_items(self, items):
|
||||
self.items = items
|
||||
self.filtered = self.items.copy()
|
||||
return self
|
||||
|
||||
def get_items(self):
|
||||
return self.items
|
||||
|
||||
def get_filtered(self):
|
||||
return self.filtered
|
||||
|
||||
def on_search(self, query):
|
||||
logger.debug(f"on_search {query=}")
|
||||
self.search(query)
|
||||
@@ -108,7 +82,6 @@ class Search(MultipleInstance):
|
||||
*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results",
|
||||
style="max-height: 400px;" if self.max_height else None
|
||||
),
|
||||
id=f"{self._id}",
|
||||
)
|
||||
|
||||
@@ -52,47 +52,32 @@ class TabsManagerState(DbObject):
|
||||
self.active_tab: str | None = None
|
||||
|
||||
# must not be persisted in DB
|
||||
self.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped)
|
||||
self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed
|
||||
self._tabs_content: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"ShowTab",
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
"Activate or show a specific tab",
|
||||
self._owner,
|
||||
self._owner.show_tab,
|
||||
args=[tab_id,
|
||||
True,
|
||||
False],
|
||||
key=f"{self._owner.get_full_id()}-ShowTab-{tab_id}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
def close_tab(self, tab_id):
|
||||
return Command(f"CloseTab",
|
||||
return Command(f"{self._prefix}CloseTab",
|
||||
"Close a specific tab",
|
||||
self._owner,
|
||||
self._owner.close_tab,
|
||||
kwargs={"tab_id": tab_id},
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
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"AddTab",
|
||||
"Add a new tab",
|
||||
self._owner,
|
||||
self._owner.on_new_tab,
|
||||
args=[label,
|
||||
component,
|
||||
auto_increment],
|
||||
key="#{id-name-args}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
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"))
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._tab_count = 0
|
||||
self._state = TabsManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
self._boundaries = Boundaries()
|
||||
@@ -101,7 +86,6 @@ class TabsManager(MultipleInstance):
|
||||
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}")
|
||||
@@ -112,64 +96,18 @@ class TabsManager(MultipleInstance):
|
||||
def _get_ordered_tabs(self):
|
||||
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
|
||||
|
||||
def _dynamic_get_content(self, tab_id):
|
||||
def _get_tab_content(self, tab_id):
|
||||
if tab_id not in self._state.tabs:
|
||||
return Div("Tab not found.")
|
||||
|
||||
return None
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component"] is None:
|
||||
return Div("Tab content does not support serialization.")
|
||||
|
||||
# 1. Try to get existing component instance
|
||||
res = InstancesManager.get(self._session, tab_config["component"][1], None)
|
||||
if res is not None:
|
||||
logger.debug(f"Component {tab_config['component'][1]} already exists")
|
||||
return res
|
||||
|
||||
# 2. Get or create parent
|
||||
if tab_config["component_parent"] is None:
|
||||
logger.error(f"No parent defined for tab {tab_id}")
|
||||
return Div("Failed to retrieve tab content (no parent).")
|
||||
|
||||
parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None)
|
||||
if parent is None:
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} not found for tab {tab_id}")
|
||||
return Div("Parent component not available")
|
||||
|
||||
# 3. If parent supports create_tab_content, use it
|
||||
if hasattr(parent, 'create_tab_content'):
|
||||
try:
|
||||
logger.debug(f"Asking parent {tab_config['component_parent'][1]} to create tab content for {tab_id}")
|
||||
content = parent.create_tab_content(tab_id)
|
||||
# Store in cache
|
||||
self._state.ns_tabs_content[tab_id] = content
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.error(f"Error while parent creating tab content: {e}")
|
||||
return Div("Failed to retrieve tab content (cannot create).")
|
||||
else:
|
||||
# Parent doesn't support create_tab_content, fallback to error
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} doesn't support create_tab_content")
|
||||
return Div("Failed to retrieve tab content (create tab not supported).")
|
||||
if tab_config["component_type"] is None:
|
||||
return None
|
||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||
|
||||
def _get_or_create_tab_content(self, tab_id):
|
||||
"""
|
||||
Get tab content from cache or create it.
|
||||
This method ensures content is always stored in raw form (not wrapped).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab
|
||||
|
||||
Returns:
|
||||
Raw content component (not wrapped in Div)
|
||||
"""
|
||||
if tab_id not in self._state.ns_tabs_content:
|
||||
self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id)
|
||||
return self._state.ns_tabs_content[tab_id]
|
||||
|
||||
def _get_tab_count(self):
|
||||
res = self._tab_count
|
||||
self._tab_count += 1
|
||||
@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):
|
||||
@@ -178,20 +116,20 @@ class TabsManager(MultipleInstance):
|
||||
label = f"{label}_{self._get_tab_count()}"
|
||||
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
|
||||
|
||||
tab_id = self.create_tab(label, component)
|
||||
return self.show_tab(tab_id, oob=False)
|
||||
|
||||
def show_or_create_tab(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"show_or_create_tab {tab_id=}, {label=}, {component=}, {activate=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
tab_id = self._tab_already_exists(label, component)
|
||||
if tab_id:
|
||||
return self.show_tab(tab_id)
|
||||
|
||||
return self.show_tab(tab_id, activate=activate, oob=True)
|
||||
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 create_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
"""
|
||||
Add a new tab or update an existing one with the same component type, ID and label.
|
||||
The tab is not yet sent to the client.
|
||||
|
||||
Args:
|
||||
label: Display label for the tab
|
||||
@@ -202,55 +140,68 @@ class TabsManager(MultipleInstance):
|
||||
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_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)
|
||||
|
||||
if existing_tab_id:
|
||||
# Update existing tab (only the component instance in memory)
|
||||
tab_id = existing_tab_id
|
||||
state._tabs_content[tab_id] = component
|
||||
else:
|
||||
# Create new tab
|
||||
tab_id = str(uuid.uuid4())
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# Add tab to order
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Store component in memory
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
state.active_tab = tab_id
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4())
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
return tab_id
|
||||
|
||||
def show_tab(self, tab_id, activate: bool = True, oob=True, is_new=True):
|
||||
"""
|
||||
Send the tab to the client if needed.
|
||||
If the tab was already sent, just update the active tab.
|
||||
:param tab_id:
|
||||
:param activate:
|
||||
:param oob: default=True so other control will not care of the target
|
||||
:param is_new: is it a new tab or an existing one?
|
||||
:return:
|
||||
"""
|
||||
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 activate:
|
||||
self._state.active_tab = tab_id
|
||||
|
||||
# Get or create content (always stored in raw form)
|
||||
content = self._get_or_create_tab_content(tab_id)
|
||||
|
||||
if tab_id not in self._state.ns_tabs_sent_to_client:
|
||||
logger.debug(f" Content not in client memory. Sending it.")
|
||||
self._state.ns_tabs_sent_to_client.add(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)
|
||||
return (self._mk_tabs_controller(oob),
|
||||
self._mk_tabs_header_wrapper(oob),
|
||||
self._wrap_tab_content(tab_content, is_new))
|
||||
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 in client memory. Just switch.")
|
||||
return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date
|
||||
|
||||
def change_tab_content(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
|
||||
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.error(f" Tab {tab_id} not found. Cannot change its content.")
|
||||
return None
|
||||
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client
|
||||
return self.show_tab(tab_id, activate=activate, oob=True, is_new=False)
|
||||
logger.debug(f" Content already exists. Just switch.")
|
||||
return self._mk_tabs_controller()
|
||||
|
||||
def close_tab(self, tab_id: str):
|
||||
"""
|
||||
@@ -260,12 +211,10 @@ class TabsManager(MultipleInstance):
|
||||
tab_id: ID of the tab to close
|
||||
|
||||
Returns:
|
||||
tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping,
|
||||
or self if tab not found
|
||||
Self for chaining
|
||||
"""
|
||||
logger.debug(f"close_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return self
|
||||
|
||||
# Copy state
|
||||
@@ -276,12 +225,8 @@ class TabsManager(MultipleInstance):
|
||||
state.tabs_order.remove(tab_id)
|
||||
|
||||
# Remove from content
|
||||
if tab_id in state.ns_tabs_content:
|
||||
del state.ns_tabs_content[tab_id]
|
||||
|
||||
# Remove from content sent
|
||||
if tab_id in state.ns_tabs_sent_to_client:
|
||||
state.ns_tabs_sent_to_client.remove(tab_id)
|
||||
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:
|
||||
@@ -295,8 +240,7 @@ class TabsManager(MultipleInstance):
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete")
|
||||
return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove
|
||||
return self
|
||||
|
||||
def add_tab_btn(self):
|
||||
return mk.icon(tab_add24_regular,
|
||||
@@ -306,12 +250,11 @@ class TabsManager(MultipleInstance):
|
||||
None,
|
||||
True))
|
||||
|
||||
def _mk_tabs_controller(self, oob=False):
|
||||
return Div(id=f"{self._id}-controller",
|
||||
data_active_tab=f"{self._state.active_tab}",
|
||||
hx_on__after_settle=f'updateTabs("{self._id}-controller");',
|
||||
hx_swap_oob="true" if oob else None,
|
||||
)
|
||||
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
|
||||
@@ -321,20 +264,24 @@ class TabsManager(MultipleInstance):
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
header_content = [*visible_tab_buttons]
|
||||
|
||||
return Div(
|
||||
Div(*visible_tab_buttons, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
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):
|
||||
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
|
||||
"""
|
||||
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
|
||||
@@ -352,10 +299,12 @@ class TabsManager(MultipleInstance):
|
||||
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 {'mf-tab-active' if is_active else ''}",
|
||||
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
@@ -367,9 +316,15 @@ class TabsManager(MultipleInstance):
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
|
||||
if self._state.active_tab:
|
||||
content = self._get_or_create_tab_content(self._state.active_tab)
|
||||
tab_content = self._mk_tab_content(self._state.active_tab, content)
|
||||
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)
|
||||
|
||||
@@ -380,13 +335,10 @@ class TabsManager(MultipleInstance):
|
||||
)
|
||||
|
||||
def _mk_tab_content(self, tab_id: str, content):
|
||||
if tab_id is None:
|
||||
return Div("No Content", cls="mf-empty-content mf-tab-content hidden")
|
||||
|
||||
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 ''}",
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
|
||||
id=f"{self._id}-{tab_id}-content",
|
||||
)
|
||||
|
||||
@@ -405,26 +357,23 @@ class TabsManager(MultipleInstance):
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def _wrap_tab_content(self, tab_content, is_new=True):
|
||||
if is_new:
|
||||
return Div(
|
||||
tab_content,
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper"
|
||||
)
|
||||
else:
|
||||
tab_content.attrs["hx-swap-oob"] = "outerHTML"
|
||||
return tab_content
|
||||
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()
|
||||
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') == (component_type, component_id) and
|
||||
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
|
||||
|
||||
@@ -433,43 +382,6 @@ class TabsManager(MultipleInstance):
|
||||
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 _add_or_update_tab(self, tab_id, label, component, activate):
|
||||
state = self._state.copy()
|
||||
|
||||
# Extract component ID if the component has a get_id() method
|
||||
component_type, component_id = None, None
|
||||
parent_type, parent_id = None, None
|
||||
if isinstance(component, BaseInstance):
|
||||
component_type = component.get_prefix()
|
||||
component_id = component.get_id()
|
||||
parent = component.get_parent()
|
||||
if parent:
|
||||
parent_type = parent.get_prefix()
|
||||
parent_id = parent.get_id()
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component': (component_type, component_id) if component_type else None,
|
||||
'component_parent': (parent_type, parent_id) if parent_type else None
|
||||
}
|
||||
|
||||
# Add tab to order list
|
||||
if tab_id not in state.tabs_order:
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Add the content
|
||||
state.ns_tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
state.active_tab = tab_id
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
def update_boundaries(self):
|
||||
return Script(f"updateBoundaries('{self._id}');")
|
||||
|
||||
@@ -484,7 +396,6 @@ class TabsManager(MultipleInstance):
|
||||
self._mk_tabs_controller(),
|
||||
self._mk_tabs_header_wrapper(),
|
||||
self._mk_tab_content_wrapper(),
|
||||
Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ 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, CommandTemplate
|
||||
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
|
||||
@@ -37,7 +37,6 @@ class TreeNode:
|
||||
type: str = "default"
|
||||
parent: Optional[str] = None
|
||||
children: list[str] = field(default_factory=list)
|
||||
bag: Optional[dict] = None # to keep extra info
|
||||
|
||||
|
||||
class TreeViewState(DbObject):
|
||||
@@ -67,82 +66,74 @@ class Commands(BaseCommands):
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Create command to expand/collapse a node."""
|
||||
return Command("ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner,
|
||||
self._owner._toggle_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-ToggleNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._add_child,
|
||||
kwargs={"parent_id": parent_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddChild"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._add_sibling,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddSibling"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._start_rename,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-StartRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._save_rename,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._cancel_rename,
|
||||
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._delete_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
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,
|
||||
self._owner._select_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"SelectNode",
|
||||
f"Select node {node_id}",
|
||||
self._owner._select_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
class TreeView(MultipleInstance):
|
||||
@@ -182,7 +173,7 @@ class TreeView(MultipleInstance):
|
||||
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.
|
||||
@@ -194,9 +185,6 @@ class TreeView(MultipleInstance):
|
||||
If None, appends to end. If provided, inserts at that position.
|
||||
"""
|
||||
self._state.items[node.id] = node
|
||||
if parent_id is None and node.parent is not None:
|
||||
parent_id = node.parent
|
||||
|
||||
node.parent = parent_id
|
||||
|
||||
if parent_id and parent_id in self._state.items:
|
||||
@@ -207,72 +195,12 @@ class TreeView(MultipleInstance):
|
||||
else:
|
||||
parent.children.append(node.id)
|
||||
|
||||
def ensure_path(self, path: str):
|
||||
"""Add a node to the tree based on a path string.
|
||||
|
||||
Args:
|
||||
path: Dot-separated path string (e.g., "folder1.folder2.file")
|
||||
|
||||
Raises:
|
||||
ValueError: If path contains empty parts after stripping
|
||||
"""
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid path: path is None")
|
||||
|
||||
path = path.strip().strip(".")
|
||||
if path == "":
|
||||
raise ValueError(f"Invalid path: path is empty")
|
||||
|
||||
parent_id = None
|
||||
current_nodes = [node for node in self._state.items.values() if node.parent is None]
|
||||
|
||||
path_parts = path.split(".")
|
||||
for part in path_parts:
|
||||
part = part.strip()
|
||||
|
||||
# Validate that part is not empty after stripping
|
||||
if part == "":
|
||||
raise ValueError(f"Invalid path: path contains empty parts")
|
||||
|
||||
node = [node for node in current_nodes if node.label == part]
|
||||
if len(node) == 0:
|
||||
# create the node
|
||||
node = TreeNode(label=part, type="folder")
|
||||
self.add_node(node, parent_id=parent_id)
|
||||
else:
|
||||
node = node[0]
|
||||
|
||||
current_nodes = [self._state.items[node_id] for node_id in node.children]
|
||||
parent_id = node.id
|
||||
|
||||
return parent_id
|
||||
|
||||
def get_selected_id(self):
|
||||
if self._state.selected is None:
|
||||
return None
|
||||
return self._state.items[self._state.selected].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 clear(self):
|
||||
state = self._state.copy()
|
||||
state.items = {}
|
||||
state.opened = []
|
||||
state.selected = None
|
||||
state.editing = None
|
||||
self._state.update(state)
|
||||
return self
|
||||
|
||||
def get_bag(self, node_id: str):
|
||||
try:
|
||||
return self._state.items[node_id].bag
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
@@ -406,16 +334,17 @@ class TreeView(MultipleInstance):
|
||||
|
||||
# Toggle icon
|
||||
toggle = mk.icon(
|
||||
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else None,
|
||||
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=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
|
||||
), command=self.commands.save_rename(node_id))
|
||||
else:
|
||||
label_element = mk.mk(
|
||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
||||
@@ -428,6 +357,7 @@ class TreeView(MultipleInstance):
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -442,8 +372,7 @@ class TreeView(MultipleInstance):
|
||||
return Div(
|
||||
node_element,
|
||||
*children_elements,
|
||||
cls="mf-treenode-container",
|
||||
data_node_id=node_id,
|
||||
cls="mf-treenode-container"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
@@ -461,7 +390,7 @@ class TreeView(MultipleInstance):
|
||||
|
||||
return Div(
|
||||
*[self._render_node(node_id) for node_id in root_nodes],
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard"),
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
|
||||
id=self._id,
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
@@ -33,10 +33,7 @@ class UserProfileState:
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def update_dark_mode(self):
|
||||
return Command("UpdateDarkMode",
|
||||
"Set the dark mode",
|
||||
self._owner,
|
||||
self._owner.update_dark_mode).htmx(target=None)
|
||||
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
|
||||
|
||||
|
||||
class UserProfile(SingleInstance):
|
||||
|
||||
@@ -25,33 +25,19 @@ class VisNetworkState(DbObject):
|
||||
},
|
||||
"physics": {"enabled": True}
|
||||
}
|
||||
self.events_handlers: dict = {} # {event_name: command_url}
|
||||
|
||||
|
||||
class VisNetwork(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
||||
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}")
|
||||
|
||||
# possible events (expected in snake_case
|
||||
# - select_node → selectNode
|
||||
# - select → select
|
||||
# - click → click
|
||||
# - double_click → doubleClick
|
||||
|
||||
self._state = VisNetworkState(self)
|
||||
|
||||
# Convert Commands to URLs
|
||||
handlers_htmx_options = {
|
||||
event_name: command.ajax_htmx_options()
|
||||
for event_name, command in events_handlers.items()
|
||||
} if events_handlers else {}
|
||||
|
||||
self._update_state(nodes, edges, options, handlers_htmx_options)
|
||||
self._update_state(nodes, edges, options)
|
||||
|
||||
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||
if not nodes and not edges and not options and not events_handlers:
|
||||
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()
|
||||
@@ -61,8 +47,6 @@ class VisNetwork(MultipleInstance):
|
||||
state.edges = edges
|
||||
if options is not None:
|
||||
state.options = options
|
||||
if events_handlers is not None:
|
||||
state.events_handlers = events_handlers
|
||||
|
||||
self._state.update(state)
|
||||
|
||||
@@ -86,34 +70,6 @@ class VisNetwork(MultipleInstance):
|
||||
# Convert Python options to JS
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
# Map Python event names to vis-network event names
|
||||
event_name_map = {
|
||||
"select_node": "selectNode",
|
||||
"select": "select",
|
||||
"click": "click",
|
||||
"double_click": "doubleClick"
|
||||
}
|
||||
|
||||
# Generate event handlers JavaScript
|
||||
event_handlers_js = ""
|
||||
for event_name, command_htmx_options in self._state.events_handlers.items():
|
||||
vis_event_name = event_name_map.get(event_name, event_name)
|
||||
event_handlers_js += f"""
|
||||
network.on('{vis_event_name}', function(params) {{
|
||||
const event_data = {{
|
||||
event_name: '{event_name}',
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
pointer: params.pointer
|
||||
}};
|
||||
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
||||
values: {{event_data: JSON.stringify(event_data)}},
|
||||
target: '{command_htmx_options['target']}',
|
||||
swap: '{command_htmx_options['swap']}'
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
|
||||
return (
|
||||
Div(
|
||||
id=self._id,
|
||||
@@ -136,7 +92,6 @@ class VisNetwork(MultipleInstance):
|
||||
}};
|
||||
const options = {js_options};
|
||||
const network = new vis.Network(container, data, options);
|
||||
{event_handlers_js}
|
||||
}})();
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGridRowState:
|
||||
row_id: int
|
||||
visible: bool = True
|
||||
height: int | None = None
|
||||
format: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGridColumnState:
|
||||
col_id: str # name of the column: cannot be changed
|
||||
col_index: int # index of the column in the dataframe: cannot be changed
|
||||
title: str = None
|
||||
type: ColumnType = ColumnType.Text
|
||||
visible: bool = True
|
||||
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
|
||||
format: list = field(default_factory=list) #
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridEditionState:
|
||||
under_edition: tuple[int, int] | None = None
|
||||
previous_under_edition: tuple[int, int] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridSelectionState:
|
||||
selected: tuple[int, int] | None = None # column first, then row
|
||||
last_selected: tuple[int, int] | None = None
|
||||
selection_mode: str = None # valid values are "row", "column" or None for "cell"
|
||||
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
|
||||
last_extra_selected: tuple[int, int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGridHeaderFooterConf:
|
||||
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridView:
|
||||
name: str
|
||||
type: ViewType = ViewType.Table
|
||||
columns: list[DataGridColumnState] = None
|
||||
@@ -1,15 +1,8 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.core.commands import Command, CommandTemplate
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.utils import merge_classes
|
||||
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_row20_regular, \
|
||||
number_symbol20_regular
|
||||
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
||||
checkbox_checked20_filled
|
||||
from myfasthtml.icons.fluent_p2 import text_bullet_list_square20_regular, text_field20_regular
|
||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
|
||||
|
||||
|
||||
class Ids:
|
||||
@@ -21,7 +14,7 @@ class Ids:
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command | CommandTemplate = None, binding: Binding = None, **kwargs):
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
"""
|
||||
Defines a static method for creating a Button object with specific configurations.
|
||||
|
||||
@@ -40,7 +33,7 @@ class mk:
|
||||
@staticmethod
|
||||
def dialog_buttons(ok_title: str = "OK",
|
||||
cancel_title: str = "Cancel",
|
||||
on_ok: Command | CommandTemplate = None,
|
||||
on_ok: Command = None,
|
||||
on_cancel: Command = None,
|
||||
cls=None):
|
||||
return Div(
|
||||
@@ -57,9 +50,8 @@ class mk:
|
||||
size=20,
|
||||
can_select=True,
|
||||
can_hover=False,
|
||||
tooltip=None,
|
||||
cls='',
|
||||
command: Command | CommandTemplate = None,
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
@@ -73,7 +65,6 @@ class mk:
|
||||
: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 tooltip:
|
||||
: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.
|
||||
@@ -85,14 +76,9 @@ class mk:
|
||||
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||
'icon-btn' if can_select else '',
|
||||
'mmt-btn' if can_hover else '',
|
||||
'flex items-center justify-center',
|
||||
cls,
|
||||
kwargs)
|
||||
|
||||
if tooltip:
|
||||
merged_cls = merge_classes(merged_cls, "mf-tooltip")
|
||||
kwargs["data-tooltip"] = tooltip
|
||||
|
||||
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
@@ -100,10 +86,10 @@ class mk:
|
||||
icon=None,
|
||||
size: str = "sm",
|
||||
cls='',
|
||||
command: Command | CommandTemplate = None,
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex truncate items-center", "mf-button" if command else None, cls, 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)
|
||||
@@ -117,10 +103,7 @@ class mk:
|
||||
replace("xl", "32"))
|
||||
|
||||
@staticmethod
|
||||
def manage_command(ft, command: Command | CommandTemplate):
|
||||
if isinstance(command, CommandTemplate):
|
||||
command = command.command
|
||||
|
||||
def manage_command(ft, command: Command):
|
||||
if command:
|
||||
ft = command.bind_ft(ft)
|
||||
|
||||
@@ -141,23 +124,7 @@ class mk:
|
||||
return ft
|
||||
|
||||
@staticmethod
|
||||
def mk(ft, command: Command | CommandTemplate = None, binding: Binding = None, init_binding=True):
|
||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
return ft
|
||||
|
||||
|
||||
icons = {
|
||||
None: question20_regular,
|
||||
True: checkbox_checked20_regular,
|
||||
False: checkbox_unchecked20_regular,
|
||||
|
||||
"Brain": brain_circuit20_regular,
|
||||
|
||||
ColumnType.RowIndex: number_symbol20_regular,
|
||||
ColumnType.Text: text_field20_regular,
|
||||
ColumnType.Number: number_row20_regular,
|
||||
ColumnType.Datetime: calendar_ltr20_regular,
|
||||
ColumnType.Bool: checkbox_checked20_filled,
|
||||
ColumnType.Enum: text_bullet_list_square20_regular,
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
from myfasthtml.core.dbmanager import DbManager
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
DATAGRIDS_REGISTRY_ENTRY_KEY = "data_grids_registry"
|
||||
|
||||
|
||||
class DataGridsRegistry(SingleInstance):
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self._db_manager = DbManager(parent)
|
||||
|
||||
# init the registry
|
||||
if not self._db_manager.exists_entry(DATAGRIDS_REGISTRY_ENTRY_KEY):
|
||||
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, {})
|
||||
|
||||
def put(self, namespace, name, datagrid_id):
|
||||
"""
|
||||
|
||||
:param namespace:
|
||||
:param name:
|
||||
:param datagrid_id:
|
||||
:return:
|
||||
"""
|
||||
all_entries = self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)
|
||||
all_entries[datagrid_id] = (namespace, name)
|
||||
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
|
||||
|
||||
def get_all_tables(self):
|
||||
all_entries = self._get_all_entries()
|
||||
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
|
||||
|
||||
def get_columns(self, table_name):
|
||||
try:
|
||||
as_fullname_dict = self._get_entries_as_full_name_dict()
|
||||
grid_id = as_fullname_dict[table_name]
|
||||
|
||||
# load datagrid state
|
||||
state_id = f"{grid_id}#state"
|
||||
state = self._db_manager.load(state_id)
|
||||
return [c.col_id for c in state["columns"]] if state else []
|
||||
except KeyError:
|
||||
return []
|
||||
|
||||
def get_column_values(self, table_name, column_name):
|
||||
try:
|
||||
as_fullname_dict = self._get_entries_as_full_name_dict()
|
||||
grid_id = as_fullname_dict[table_name]
|
||||
|
||||
# load dataframe
|
||||
state_id = f"{grid_id}#state"
|
||||
state = self._db_manager.load(state_id)
|
||||
df = state["ne_df"] if state else None
|
||||
return df[column_name].tolist() if df is not None else []
|
||||
|
||||
except KeyError:
|
||||
return []
|
||||
|
||||
def get_row_count(self, table_name):
|
||||
try:
|
||||
as_fullname_dict = self._get_entries_as_full_name_dict()
|
||||
grid_id = as_fullname_dict[table_name]
|
||||
|
||||
# load dataframe
|
||||
state_id = f"{grid_id}#state"
|
||||
state = self._db_manager.load(state_id)
|
||||
df = state["ne_df"] if state else None
|
||||
return len(df) if df is not None else 0
|
||||
|
||||
except KeyError:
|
||||
return 0
|
||||
|
||||
def _get_all_entries(self):
|
||||
return {k: v for k, v in self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY).items()
|
||||
if not k.startswith("__")}
|
||||
|
||||
def _get_entries_as_id_dict(self):
|
||||
all_entries = self._get_all_entries()
|
||||
return {_id: f"{namespace}.{name}" for _id, (namespace, name) in all_entries.items()}
|
||||
|
||||
def _get_entries_as_full_name_dict(self):
|
||||
all_entries = self._get_all_entries()
|
||||
return {f"{namespace}.{name}": _id for _id, (namespace, name) in all_entries.items()}
|
||||
@@ -1,21 +1,13 @@
|
||||
import html
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myutils.observable import NotObservableError, ObservableResultCollector
|
||||
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.utils import flatten
|
||||
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
AUTO_SWAP_OOB = "__auto_swap_oob__"
|
||||
|
||||
|
||||
class Command:
|
||||
class BaseCommand:
|
||||
"""
|
||||
Represents the base command class for defining executable actions.
|
||||
|
||||
@@ -32,130 +24,28 @@ class Command:
|
||||
:type description: str
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def process_key(key, name, owner, args, kwargs):
|
||||
def _compute_from_args():
|
||||
res = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "get_full_id"):
|
||||
res.append(arg.get_full_id())
|
||||
else:
|
||||
res.append(str(arg))
|
||||
return "-".join(res)
|
||||
|
||||
# special management when kwargs are provided
|
||||
# In this situation,
|
||||
# either there is no parameter (so one single instance of the command is enough)
|
||||
# or the parameter is a kwargs (so the parameters are provided when the command is called)
|
||||
if (key is None
|
||||
and owner is not None
|
||||
and args is None # args is not provided
|
||||
):
|
||||
key = f"{owner.get_full_id()}-{name}"
|
||||
|
||||
key = key.replace("#{args}", _compute_from_args())
|
||||
key = key.replace("#{id}", owner.get_full_id())
|
||||
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
|
||||
|
||||
return key
|
||||
|
||||
def __init__(self, name,
|
||||
description,
|
||||
owner=None,
|
||||
callback=None,
|
||||
args: list = None,
|
||||
kwargs: dict = None,
|
||||
key=None,
|
||||
auto_register=True):
|
||||
def __init__(self, name, description):
|
||||
self.id = uuid.uuid4()
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.owner = owner
|
||||
self.callback = callback
|
||||
self.default_args = args or []
|
||||
self.default_kwargs = kwargs or {}
|
||||
self._htmx_extra = {AUTO_SWAP_OOB: True}
|
||||
self._htmx_extra = {}
|
||||
self._bindings = []
|
||||
self._ft = None
|
||||
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||
self._key = key
|
||||
|
||||
# special management when kwargs are provided
|
||||
# In this situation,
|
||||
# either there is no parameter (so one single instance of the command is enough)
|
||||
# or the parameter is a kwargs (so the parameters are provided when the command is called)
|
||||
if (self._key is None
|
||||
and self.owner is not None
|
||||
and args is None # args is not provided
|
||||
):
|
||||
self._key = f"{owner.get_full_id()}-{name}"
|
||||
|
||||
# register the command
|
||||
if auto_register:
|
||||
if self._key in CommandsManager.commands_by_key:
|
||||
self.id = CommandsManager.commands_by_key[self._key].id
|
||||
else:
|
||||
CommandsManager.register(self)
|
||||
CommandsManager.register(self)
|
||||
|
||||
def get_key(self):
|
||||
return self._key
|
||||
|
||||
def get_htmx_params(self, escaped=False, values_encode=None):
|
||||
res = {
|
||||
def get_htmx_params(self):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": {"c_id": f"{self.id}"},
|
||||
}
|
||||
|
||||
for k, v in self._htmx_extra.items():
|
||||
if k == "hx-post":
|
||||
continue # cannot override this one
|
||||
elif k == "hx-vals":
|
||||
res["hx-vals"] |= v
|
||||
else:
|
||||
res[k] = v
|
||||
|
||||
# kwarg are given to the callback as values
|
||||
res["hx-vals"] |= self.default_kwargs
|
||||
|
||||
if escaped:
|
||||
res["hx-vals"] = html.escape(json.dumps(res["hx-vals"]))
|
||||
|
||||
if values_encode == "json":
|
||||
res["hx-vals"] = json.dumps(res["hx-vals"])
|
||||
|
||||
return res
|
||||
} | self._htmx_extra
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
|
||||
with ObservableResultCollector(self._bindings) as collector:
|
||||
kwargs = self._create_kwargs(self.default_kwargs,
|
||||
client_response,
|
||||
{"client_response": client_response or {}})
|
||||
ret = self.callback(*self.default_args, **kwargs)
|
||||
|
||||
ret_from_bound_commands = []
|
||||
if self.owner:
|
||||
for command in self.owner.get_bound_commands(self.name):
|
||||
logger.debug(f" will execute bound command {command.name}...")
|
||||
r = command.execute(client_response)
|
||||
ret_from_bound_commands.append(r) # it will be flatten if needed later
|
||||
|
||||
all_ret = flatten(ret, ret_from_bound_commands, collector.results)
|
||||
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||
for r in all_ret[1:]:
|
||||
if (hasattr(r, 'attrs')
|
||||
and "hx-swap-oob" not in r.attrs
|
||||
and r.get("id", None) is not None):
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
|
||||
return all_ret[0] if len(all_ret) == 1 else all_ret
|
||||
raise NotImplementedError
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True):
|
||||
self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||
# Note that the default value is the same than in get_htmx_params()
|
||||
if target is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
@@ -207,23 +97,42 @@ class Command:
|
||||
def url(self):
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": self.default_kwargs
|
||||
}
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
|
||||
return res
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
def _cast_parameter(self, key, value):
|
||||
if key in self._callback_parameters:
|
||||
param = self._callback_parameters[key]
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Represents a command that encapsulates a callable action with parameters.
|
||||
|
||||
This class is designed to hold a defined action (callback) alongside its arguments
|
||||
and keyword arguments.
|
||||
|
||||
:ivar name: The name of the command.
|
||||
:type name: str
|
||||
:ivar description: A brief description of the command.
|
||||
:type description: str
|
||||
:ivar callback: The function or callable to be executed.
|
||||
:type callback: Callable
|
||||
:ivar args: Positional arguments to be passed to the callback.
|
||||
:type args: tuple
|
||||
:ivar kwargs: Keyword arguments to be passed to the callback.
|
||||
:type kwargs: dict
|
||||
"""
|
||||
|
||||
def __init__(self, name, description, callback, *args, **kwargs):
|
||||
super().__init__(name, description)
|
||||
self.callback = callback
|
||||
self.callback_parameters = dict(inspect.signature(callback).parameters)
|
||||
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:
|
||||
@@ -232,63 +141,65 @@ class Command:
|
||||
return float(value)
|
||||
elif param.annotation == list:
|
||||
return value.split(",")
|
||||
elif param.annotation == dict:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def _create_kwargs(self, *args):
|
||||
"""
|
||||
Try to recreate the requested kwargs from the client response and the default kwargs.
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
all_args = {}
|
||||
for arg in [arg for arg in args if arg is not None]:
|
||||
all_args |= arg
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
res = {}
|
||||
for k, v in self._callback_parameters.items():
|
||||
if k in all_args:
|
||||
res[k] = self._cast_parameter(k, all_args[k])
|
||||
return res
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
def binding_result_callback(attr, old, new, results):
|
||||
ret_from_bindings.extend(results)
|
||||
|
||||
for data in self._bindings:
|
||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
new_kwargs = self.kwargs.copy()
|
||||
if client_response:
|
||||
for k, v in client_response.items():
|
||||
if k in self.callback_parameters:
|
||||
new_kwargs[k] = self._convert(k, v)
|
||||
if 'client_response' in self.callback_parameters:
|
||||
new_kwargs['client_response'] = client_response
|
||||
|
||||
ret = self.callback(*self.args, **new_kwargs)
|
||||
|
||||
for data in self._bindings:
|
||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
# 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 not ret_from_bindings:
|
||||
return ret
|
||||
|
||||
if isinstance(ret, (list, tuple)):
|
||||
return list(ret) + ret_from_bindings
|
||||
else:
|
||||
return [ret] + ret_from_bindings
|
||||
|
||||
|
||||
class LambdaCommand(Command):
|
||||
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, owner, delegate)
|
||||
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, delegate)
|
||||
self.htmx(target=None)
|
||||
|
||||
|
||||
class CommandTemplate:
|
||||
def __init__(self, key, command_type, args: list = None, kwargs: dict = None):
|
||||
self.key = key
|
||||
args = args or []
|
||||
kwargs = kwargs or {}
|
||||
self.command = CommandsManager.get_command_by_key(key) or command_type(*args, **kwargs)
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
return self.callback(client_response)
|
||||
|
||||
|
||||
class CommandsManager:
|
||||
commands = {} # by_id
|
||||
commands_by_key = {}
|
||||
commands = {}
|
||||
|
||||
@staticmethod
|
||||
def register(command: Command):
|
||||
def register(command: BaseCommand):
|
||||
CommandsManager.commands[str(command.id)] = command
|
||||
if (key := command.get_key()) is not None:
|
||||
CommandsManager.commands_by_key[key] = command
|
||||
|
||||
@staticmethod
|
||||
def get_command(command_id: str) -> Optional[Command]:
|
||||
def get_command(command_id: str) -> Optional[BaseCommand]:
|
||||
return CommandsManager.commands.get(command_id)
|
||||
|
||||
@staticmethod
|
||||
def get_command_by_key(key):
|
||||
return CommandsManager.commands_by_key.get(key, None)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
CommandsManager.commands.clear()
|
||||
CommandsManager.commands_by_key.clear()
|
||||
return CommandsManager.commands.clear()
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
NO_DEFAULT_VALUE = object()
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
# Datagrid
|
||||
ROW_INDEX_ID = "__row_index__"
|
||||
DATAGRID_DEFAULT_COLUMN_WIDTH = 100
|
||||
DATAGRID_PAGE_SIZE = 1000
|
||||
FILTER_INPUT_CID = "__filter_input__"
|
||||
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
Bindings = "/bindings"
|
||||
Completions = "/completions"
|
||||
Validations = "/validations"
|
||||
|
||||
|
||||
class ColumnType(Enum):
|
||||
RowIndex = "RowIndex"
|
||||
Text = "Text"
|
||||
Number = "Number"
|
||||
Datetime = "DateTime"
|
||||
Bool = "Boolean"
|
||||
Choice = "Choice"
|
||||
Enum = "Enum"
|
||||
|
||||
|
||||
class ViewType(Enum):
|
||||
Table = "Table"
|
||||
Chart = "Chart"
|
||||
Form = "Form"
|
||||
|
||||
|
||||
class FooterAggregation(Enum):
|
||||
Sum = "Sum"
|
||||
Mean = "Mean"
|
||||
Min = "Min"
|
||||
Max = "Max"
|
||||
Count = "Count"
|
||||
FilteredSum = "FilteredSum"
|
||||
FilteredMean = "FilteredMean"
|
||||
FilteredMin = "FilteredMin"
|
||||
FilteredMax = "FilteredMax"
|
||||
FilteredCount = "FilteredCount"
|
||||
Bindings = "/bindings"
|
||||
@@ -1,19 +0,0 @@
|
||||
import pandas as pd
|
||||
from dbengine.handlers import BaseRefHandler
|
||||
|
||||
|
||||
class DataFrameHandler(BaseRefHandler):
|
||||
def is_eligible_for(self, obj):
|
||||
return isinstance(obj, pd.DataFrame)
|
||||
|
||||
def tag(self):
|
||||
return "DataFrame"
|
||||
|
||||
def serialize_to_bytes(self, df) -> bytes:
|
||||
from io import BytesIO
|
||||
import pickle
|
||||
return pickle.dumps(df)
|
||||
|
||||
def deserialize_from_bytes(self, data: bytes):
|
||||
import pickle
|
||||
return pickle.loads(data)
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -7,15 +6,12 @@ from dbengine.dbengine import DbEngine
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
from myfasthtml.core.utils import retrieve_user_info
|
||||
|
||||
logger = logging.getLogger("DbManager")
|
||||
|
||||
|
||||
class DbManager(SingleInstance):
|
||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(parent, auto_register=auto_register)
|
||||
|
||||
if not hasattr(self, "db"): # hack to manage singleton inheritance
|
||||
self.db = DbEngine(root=root)
|
||||
self.db = DbEngine(root=root)
|
||||
|
||||
def save(self, entry, obj):
|
||||
self.db.save(self.get_tenant(), self.get_user(), entry, obj)
|
||||
@@ -41,13 +37,10 @@ class DbObject:
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||
self._owner = owner
|
||||
self._name = name or owner.get_id()
|
||||
if self._name.startswith(("#", "-")) and owner.get_parent() is not None:
|
||||
self._name = owner.get_parent().get_id() + self._name
|
||||
self._name = name or self.__class__.__name__
|
||||
self._db_manager = db_manager or DbManager(self._owner)
|
||||
self._save_state = save_state
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@@ -58,46 +51,29 @@ class DbObject:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._initializing = old_state
|
||||
self._finalize_initialization()
|
||||
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 name.startswith("ns") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
if not name.startswith("ne_"):
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
|
||||
super().__setattr__(name, value)
|
||||
self._save_self()
|
||||
|
||||
def _finalize_initialization(self):
|
||||
if getattr(self, "_initializing", False):
|
||||
return # still under initialization
|
||||
|
||||
if self._reload_self():
|
||||
logger.debug(f"finalize_initialization ({self._name}) : Loaded existing content.")
|
||||
return
|
||||
else:
|
||||
logger.debug(
|
||||
f"finalize_initialization ({self._name}) : No existing content found, creating new entry {self._save_state=}.")
|
||||
self._save_self()
|
||||
|
||||
def _reload_self(self):
|
||||
if self._db_manager.exists_entry(self._name):
|
||||
props = self._db_manager.load(self._name)
|
||||
self.update(props)
|
||||
return True
|
||||
|
||||
return False
|
||||
else:
|
||||
self._save_self()
|
||||
|
||||
def _save_self(self):
|
||||
if not self._save_state:
|
||||
return
|
||||
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
|
||||
not k.startswith("_") and not k.startswith("ns")}
|
||||
if props:
|
||||
@@ -122,8 +98,6 @@ class DbObject:
|
||||
properties = {}
|
||||
if args:
|
||||
arg = args[0]
|
||||
if arg is None:
|
||||
return self
|
||||
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||
raise ValueError("Only dict or Expando are allowed as argument")
|
||||
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||
@@ -138,21 +112,8 @@ class DbObject:
|
||||
setattr(self, k, v)
|
||||
self._save_self()
|
||||
self._initializing = old_state
|
||||
return self
|
||||
|
||||
def copy(self):
|
||||
as_dict = self._get_properties().copy()
|
||||
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
|
||||
return SimpleNamespace(**as_dict)
|
||||
|
||||
def save(self):
|
||||
self._save_self()
|
||||
|
||||
def reload(self):
|
||||
self._reload_self()
|
||||
|
||||
def exists(self):
|
||||
return self._db_manager.exists_entry(self._name)
|
||||
|
||||
def get_id(self):
|
||||
return self._name
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Base class for DSL definitions.
|
||||
|
||||
DSLDefinition provides the interface for defining domain-specific languages
|
||||
that can be used with the DslEditor control and CodeMirror.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import cached_property
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from myfasthtml.core.dsl.lark_to_lezer import (
|
||||
lark_to_lezer_grammar,
|
||||
extract_completions_from_grammar,
|
||||
)
|
||||
from myfasthtml.core.utils import make_safe_id
|
||||
|
||||
|
||||
class DSLDefinition(ABC):
|
||||
"""
|
||||
Base class for DSL definitions.
|
||||
|
||||
Subclasses must implement get_grammar() to provide the Lark grammar.
|
||||
The Lezer grammar and completions are automatically derived.
|
||||
|
||||
Attributes:
|
||||
name: Human-readable name of the DSL.
|
||||
"""
|
||||
|
||||
name: str = "DSL"
|
||||
|
||||
@abstractmethod
|
||||
def get_grammar(self) -> str:
|
||||
"""
|
||||
Return the Lark grammar string for this DSL.
|
||||
|
||||
Returns:
|
||||
The Lark grammar as a string.
|
||||
"""
|
||||
pass
|
||||
|
||||
@cached_property
|
||||
def lezer_grammar(self) -> str:
|
||||
"""
|
||||
Return the Lezer grammar derived from the Lark grammar.
|
||||
|
||||
This is cached after first computation.
|
||||
|
||||
Returns:
|
||||
The Lezer grammar as a string.
|
||||
"""
|
||||
return lark_to_lezer_grammar(self.get_grammar())
|
||||
|
||||
@cached_property
|
||||
def completions(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Return completion items extracted from the grammar.
|
||||
|
||||
This is cached after first computation.
|
||||
|
||||
Returns:
|
||||
Dictionary with completion categories:
|
||||
- 'keywords': Language keywords (if, not, and, etc.)
|
||||
- 'operators': Comparison and arithmetic operators
|
||||
- 'functions': Function-like constructs (style, format, etc.)
|
||||
- 'types': Type names (number, date, boolean, etc.)
|
||||
- 'literals': Literal values (True, False, etc.)
|
||||
"""
|
||||
return extract_completions_from_grammar(self.get_grammar())
|
||||
|
||||
def get_editor_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the configuration for the DslEditor JavaScript initialization.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- 'lezerGrammar': The Lezer grammar string
|
||||
- 'completions': The completion items
|
||||
- 'name': The DSL name
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"lezerGrammar": self.lezer_grammar,
|
||||
"completions": self.completions,
|
||||
}
|
||||
|
||||
def get_id(self):
|
||||
return make_safe_id(self.name)
|
||||
@@ -1,172 +0,0 @@
|
||||
"""
|
||||
Base completion engine for DSL autocompletion.
|
||||
|
||||
Provides an abstract base class that specific DSL implementations
|
||||
can extend to provide context-aware autocompletion.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from . import utils
|
||||
from .base_provider import BaseMetadataProvider
|
||||
from .types import Position, Suggestion, CompletionResult
|
||||
|
||||
|
||||
class BaseCompletionEngine(ABC):
|
||||
"""
|
||||
Abstract base class for DSL completion engines.
|
||||
|
||||
Subclasses must implement:
|
||||
- detect_scope(): Find the current scope from previous lines
|
||||
- detect_context(): Determine what kind of completion is expected
|
||||
- get_suggestions(): Generate suggestions for the detected context
|
||||
|
||||
The main entry point is get_completions(), which orchestrates the flow.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: BaseMetadataProvider):
|
||||
"""
|
||||
Initialize the completion engine.
|
||||
|
||||
Args:
|
||||
provider: Metadata provider for context-aware suggestions
|
||||
"""
|
||||
self.provider = provider
|
||||
|
||||
def get_completions(self, text: str, cursor: Position) -> CompletionResult:
|
||||
"""
|
||||
Get autocompletion suggestions for the given cursor position.
|
||||
|
||||
This is the main entry point. It:
|
||||
1. Checks if cursor is in a comment (no suggestions)
|
||||
2. Detects the current scope (e.g., which column)
|
||||
3. Detects the completion context (what kind of token is expected)
|
||||
4. Generates and filters suggestions
|
||||
|
||||
Args:
|
||||
text: The full DSL document text
|
||||
cursor: Cursor position
|
||||
|
||||
Returns:
|
||||
CompletionResult with suggestions and replacement range
|
||||
"""
|
||||
# Get the current line up to cursor
|
||||
line = utils.get_line_at(text, cursor.line)
|
||||
line_to_cursor = utils.get_line_up_to_cursor(text, cursor)
|
||||
|
||||
# Check if in comment - no suggestions
|
||||
if utils.is_in_comment(line, cursor.ch):
|
||||
return self._empty_result(cursor)
|
||||
|
||||
# Find word boundaries for replacement range
|
||||
word_range = utils.find_word_boundaries(line, cursor.ch)
|
||||
prefix = line[word_range.start: cursor.ch]
|
||||
|
||||
# Detect scope from previous lines
|
||||
scope = self.detect_scope(text, cursor.line)
|
||||
|
||||
# Detect completion context
|
||||
context = self.detect_context(text, cursor, scope)
|
||||
|
||||
# Get suggestions for this context
|
||||
suggestions = self.get_suggestions(context, scope, prefix)
|
||||
|
||||
# Filter suggestions by prefix
|
||||
if prefix:
|
||||
suggestions = self._filter_suggestions(suggestions, prefix)
|
||||
|
||||
# Build result with correct positions
|
||||
from_pos = Position(line=cursor.line, ch=word_range.start)
|
||||
to_pos = Position(line=cursor.line, ch=word_range.end)
|
||||
|
||||
return CompletionResult(
|
||||
from_pos=from_pos,
|
||||
to_pos=to_pos,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def detect_scope(self, text: str, current_line: int) -> Any:
|
||||
"""
|
||||
Detect the current scope by scanning previous lines.
|
||||
|
||||
The scope determines which data context we're in (e.g., which column
|
||||
for column values suggestions).
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
current_line: Current line number (0-based)
|
||||
|
||||
Returns:
|
||||
Scope object (type depends on the specific DSL)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def detect_context(self, text: str, cursor: Position, scope: Any) -> Any:
|
||||
"""
|
||||
Detect the completion context at the cursor position.
|
||||
|
||||
Analyzes the current line to determine what kind of token
|
||||
is expected (e.g., keyword, preset name, operator).
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
cursor: Cursor position
|
||||
scope: The detected scope
|
||||
|
||||
Returns:
|
||||
Context identifier (type depends on the specific DSL)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_suggestions(self, context: Any, scope: Any, prefix: str) -> list[Suggestion]:
|
||||
"""
|
||||
Generate suggestions for the given context.
|
||||
|
||||
Args:
|
||||
context: The detected completion context
|
||||
scope: The detected scope
|
||||
prefix: The current word prefix (for filtering)
|
||||
|
||||
Returns:
|
||||
List of suggestions
|
||||
"""
|
||||
pass
|
||||
|
||||
def _filter_suggestions(
|
||||
self, suggestions: list[Suggestion], prefix: str
|
||||
) -> list[Suggestion]:
|
||||
"""
|
||||
Filter suggestions by prefix (case-insensitive).
|
||||
|
||||
Args:
|
||||
suggestions: List of suggestions
|
||||
prefix: Prefix to filter by
|
||||
|
||||
Returns:
|
||||
Filtered list of suggestions
|
||||
"""
|
||||
prefix_lower = prefix.lower()
|
||||
return [s for s in suggestions if s.label.lower().startswith(prefix_lower)]
|
||||
|
||||
def _empty_result(self, cursor: Position) -> CompletionResult:
|
||||
"""
|
||||
Return an empty completion result.
|
||||
|
||||
Args:
|
||||
cursor: Cursor position
|
||||
|
||||
Returns:
|
||||
CompletionResult with no suggestions
|
||||
"""
|
||||
return CompletionResult(
|
||||
from_pos=cursor,
|
||||
to_pos=cursor,
|
||||
suggestions=[],
|
||||
)
|
||||
|
||||
def get_id(self):
|
||||
return type(self).__name__
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Base provider protocol for DSL autocompletion.
|
||||
|
||||
Defines the minimal interface that metadata providers must implement
|
||||
to support context-aware autocompletion.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BaseMetadataProvider(Protocol):
|
||||
"""
|
||||
Protocol defining the interface for metadata providers.
|
||||
|
||||
Metadata providers give the autocompletion engine access to
|
||||
context-specific data (e.g., column names, available values).
|
||||
|
||||
This is a minimal interface. Specific DSL implementations
|
||||
can extend this with additional methods.
|
||||
"""
|
||||
|
||||
def get_style_presets(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available style preset names.
|
||||
|
||||
Returns:
|
||||
List of style preset names (e.g., ["primary", "error", "success"])
|
||||
"""
|
||||
...
|
||||
|
||||
def get_format_presets(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available format preset names.
|
||||
|
||||
Returns:
|
||||
List of format preset names (e.g., ["EUR", "USD", "percentage"])
|
||||
"""
|
||||
...
|
||||
@@ -1,256 +0,0 @@
|
||||
"""
|
||||
Utilities for converting Lark grammars to Lezer format and extracting completions.
|
||||
|
||||
This module provides functions to:
|
||||
1. Transform a Lark grammar to a Lezer grammar for CodeMirror
|
||||
2. Extract completion items (keywords, operators, etc.) from a Lark grammar
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Set
|
||||
|
||||
|
||||
def lark_to_lezer_grammar(lark_grammar: str) -> str:
|
||||
"""
|
||||
Convert a Lark grammar to a Lezer grammar.
|
||||
|
||||
This is a simplified converter that handles common Lark patterns.
|
||||
Complex grammars may require manual adjustment.
|
||||
|
||||
Args:
|
||||
lark_grammar: The Lark grammar string.
|
||||
|
||||
Returns:
|
||||
The Lezer grammar string.
|
||||
"""
|
||||
lines = lark_grammar.strip().split("\n")
|
||||
lezer_rules = []
|
||||
tokens = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith("//") or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Skip Lark-specific directives
|
||||
if line.startswith("%"):
|
||||
continue
|
||||
|
||||
# Parse rule definitions (lowercase names only)
|
||||
rule_match = re.match(r"^([a-z_][a-z0-9_]*)\s*:\s*(.+)$", line)
|
||||
if rule_match:
|
||||
name, body = rule_match.groups()
|
||||
lezer_rule = _convert_rule(name, body)
|
||||
if lezer_rule:
|
||||
lezer_rules.append(lezer_rule)
|
||||
continue
|
||||
|
||||
# Parse terminal definitions (uppercase names)
|
||||
terminal_match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*:\s*(.+)$", line)
|
||||
if terminal_match:
|
||||
name, pattern = terminal_match.groups()
|
||||
token = _convert_terminal(name, pattern)
|
||||
if token:
|
||||
tokens.append(token)
|
||||
|
||||
# Build Lezer grammar
|
||||
lezer_output = ["@top Start { scope+ }", ""]
|
||||
|
||||
# Add rules
|
||||
for rule in lezer_rules:
|
||||
lezer_output.append(rule)
|
||||
|
||||
lezer_output.append("")
|
||||
lezer_output.append("@tokens {")
|
||||
|
||||
# Add tokens
|
||||
for token in tokens:
|
||||
lezer_output.append(f" {token}")
|
||||
|
||||
# Add common tokens
|
||||
lezer_output.extend([
|
||||
' whitespace { $[ \\t]+ }',
|
||||
' newline { $[\\n\\r] }',
|
||||
' Comment { "#" ![$\\n]* }',
|
||||
])
|
||||
|
||||
lezer_output.append("}")
|
||||
lezer_output.append("")
|
||||
lezer_output.append("@skip { whitespace | Comment }")
|
||||
|
||||
return "\n".join(lezer_output)
|
||||
|
||||
|
||||
def _convert_rule(name: str, body: str) -> str:
|
||||
"""Convert a single Lark rule to Lezer format."""
|
||||
# Skip internal rules (starting with _)
|
||||
if name.startswith("_"):
|
||||
return ""
|
||||
|
||||
# Convert rule name to PascalCase for Lezer
|
||||
lezer_name = _to_pascal_case(name)
|
||||
|
||||
# Convert body
|
||||
lezer_body = _convert_body(body)
|
||||
|
||||
if lezer_body:
|
||||
return f"{lezer_name} {{ {lezer_body} }}"
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_terminal(name: str, pattern: str) -> str:
|
||||
"""Convert a Lark terminal to Lezer token format."""
|
||||
pattern = pattern.strip()
|
||||
|
||||
# Handle regex patterns
|
||||
if pattern.startswith("/") and pattern.endswith("/"):
|
||||
regex = pattern[1:-1]
|
||||
# Convert to Lezer regex format
|
||||
return f'{name} {{ ${regex}$ }}'
|
||||
|
||||
# Handle string literals
|
||||
if pattern.startswith('"') or pattern.startswith("'"):
|
||||
return f'{name} {{ {pattern} }}'
|
||||
|
||||
# Handle alternatives (literal strings separated by |)
|
||||
if "|" in pattern:
|
||||
alternatives = [alt.strip() for alt in pattern.split("|")]
|
||||
if all(alt.startswith('"') or alt.startswith("'") for alt in alternatives):
|
||||
return f'{name} {{ {" | ".join(alternatives)} }}'
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_body(body: str) -> str:
|
||||
"""Convert the body of a Lark rule to Lezer format."""
|
||||
# Remove inline transformations (-> name)
|
||||
body = re.sub(r"\s*->\s*\w+", "", body)
|
||||
|
||||
# Convert alternatives
|
||||
parts = []
|
||||
for alt in body.split("|"):
|
||||
alt = alt.strip()
|
||||
if alt:
|
||||
converted = _convert_sequence(alt)
|
||||
if converted:
|
||||
parts.append(converted)
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _convert_sequence(seq: str) -> str:
|
||||
"""Convert a sequence of items in a rule."""
|
||||
items = []
|
||||
|
||||
# Tokenize the sequence
|
||||
tokens = re.findall(
|
||||
r'"[^"]*"|\'[^\']*\'|/[^/]+/|\([^)]+\)|\[[^\]]+\]|[a-zA-Z_][a-zA-Z0-9_]*|\?|\*|\+',
|
||||
seq
|
||||
)
|
||||
|
||||
for token in tokens:
|
||||
if token.startswith('"') or token.startswith("'"):
|
||||
# String literal
|
||||
items.append(token)
|
||||
elif token.startswith("("):
|
||||
# Group
|
||||
inner = token[1:-1]
|
||||
items.append(f"({_convert_body(inner)})")
|
||||
elif token.startswith("["):
|
||||
# Optional group in Lark
|
||||
inner = token[1:-1]
|
||||
items.append(f"({_convert_body(inner)})?")
|
||||
elif token in ("?", "*", "+"):
|
||||
# Quantifiers - attach to previous item
|
||||
if items:
|
||||
items[-1] = items[-1] + token
|
||||
elif token.isupper() or token.startswith("_"):
|
||||
# Terminal reference
|
||||
items.append(token)
|
||||
elif token.islower() or "_" in token:
|
||||
# Rule reference - convert to PascalCase
|
||||
items.append(_to_pascal_case(token))
|
||||
|
||||
return " ".join(items)
|
||||
|
||||
|
||||
def _to_pascal_case(name: str) -> str:
|
||||
"""Convert snake_case to PascalCase."""
|
||||
return "".join(word.capitalize() for word in name.split("_"))
|
||||
|
||||
|
||||
def extract_completions_from_grammar(lark_grammar: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Extract completion items from a Lark grammar.
|
||||
|
||||
Parses the grammar to find:
|
||||
- Keywords (reserved words like if, not, and)
|
||||
- Operators (==, !=, contains, etc.)
|
||||
- Functions (style, format, etc.)
|
||||
- Types (number, date, boolean, etc.)
|
||||
- Literals (True, False, etc.)
|
||||
|
||||
Args:
|
||||
lark_grammar: The Lark grammar string.
|
||||
|
||||
Returns:
|
||||
Dictionary with completion categories.
|
||||
"""
|
||||
keywords: Set[str] = set()
|
||||
operators: Set[str] = set()
|
||||
functions: Set[str] = set()
|
||||
types: Set[str] = set()
|
||||
literals: Set[str] = set()
|
||||
|
||||
# Find all quoted strings (potential keywords/operators)
|
||||
quoted_strings = re.findall(r'"([^"]+)"', lark_grammar)
|
||||
|
||||
# Also look for terminal definitions with string alternatives (e.g., BOOLEAN: "True" | "False")
|
||||
terminal_literals = re.findall(r'[A-Z_]+:\s*"([^"]+)"(?:\s*\|\s*"([^"]+)")*', lark_grammar)
|
||||
for match in terminal_literals:
|
||||
for literal in match:
|
||||
if literal:
|
||||
quoted_strings.append(literal)
|
||||
|
||||
for s in quoted_strings:
|
||||
s_lower = s.lower()
|
||||
|
||||
# Classify based on pattern
|
||||
if s in ("==", "!=", "<=", "<", ">=", ">", "+", "-", "*", "/"):
|
||||
operators.add(s)
|
||||
elif s_lower in ("contains", "startswith", "endswith", "in", "between", "isempty", "isnotempty"):
|
||||
operators.add(s_lower)
|
||||
elif s_lower in ("if", "not", "and", "or"):
|
||||
keywords.add(s_lower)
|
||||
elif s_lower in ("true", "false"):
|
||||
literals.add(s)
|
||||
elif s_lower in ("style", "format"):
|
||||
functions.add(s_lower)
|
||||
elif s_lower in ("column", "row", "cell", "value", "col"):
|
||||
keywords.add(s_lower)
|
||||
elif s_lower in ("number", "date", "boolean", "text", "enum"):
|
||||
types.add(s_lower)
|
||||
elif s_lower == "case":
|
||||
keywords.add(s_lower)
|
||||
|
||||
# Find function-like patterns: word "("
|
||||
function_patterns = re.findall(r'"(\w+)"\s*"?\("', lark_grammar)
|
||||
for func in function_patterns:
|
||||
if func.lower() not in ("true", "false"):
|
||||
functions.add(func.lower())
|
||||
|
||||
# Find type patterns from format_type rule
|
||||
type_match = re.search(r'format_type\s*:\s*(.+?)(?:\n\n|\Z)', lark_grammar, re.DOTALL)
|
||||
if type_match:
|
||||
type_strings = re.findall(r'"(\w+)"', type_match.group(1))
|
||||
types.update(t.lower() for t in type_strings)
|
||||
|
||||
return {
|
||||
"keywords": sorted(keywords),
|
||||
"operators": sorted(operators),
|
||||
"functions": sorted(functions),
|
||||
"types": sorted(types),
|
||||
"literals": sorted(literals),
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"""
|
||||
Base types for DSL autocompletion.
|
||||
|
||||
Provides dataclasses for cursor position, suggestions, and completion results
|
||||
compatible with CodeMirror 5.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Position:
|
||||
"""
|
||||
Cursor position in a document.
|
||||
|
||||
Compatible with CodeMirror 5 position format.
|
||||
|
||||
Attributes:
|
||||
line: 0-based line number
|
||||
ch: 0-based character position in the line
|
||||
"""
|
||||
|
||||
line: int
|
||||
ch: int
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
"""Convert to CodeMirror-compatible dictionary."""
|
||||
return {"line": self.line, "ch": self.ch}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Suggestion:
|
||||
"""
|
||||
A single autocompletion suggestion.
|
||||
|
||||
Attributes:
|
||||
label: The text to display and insert
|
||||
detail: Optional description shown next to the label
|
||||
kind: Optional category (e.g., "keyword", "preset", "value")
|
||||
"""
|
||||
|
||||
label: str
|
||||
detail: str = ""
|
||||
kind: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {"label": self.label}
|
||||
if self.detail:
|
||||
result["detail"] = self.detail
|
||||
if self.kind:
|
||||
result["kind"] = self.kind
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompletionResult:
|
||||
"""
|
||||
Result of an autocompletion request.
|
||||
|
||||
Compatible with CodeMirror 5 hint format.
|
||||
|
||||
Attributes:
|
||||
from_pos: Start position of the text to replace
|
||||
to_pos: End position of the text to replace
|
||||
suggestions: List of completion suggestions
|
||||
"""
|
||||
|
||||
from_pos: Position
|
||||
to_pos: Position
|
||||
suggestions: list[Suggestion] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to CodeMirror-compatible dictionary."""
|
||||
return {
|
||||
"from": self.from_pos.to_dict(),
|
||||
"to": self.to_pos.to_dict(),
|
||||
"suggestions": [s.to_dict() for s in self.suggestions],
|
||||
}
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return True if there are no suggestions."""
|
||||
return len(self.suggestions) == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WordRange:
|
||||
"""
|
||||
Range of a word in a line.
|
||||
|
||||
Used for determining what text to replace when applying a suggestion.
|
||||
|
||||
Attributes:
|
||||
start: Start character position (inclusive)
|
||||
end: End character position (exclusive)
|
||||
text: The word text
|
||||
"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
text: str = ""
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Shared utilities for DSL autocompletion.
|
||||
|
||||
Provides helper functions for text analysis, word boundary detection,
|
||||
and other common operations used by completion engines.
|
||||
"""
|
||||
|
||||
from .types import Position, WordRange
|
||||
|
||||
# Delimiters used to detect word boundaries
|
||||
DELIMITERS = set('"\' ()[]{}=,:<>!\t\n\r')
|
||||
|
||||
|
||||
def get_line_at(text: str, line_number: int) -> str:
|
||||
"""
|
||||
Get the content of a specific line.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
line_number: 0-based line number
|
||||
|
||||
Returns:
|
||||
The line content, or empty string if line doesn't exist
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
if 0 <= line_number < len(lines):
|
||||
return lines[line_number]
|
||||
return ""
|
||||
|
||||
|
||||
def get_line_up_to_cursor(text: str, cursor: Position) -> str:
|
||||
"""
|
||||
Get the content of the current line up to the cursor position.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
cursor: Cursor position
|
||||
|
||||
Returns:
|
||||
The line content from start to cursor position
|
||||
"""
|
||||
line = get_line_at(text, cursor.line)
|
||||
return line[: cursor.ch]
|
||||
|
||||
|
||||
def get_lines_up_to(text: str, line_number: int) -> list[str]:
|
||||
"""
|
||||
Get all lines from start up to and including the specified line.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
line_number: 0-based line number (inclusive)
|
||||
|
||||
Returns:
|
||||
List of lines from 0 to line_number
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
return lines[: line_number + 1]
|
||||
|
||||
|
||||
def find_word_boundaries(line: str, cursor_ch: int) -> WordRange:
|
||||
"""
|
||||
Find the word boundaries around the cursor position.
|
||||
|
||||
Uses delimiters to detect where a word starts and ends.
|
||||
The cursor can be anywhere within the word.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
WordRange with start, end positions and the word text
|
||||
"""
|
||||
if not line or cursor_ch < 0:
|
||||
return WordRange(start=cursor_ch, end=cursor_ch, text="")
|
||||
|
||||
# Clamp cursor position to line length
|
||||
cursor_ch = min(cursor_ch, len(line))
|
||||
|
||||
# Find start of word (scan backwards from cursor)
|
||||
start = cursor_ch
|
||||
while start > 0 and line[start - 1] not in DELIMITERS:
|
||||
start -= 1
|
||||
|
||||
# Find end of word (scan forwards from cursor)
|
||||
end = cursor_ch
|
||||
while end < len(line) and line[end] not in DELIMITERS:
|
||||
end += 1
|
||||
|
||||
word = line[start:end]
|
||||
return WordRange(start=start, end=end, text=word)
|
||||
|
||||
|
||||
def get_prefix(line: str, cursor_ch: int) -> str:
|
||||
"""
|
||||
Get the word prefix before the cursor.
|
||||
|
||||
This is the text from the start of the current word to the cursor.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
The prefix text
|
||||
"""
|
||||
word_range = find_word_boundaries(line, cursor_ch)
|
||||
# Prefix is from word start to cursor
|
||||
return line[word_range.start: cursor_ch]
|
||||
|
||||
|
||||
def is_in_comment(line: str, cursor_ch: int) -> bool:
|
||||
"""
|
||||
Check if the cursor is inside a comment.
|
||||
|
||||
A comment starts with # and extends to the end of the line.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
True if cursor is after a # character
|
||||
"""
|
||||
# Find first # that's not inside a string
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
for i, char in enumerate(line):
|
||||
if i >= cursor_ch:
|
||||
break
|
||||
|
||||
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
|
||||
if not in_string:
|
||||
in_string = True
|
||||
string_char = char
|
||||
elif char == string_char:
|
||||
in_string = False
|
||||
string_char = None
|
||||
elif char == "#" and not in_string:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_in_string(line: str, cursor_ch: int) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if the cursor is inside a string literal.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
Tuple of (is_in_string, quote_char)
|
||||
quote_char is '"' or "'" if inside a string, None otherwise
|
||||
"""
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
for i, char in enumerate(line):
|
||||
if i >= cursor_ch:
|
||||
break
|
||||
|
||||
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
|
||||
if not in_string:
|
||||
in_string = True
|
||||
string_char = char
|
||||
elif char == string_char:
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
return in_string, string_char if in_string else None
|
||||
|
||||
|
||||
def get_indentation(line: str) -> int:
|
||||
"""
|
||||
Get the indentation level of a line.
|
||||
|
||||
Counts leading spaces (tabs are converted to 4 spaces).
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
|
||||
Returns:
|
||||
Number of leading spaces
|
||||
"""
|
||||
count = 0
|
||||
for char in line:
|
||||
if char == " ":
|
||||
count += 1
|
||||
elif char == "\t":
|
||||
count += 4
|
||||
else:
|
||||
break
|
||||
return count
|
||||
|
||||
|
||||
def is_indented(line: str) -> bool:
|
||||
"""
|
||||
Check if a line is indented (has leading whitespace).
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
|
||||
Returns:
|
||||
True if line starts with whitespace
|
||||
"""
|
||||
return len(line) > 0 and line[0] in (" ", "\t")
|
||||
|
||||
|
||||
def strip_quotes(text: str) -> str:
|
||||
"""
|
||||
Remove surrounding quotes from a string.
|
||||
|
||||
Args:
|
||||
text: Text that may be quoted
|
||||
|
||||
Returns:
|
||||
Text without surrounding quotes
|
||||
"""
|
||||
if len(text) >= 2:
|
||||
if (text[0] == '"' and text[-1] == '"') or (text[0] == "'" and text[-1] == "'"):
|
||||
return text[1:-1]
|
||||
return text
|
||||
@@ -1,31 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
|
||||
|
||||
@dataclass
|
||||
class DslDefinition:
|
||||
completion: BaseCompletionEngine
|
||||
validation: DSLParser # To do, this parser is not generic (specific to the Formatting DSL)
|
||||
|
||||
|
||||
class DslsManager:
|
||||
dsls: dict[str, DslDefinition] = {}
|
||||
|
||||
@staticmethod
|
||||
def register(dsl_id: str, completion: BaseCompletionEngine, validation: DSLParser):
|
||||
# then engine_id is actually the DSL id
|
||||
DslsManager.dsls[dsl_id] = DslDefinition(completion, validation)
|
||||
|
||||
@staticmethod
|
||||
def get_completion_engine(engine_id) -> BaseCompletionEngine:
|
||||
return DslsManager.dsls[engine_id].completion
|
||||
|
||||
@staticmethod
|
||||
def get_validation_parser(engine_id) -> DSLParser:
|
||||
return DslsManager.dsls[engine_id].validation
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
DslsManager.dsls = {}
|
||||
@@ -1 +0,0 @@
|
||||
# Formatting module for DataGrid
|
||||
@@ -1,200 +0,0 @@
|
||||
from numbers import Number
|
||||
from typing import Any
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import Condition
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""Evaluates conditions against cell values."""
|
||||
|
||||
def evaluate(self, condition: Condition, cell_value: Any, row_data: dict = None) -> bool:
|
||||
"""
|
||||
Evaluate a condition against a cell value.
|
||||
|
||||
Args:
|
||||
condition: The condition to evaluate
|
||||
cell_value: The value of the current cell
|
||||
row_data: Dict of {col_id: value} for column references (optional)
|
||||
|
||||
Returns:
|
||||
True if condition is met, False otherwise
|
||||
"""
|
||||
# If col parameter is set, use that column's value instead of cell_value
|
||||
if condition.col is not None:
|
||||
if row_data is None or condition.col not in row_data:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
cell_value = row_data[condition.col]
|
||||
|
||||
# Handle isempty/isnotempty first (they work with None)
|
||||
if condition.operator == "isempty":
|
||||
result = self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
if condition.operator == "isnotempty":
|
||||
result = not self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
if condition.operator == "isnan":
|
||||
result = self._is_nan(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
# For all other operators, None cell_value returns False
|
||||
if cell_value is None:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Resolve the comparison value (might be a column reference)
|
||||
compare_value = self._resolve_value(condition.value, row_data)
|
||||
|
||||
# If reference resolved to None, return False
|
||||
if compare_value is None and isinstance(condition.value, dict):
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Evaluate based on operator
|
||||
result = self._evaluate_operator(
|
||||
condition.operator,
|
||||
cell_value,
|
||||
compare_value,
|
||||
condition.case_sensitive
|
||||
)
|
||||
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
def _resolve_value(self, value: Any, row_data: dict) -> Any:
|
||||
"""Resolve a value, handling column references."""
|
||||
if isinstance(value, dict) and "col" in value:
|
||||
if row_data is None:
|
||||
return None
|
||||
col_id = value["col"]
|
||||
return row_data.get(col_id)
|
||||
return value
|
||||
|
||||
def _is_empty(self, value: Any) -> bool:
|
||||
"""Check if a value is empty (None or empty string)."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and value == "":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_nan(self, value: Any) -> bool:
|
||||
"""Check if a value is NaN."""
|
||||
return isinstance(value, float) and value != value
|
||||
|
||||
def _apply_negate(self, result: bool, negate: bool) -> bool:
|
||||
"""Apply negation if needed."""
|
||||
return not result if negate else result
|
||||
|
||||
def _evaluate_operator(
|
||||
self,
|
||||
operator: str,
|
||||
cell_value: Any,
|
||||
compare_value: Any,
|
||||
case_sensitive: bool
|
||||
) -> bool:
|
||||
"""Evaluate a specific operator."""
|
||||
try:
|
||||
if operator == "==":
|
||||
return self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "!=":
|
||||
return not self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "<":
|
||||
return self._less_than(cell_value, compare_value)
|
||||
elif operator == "<=":
|
||||
return self._less_than_or_equal(cell_value, compare_value)
|
||||
elif operator == ">":
|
||||
return self._greater_than(cell_value, compare_value)
|
||||
elif operator == ">=":
|
||||
return self._greater_than_or_equal(cell_value, compare_value)
|
||||
elif operator == "contains":
|
||||
return self._contains(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "startswith":
|
||||
return self._startswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "endswith":
|
||||
return self._endswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "in":
|
||||
return self._in_list(cell_value, compare_value)
|
||||
elif operator == "between":
|
||||
return self._between(cell_value, compare_value)
|
||||
else:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# Type mismatch or invalid operation
|
||||
return False
|
||||
|
||||
def _equals(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check equality, with optional case-insensitive string comparison."""
|
||||
if isinstance(cell_value, str) and isinstance(compare_value, str):
|
||||
if case_sensitive:
|
||||
return cell_value == compare_value
|
||||
return cell_value.lower() == compare_value.lower()
|
||||
return cell_value == compare_value
|
||||
|
||||
def _less_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value < compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
# Allow int/float comparison
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value < compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value < compare_value
|
||||
|
||||
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value <= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value <= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value <= compare_value
|
||||
|
||||
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value > compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value > compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value > compare_value
|
||||
|
||||
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value >= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value >= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value >= compare_value
|
||||
|
||||
def _contains(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value contains compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return compare_str in cell_str
|
||||
return compare_str.lower() in cell_str.lower()
|
||||
|
||||
def _startswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value starts with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.startswith(compare_str)
|
||||
return cell_str.lower().startswith(compare_str.lower())
|
||||
|
||||
def _endswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value ends with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.endswith(compare_str)
|
||||
return cell_str.lower().endswith(compare_str.lower())
|
||||
|
||||
def _in_list(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is in the list."""
|
||||
if not isinstance(compare_value, list):
|
||||
raise TypeError("'in' operator requires a list")
|
||||
return cell_value in compare_value
|
||||
|
||||
def _between(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is between min and max (inclusive)."""
|
||||
if not isinstance(compare_value, list) or len(compare_value) != 2:
|
||||
raise TypeError("'between' operator requires a list of [min, max]")
|
||||
min_val, max_val = compare_value
|
||||
return min_val <= cell_value <= max_val
|
||||
@@ -1,168 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# === Condition ===
|
||||
|
||||
@dataclass
|
||||
class Condition:
|
||||
"""
|
||||
Represents a condition for conditional formatting.
|
||||
|
||||
Attributes:
|
||||
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
|
||||
"contains", "startswith", "endswith", "in", "between",
|
||||
"isempty", "isnotempty")
|
||||
value: Value to compare against (literal, list, or {"col": "..."} for reference)
|
||||
negate: If True, inverts the condition result
|
||||
case_sensitive: If True, string comparisons are case-sensitive (default False)
|
||||
col: Column ID for row-level conditions (evaluate this column instead of current cell)
|
||||
row: Row index for column-level conditions (evaluate this row instead of current cell)
|
||||
"""
|
||||
operator: str
|
||||
value: Any = None
|
||||
negate: bool = False
|
||||
case_sensitive: bool = False
|
||||
col: str = None
|
||||
row: int = None
|
||||
|
||||
|
||||
# === Style ===
|
||||
|
||||
@dataclass
|
||||
class Style:
|
||||
"""
|
||||
Represents style properties for cell formatting.
|
||||
|
||||
Attributes:
|
||||
preset: Name of a style preset ("primary", "success", "error", etc.)
|
||||
background_color: Background color (hex, CSS name, or CSS variable)
|
||||
color: Text color
|
||||
font_weight: "normal" or "bold"
|
||||
font_style: "normal" or "italic"
|
||||
font_size: Font size ("12px", "0.9em")
|
||||
text_decoration: "none", "underline", or "line-through"
|
||||
"""
|
||||
preset: str = None
|
||||
background_color: str = None
|
||||
color: str = None
|
||||
font_weight: str = None
|
||||
font_style: str = None
|
||||
font_size: str = None
|
||||
text_decoration: str = None
|
||||
|
||||
|
||||
# === Formatters ===
|
||||
|
||||
@dataclass
|
||||
class Formatter:
|
||||
"""Base class for all formatters."""
|
||||
preset: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberFormatter(Formatter):
|
||||
"""
|
||||
Formatter for numbers, currencies, and percentages.
|
||||
|
||||
Attributes:
|
||||
prefix: Text before value (e.g., "$")
|
||||
suffix: Text after value (e.g., " EUR")
|
||||
thousands_sep: Thousands separator (e.g., ",", " ")
|
||||
decimal_sep: Decimal separator (e.g., ".", ",")
|
||||
precision: Number of decimal places
|
||||
multiplier: Multiply value before display (e.g., 100 for percentage)
|
||||
"""
|
||||
prefix: str = ""
|
||||
suffix: str = ""
|
||||
thousands_sep: str = ""
|
||||
decimal_sep: str = "."
|
||||
precision: int = 0
|
||||
multiplier: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateFormatter(Formatter):
|
||||
"""
|
||||
Formatter for dates and datetimes.
|
||||
|
||||
Attributes:
|
||||
format: strftime format pattern (default: "%Y-%m-%d")
|
||||
"""
|
||||
format: str = "%Y-%m-%d"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BooleanFormatter(Formatter):
|
||||
"""
|
||||
Formatter for boolean values.
|
||||
|
||||
Attributes:
|
||||
true_value: Display string for True
|
||||
false_value: Display string for False
|
||||
null_value: Display string for None/null
|
||||
"""
|
||||
true_value: str = "true"
|
||||
false_value: str = "false"
|
||||
null_value: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextFormatter(Formatter):
|
||||
"""
|
||||
Formatter for text transformations.
|
||||
|
||||
Attributes:
|
||||
transform: Text transformation ("uppercase", "lowercase", "capitalize")
|
||||
max_length: Maximum length before truncation
|
||||
ellipsis: Suffix when truncated (default: "...")
|
||||
"""
|
||||
transform: str = None
|
||||
max_length: int = None
|
||||
ellipsis: str = "..."
|
||||
|
||||
@dataclass
|
||||
class ConstantFormatter(Formatter):
|
||||
value: str = None
|
||||
|
||||
@dataclass
|
||||
class EnumFormatter(Formatter):
|
||||
"""
|
||||
Formatter for mapping values to display labels.
|
||||
|
||||
Attributes:
|
||||
source: Data source dict with "type" and "value" keys
|
||||
- {"type": "mapping", "value": {"key": "label", ...}}
|
||||
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
|
||||
default: Label for unknown values
|
||||
allow_empty: Show empty option in Select dropdowns
|
||||
empty_label: Label for empty option
|
||||
order_by: Sort order ("source", "display", "value")
|
||||
"""
|
||||
source: dict = field(default_factory=dict)
|
||||
default: str = ""
|
||||
allow_empty: bool = True
|
||||
empty_label: str = "-- Select --"
|
||||
order_by: str = "source"
|
||||
|
||||
|
||||
# === Format Rule ===
|
||||
|
||||
@dataclass
|
||||
class FormatRule:
|
||||
"""
|
||||
A formatting rule combining condition, style, and formatter.
|
||||
|
||||
Rules:
|
||||
- style and formatter can appear alone (unconditional formatting)
|
||||
- condition cannot appear alone - must be paired with style and/or formatter
|
||||
- If condition is present, style/formatter is applied only if condition is met
|
||||
|
||||
Attributes:
|
||||
condition: Optional condition for conditional formatting
|
||||
style: Optional style to apply
|
||||
formatter: Optional formatter to apply
|
||||
"""
|
||||
condition: Condition = None
|
||||
style: Style = None
|
||||
formatter: Formatter = None
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
DataGrid Formatting DSL Module.
|
||||
|
||||
This module provides a Domain Specific Language (DSL) for defining
|
||||
formatting rules in the DataGrid component.
|
||||
|
||||
Example:
|
||||
from myfasthtml.core.formatting.dsl import parse_dsl
|
||||
|
||||
rules = parse_dsl('''
|
||||
column amount:
|
||||
style("error") if value < 0
|
||||
format("EUR")
|
||||
|
||||
column status:
|
||||
style("success") if value == "approved"
|
||||
style("warning") if value == "pending"
|
||||
''')
|
||||
|
||||
for scoped_rule in rules:
|
||||
print(f"Scope: {scoped_rule.scope}")
|
||||
print(f"Rule: {scoped_rule.rule}")
|
||||
"""
|
||||
from .parser import get_parser
|
||||
from .transformer import DSLTransformer
|
||||
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
|
||||
from .exceptions import DSLError, DSLSyntaxError, DSLValidationError
|
||||
|
||||
|
||||
def parse_dsl(text: str) -> list[ScopedRule]:
|
||||
"""
|
||||
Parse DSL text into a list of ScopedRule objects.
|
||||
|
||||
Args:
|
||||
text: The DSL text to parse
|
||||
|
||||
Returns:
|
||||
List of ScopedRule objects, each containing a scope and a FormatRule
|
||||
|
||||
Raises:
|
||||
DSLSyntaxError: If the text has syntax errors
|
||||
DSLValidationError: If the text is syntactically correct but semantically invalid
|
||||
|
||||
Example:
|
||||
rules = parse_dsl('''
|
||||
column price:
|
||||
style("error") if value < 0
|
||||
format("EUR", precision=2)
|
||||
''')
|
||||
"""
|
||||
parser = get_parser()
|
||||
tree = parser.parse(text)
|
||||
transformer = DSLTransformer()
|
||||
return transformer.transform(tree)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"parse_dsl",
|
||||
# Scope classes
|
||||
"ColumnScope",
|
||||
"RowScope",
|
||||
"CellScope",
|
||||
"ScopedRule",
|
||||
# Exceptions
|
||||
"DSLError",
|
||||
"DSLSyntaxError",
|
||||
"DSLValidationError",
|
||||
]
|
||||
@@ -1,323 +0,0 @@
|
||||
"""
|
||||
Completion contexts for the formatting DSL.
|
||||
|
||||
Defines the Context enum and detection logic to determine
|
||||
what kind of autocompletion suggestions are appropriate.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
from myfasthtml.core.dsl import utils
|
||||
from myfasthtml.core.dsl.types import Position
|
||||
|
||||
|
||||
class Context(Enum):
|
||||
"""
|
||||
Autocompletion context identifiers.
|
||||
|
||||
Each context corresponds to a specific position in the DSL
|
||||
where certain types of suggestions are appropriate.
|
||||
"""
|
||||
|
||||
# No suggestions (e.g., in comment)
|
||||
NONE = auto()
|
||||
|
||||
# Scope-level contexts
|
||||
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell
|
||||
COLUMN_NAME = auto() # After "column ": column names
|
||||
ROW_INDEX = auto() # After "row ": row indices
|
||||
CELL_START = auto() # After "cell ": (
|
||||
CELL_COLUMN = auto() # After "cell (": column names
|
||||
CELL_ROW = auto() # After "cell (col, ": row indices
|
||||
|
||||
# Rule-level contexts
|
||||
RULE_START = auto() # Start of indented line: style(, format(, format.
|
||||
|
||||
# Style contexts
|
||||
STYLE_ARGS = auto() # After "style(": presets + params
|
||||
STYLE_PRESET = auto() # Inside style("): preset names
|
||||
STYLE_PARAM = auto() # After comma in style(): params
|
||||
|
||||
# Format contexts
|
||||
FORMAT_PRESET = auto() # Inside format("): preset names
|
||||
FORMAT_TYPE = auto() # After "format.": number, date, etc.
|
||||
FORMAT_PARAM_DATE = auto() # Inside format.date(): format=
|
||||
FORMAT_PARAM_TEXT = auto() # Inside format.text(): transform=, etc.
|
||||
|
||||
# After style/format
|
||||
AFTER_STYLE_OR_FORMAT = auto() # After ")": style(, format(, if
|
||||
|
||||
# Condition contexts
|
||||
CONDITION_START = auto() # After "if ": value, col., not
|
||||
CONDITION_AFTER_NOT = auto() # After "if not ": value, col.
|
||||
COLUMN_REF = auto() # After "col.": column names
|
||||
COLUMN_REF_QUOTED = auto() # After 'col."': column names with quote
|
||||
|
||||
# Operator contexts
|
||||
OPERATOR = auto() # After operand: ==, <, contains, etc.
|
||||
OPERATOR_VALUE = auto() # After operator: col., True, False, values
|
||||
BETWEEN_AND = auto() # After "between X ": and
|
||||
BETWEEN_VALUE = auto() # After "between X and ": values
|
||||
IN_LIST_START = auto() # After "in ": [
|
||||
IN_LIST_VALUE = auto() # Inside [ or after ,: values
|
||||
|
||||
# Value contexts
|
||||
BOOLEAN_VALUE = auto() # After "bold=": True, False
|
||||
COLOR_VALUE = auto() # After "color=": colors
|
||||
DATE_FORMAT_VALUE = auto() # After "format=" in format.date: patterns
|
||||
TRANSFORM_VALUE = auto() # After "transform=": uppercase, etc.
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedScope:
|
||||
"""
|
||||
Represents the detected scope from scanning previous lines.
|
||||
|
||||
Attributes:
|
||||
scope_type: "column", "row", "cell", or None
|
||||
column_name: Column name (for column and cell scopes)
|
||||
row_index: Row index (for row and cell scopes)
|
||||
table_name: DataGrid name (if determinable)
|
||||
"""
|
||||
|
||||
scope_type: str | None = None
|
||||
column_name: str | None = None
|
||||
row_index: int | None = None
|
||||
table_name: str | None = None
|
||||
|
||||
|
||||
def detect_scope(text: str, current_line: int) -> DetectedScope:
|
||||
"""
|
||||
Detect the current scope by scanning backwards from the cursor line.
|
||||
|
||||
Looks for the most recent scope declaration (column/row/cell)
|
||||
that is not indented.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
current_line: Current line number (0-based)
|
||||
|
||||
Returns:
|
||||
DetectedScope with scope information
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
|
||||
# Scan backwards from current line
|
||||
for i in range(current_line, -1, -1):
|
||||
if i >= len(lines):
|
||||
continue
|
||||
|
||||
line = lines[i]
|
||||
|
||||
# Skip empty lines and indented lines
|
||||
if not line.strip() or utils.is_indented(line):
|
||||
continue
|
||||
|
||||
# Check for column scope
|
||||
match = re.match(r'^column\s+(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*:', line)
|
||||
if match:
|
||||
column_name = match.group(1) or match.group(2)
|
||||
return DetectedScope(scope_type="column", column_name=column_name)
|
||||
|
||||
# Check for row scope
|
||||
match = re.match(r"^row\s+(\d+)\s*:", line)
|
||||
if match:
|
||||
row_index = int(match.group(1))
|
||||
return DetectedScope(scope_type="row", row_index=row_index)
|
||||
|
||||
# Check for cell scope
|
||||
match = re.match(
|
||||
r'^cell\s+\(\s*(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*,\s*(\d+)\s*\)\s*:',
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
column_name = match.group(1) or match.group(2)
|
||||
row_index = int(match.group(3))
|
||||
return DetectedScope(
|
||||
scope_type="cell", column_name=column_name, row_index=row_index
|
||||
)
|
||||
|
||||
return DetectedScope()
|
||||
|
||||
|
||||
def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context:
|
||||
"""
|
||||
Detect the completion context at the cursor position.
|
||||
|
||||
Analyzes the current line up to the cursor to determine
|
||||
what kind of token is expected.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
cursor: Cursor position
|
||||
scope: The detected scope
|
||||
|
||||
Returns:
|
||||
Context enum value
|
||||
"""
|
||||
line = utils.get_line_at(text, cursor.line)
|
||||
line_to_cursor = line[: cursor.ch]
|
||||
|
||||
# Check if in comment
|
||||
if utils.is_in_comment(line, cursor.ch):
|
||||
return Context.NONE
|
||||
|
||||
# Check if line is indented (inside a scope)
|
||||
is_indented = utils.is_indented(line)
|
||||
|
||||
# =========================================================================
|
||||
# Non-indented line contexts (scope definitions)
|
||||
# =========================================================================
|
||||
|
||||
if not is_indented:
|
||||
# After "column "
|
||||
if re.match(r"^column\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
|
||||
return Context.COLUMN_NAME
|
||||
|
||||
# After "row "
|
||||
if re.match(r"^row\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
|
||||
return Context.ROW_INDEX
|
||||
|
||||
# After "cell "
|
||||
if re.match(r"^cell\s+$", line_to_cursor):
|
||||
return Context.CELL_START
|
||||
|
||||
# After "cell ("
|
||||
if re.match(r"^cell\s+\(\s*$", line_to_cursor):
|
||||
return Context.CELL_COLUMN
|
||||
|
||||
# After "cell (col, " or "cell ("col", "
|
||||
if re.match(r'^cell\s+\(\s*(?:"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*$', line_to_cursor):
|
||||
return Context.CELL_ROW
|
||||
|
||||
# Start of line or partial keyword
|
||||
return Context.SCOPE_KEYWORD
|
||||
|
||||
# =========================================================================
|
||||
# Indented line contexts (rules inside a scope)
|
||||
# =========================================================================
|
||||
|
||||
stripped = line_to_cursor.strip()
|
||||
|
||||
# Empty indented line - rule start
|
||||
if not stripped:
|
||||
return Context.RULE_START
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Style contexts
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Inside style(" - preset
|
||||
if re.search(r'style\s*\(\s*"[^"]*$', line_to_cursor):
|
||||
return Context.STYLE_PRESET
|
||||
|
||||
# After style( without quote - args (preset or params)
|
||||
if re.search(r"style\s*\(\s*$", line_to_cursor):
|
||||
return Context.STYLE_ARGS
|
||||
|
||||
# After comma in style() - params
|
||||
if re.search(r"style\s*\([^)]*,\s*$", line_to_cursor):
|
||||
return Context.STYLE_PARAM
|
||||
|
||||
# After param= in style - check which param
|
||||
if re.search(r"style\s*\([^)]*(?:bold|italic|underline|strikethrough)\s*=\s*$", line_to_cursor):
|
||||
return Context.BOOLEAN_VALUE
|
||||
|
||||
if re.search(r"style\s*\([^)]*(?:color|background_color)\s*=\s*$", line_to_cursor):
|
||||
return Context.COLOR_VALUE
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Format contexts
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# After "format." - type
|
||||
if re.search(r"format\s*\.\s*$", line_to_cursor):
|
||||
return Context.FORMAT_TYPE
|
||||
|
||||
# Inside format(" - preset
|
||||
if re.search(r'format\s*\(\s*"[^"]*$', line_to_cursor):
|
||||
return Context.FORMAT_PRESET
|
||||
|
||||
# Inside format.date( - params
|
||||
if re.search(r"format\s*\.\s*date\s*\(\s*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_DATE
|
||||
|
||||
# After format= in format.date
|
||||
if re.search(r"format\s*\.\s*date\s*\([^)]*format\s*=\s*$", line_to_cursor):
|
||||
return Context.DATE_FORMAT_VALUE
|
||||
|
||||
# Inside format.text( - params
|
||||
if re.search(r"format\s*\.\s*text\s*\(\s*$", line_to_cursor):
|
||||
return Context.FORMAT_PARAM_TEXT
|
||||
|
||||
# After transform= in format.text
|
||||
if re.search(r"format\s*\.\s*text\s*\([^)]*transform\s*=\s*$", line_to_cursor):
|
||||
return Context.TRANSFORM_VALUE
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# After style/format - if or more style/format
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# After closing ) of style or format
|
||||
if re.search(r"\)\s*$", line_to_cursor):
|
||||
# Check if there's already an "if" on this line
|
||||
if " if " not in line_to_cursor:
|
||||
return Context.AFTER_STYLE_OR_FORMAT
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Condition contexts
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# After "if not "
|
||||
if re.search(r"\bif\s+not\s+$", line_to_cursor):
|
||||
return Context.CONDITION_AFTER_NOT
|
||||
|
||||
# After "if "
|
||||
if re.search(r"\bif\s+$", line_to_cursor):
|
||||
return Context.CONDITION_START
|
||||
|
||||
# After "col." - column reference
|
||||
if re.search(r'\bcol\s*\.\s*"$', line_to_cursor):
|
||||
return Context.COLUMN_REF_QUOTED
|
||||
|
||||
if re.search(r"\bcol\s*\.\s*$", line_to_cursor):
|
||||
return Context.COLUMN_REF
|
||||
|
||||
# After "between X and " - value
|
||||
if re.search(r"\bbetween\s+\S+\s+and\s+$", line_to_cursor):
|
||||
return Context.BETWEEN_VALUE
|
||||
|
||||
# After "between X " - and
|
||||
if re.search(r"\bbetween\s+\S+\s+$", line_to_cursor):
|
||||
return Context.BETWEEN_AND
|
||||
|
||||
# After "in [" or "in [...," - list value
|
||||
if re.search(r"\bin\s+\[[^\]]*,\s*$", line_to_cursor):
|
||||
return Context.IN_LIST_VALUE
|
||||
|
||||
if re.search(r"\bin\s+\[\s*$", line_to_cursor):
|
||||
return Context.IN_LIST_VALUE
|
||||
|
||||
# After "in " - list start
|
||||
if re.search(r"\bin\s+$", line_to_cursor):
|
||||
return Context.IN_LIST_START
|
||||
|
||||
# After operator - value
|
||||
if re.search(r"(?:==|!=|<=?|>=?|contains|startswith|endswith)\s+$", line_to_cursor):
|
||||
return Context.OPERATOR_VALUE
|
||||
|
||||
# After operand (value, col.xxx, literal) - operator
|
||||
if re.search(r"(?:value|col\.[a-zA-Z_][a-zA-Z0-9_]*|\d+|\"[^\"]*\"|True|False)\s+$", line_to_cursor):
|
||||
return Context.OPERATOR
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fallback - rule start for partial input
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# If we're at the start of typing something
|
||||
if re.match(r"^\s*[a-zA-Z]*$", line_to_cursor):
|
||||
return Context.RULE_START
|
||||
|
||||
return Context.NONE
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
Completion engine for the formatting DSL.
|
||||
|
||||
Implements the BaseCompletionEngine for DataGrid formatting rules.
|
||||
"""
|
||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
|
||||
from . import suggestions as suggestions_module
|
||||
from .contexts import Context, DetectedScope, detect_scope, detect_context
|
||||
from .provider import DatagridMetadataProvider
|
||||
|
||||
|
||||
class FormattingCompletionEngine(BaseCompletionEngine):
|
||||
"""
|
||||
Autocompletion engine for the DataGrid Formatting DSL.
|
||||
|
||||
Provides context-aware suggestions for:
|
||||
- Scope definitions (column, row, cell)
|
||||
- Style expressions with presets and parameters
|
||||
- Format expressions with presets and types
|
||||
- Conditions with operators and values
|
||||
"""
|
||||
|
||||
def __init__(self, provider: DatagridMetadataProvider):
|
||||
"""
|
||||
Initialize the completion engine.
|
||||
|
||||
Args:
|
||||
provider: DataGrid metadata provider for dynamic suggestions
|
||||
"""
|
||||
super().__init__(provider)
|
||||
self.provider: DatagridMetadataProvider = provider
|
||||
|
||||
def detect_scope(self, text: str, current_line: int) -> DetectedScope:
|
||||
"""
|
||||
Detect the current scope by scanning previous lines.
|
||||
|
||||
Looks for the most recent scope declaration (column/row/cell).
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
current_line: Current line number (0-based)
|
||||
|
||||
Returns:
|
||||
DetectedScope with scope information
|
||||
"""
|
||||
return detect_scope(text, current_line)
|
||||
|
||||
def detect_context(
|
||||
self, text: str, cursor: Position, scope: DetectedScope
|
||||
) -> Context:
|
||||
"""
|
||||
Detect the completion context at the cursor position.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
cursor: Cursor position
|
||||
scope: The detected scope
|
||||
|
||||
Returns:
|
||||
Context enum value
|
||||
"""
|
||||
return detect_context(text, cursor, scope)
|
||||
|
||||
def get_suggestions(
|
||||
self, context: Context, scope: DetectedScope, prefix: str
|
||||
) -> list[Suggestion]:
|
||||
"""
|
||||
Generate suggestions for the given context.
|
||||
|
||||
Args:
|
||||
context: The detected completion context
|
||||
scope: The detected scope
|
||||
prefix: The current word prefix (not used here, filtering done in base)
|
||||
|
||||
Returns:
|
||||
List of suggestions
|
||||
"""
|
||||
return suggestions_module.get_suggestions(context, scope, self.provider)
|
||||
|
||||
|
||||
def get_completions(
|
||||
text: str,
|
||||
cursor: Position,
|
||||
provider: DatagridMetadataProvider,
|
||||
) -> CompletionResult:
|
||||
"""
|
||||
Get autocompletion suggestions for the formatting DSL.
|
||||
|
||||
This is the main entry point for the autocompletion API.
|
||||
|
||||
Args:
|
||||
text: The full DSL document text
|
||||
cursor: Cursor position (line and ch are 0-based)
|
||||
provider: DataGrid metadata provider
|
||||
|
||||
Returns:
|
||||
CompletionResult with suggestions and replacement range
|
||||
|
||||
Example:
|
||||
result = get_completions(
|
||||
text='column amount:\\n style("err',
|
||||
cursor=Position(line=1, ch=15),
|
||||
provider=my_provider
|
||||
)
|
||||
# result.suggestions contains ["error"] filtered by prefix "err"
|
||||
"""
|
||||
engine = FormattingCompletionEngine(provider)
|
||||
return engine.get_completions(text, cursor)
|
||||
@@ -1,245 +0,0 @@
|
||||
"""
|
||||
Static data for formatting DSL autocompletion.
|
||||
|
||||
Contains predefined values for style presets, colors, date patterns, etc.
|
||||
"""
|
||||
|
||||
from myfasthtml.core.dsl.types import Suggestion
|
||||
|
||||
# =============================================================================
|
||||
# Style Presets (DaisyUI 5)
|
||||
# =============================================================================
|
||||
|
||||
STYLE_PRESETS: list[Suggestion] = [
|
||||
Suggestion("primary", "Primary theme color", "preset"),
|
||||
Suggestion("secondary", "Secondary theme color", "preset"),
|
||||
Suggestion("accent", "Accent theme color", "preset"),
|
||||
Suggestion("neutral", "Neutral theme color", "preset"),
|
||||
Suggestion("info", "Info (blue)", "preset"),
|
||||
Suggestion("success", "Success (green)", "preset"),
|
||||
Suggestion("warning", "Warning (yellow)", "preset"),
|
||||
Suggestion("error", "Error (red)", "preset"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Format Presets
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_PRESETS: list[Suggestion] = [
|
||||
Suggestion("EUR", "Euro currency (1 234,56 €)", "preset"),
|
||||
Suggestion("USD", "US Dollar ($1,234.56)", "preset"),
|
||||
Suggestion("percentage", "Percentage (×100, adds %)", "preset"),
|
||||
Suggestion("short_date", "DD/MM/YYYY", "preset"),
|
||||
Suggestion("iso_date", "YYYY-MM-DD", "preset"),
|
||||
Suggestion("yes_no", "Yes/No", "preset"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# CSS Colors
|
||||
# =============================================================================
|
||||
|
||||
CSS_COLORS: list[Suggestion] = [
|
||||
Suggestion("red", "Red", "color"),
|
||||
Suggestion("blue", "Blue", "color"),
|
||||
Suggestion("green", "Green", "color"),
|
||||
Suggestion("yellow", "Yellow", "color"),
|
||||
Suggestion("orange", "Orange", "color"),
|
||||
Suggestion("purple", "Purple", "color"),
|
||||
Suggestion("pink", "Pink", "color"),
|
||||
Suggestion("gray", "Gray", "color"),
|
||||
Suggestion("black", "Black", "color"),
|
||||
Suggestion("white", "White", "color"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# DaisyUI Color Variables
|
||||
# =============================================================================
|
||||
|
||||
DAISYUI_COLORS: list[Suggestion] = [
|
||||
Suggestion("var(--color-primary)", "Primary color", "variable"),
|
||||
Suggestion("var(--color-primary-content)", "Primary content color", "variable"),
|
||||
Suggestion("var(--color-secondary)", "Secondary color", "variable"),
|
||||
Suggestion("var(--color-secondary-content)", "Secondary content color", "variable"),
|
||||
Suggestion("var(--color-accent)", "Accent color", "variable"),
|
||||
Suggestion("var(--color-accent-content)", "Accent content color", "variable"),
|
||||
Suggestion("var(--color-neutral)", "Neutral color", "variable"),
|
||||
Suggestion("var(--color-neutral-content)", "Neutral content color", "variable"),
|
||||
Suggestion("var(--color-info)", "Info color", "variable"),
|
||||
Suggestion("var(--color-info-content)", "Info content color", "variable"),
|
||||
Suggestion("var(--color-success)", "Success color", "variable"),
|
||||
Suggestion("var(--color-success-content)", "Success content color", "variable"),
|
||||
Suggestion("var(--color-warning)", "Warning color", "variable"),
|
||||
Suggestion("var(--color-warning-content)", "Warning content color", "variable"),
|
||||
Suggestion("var(--color-error)", "Error color", "variable"),
|
||||
Suggestion("var(--color-error-content)", "Error content color", "variable"),
|
||||
Suggestion("var(--color-base-100)", "Base 100", "variable"),
|
||||
Suggestion("var(--color-base-200)", "Base 200", "variable"),
|
||||
Suggestion("var(--color-base-300)", "Base 300", "variable"),
|
||||
Suggestion("var(--color-base-content)", "Base content color", "variable"),
|
||||
]
|
||||
|
||||
# Combined color suggestions
|
||||
ALL_COLORS: list[Suggestion] = CSS_COLORS + DAISYUI_COLORS
|
||||
|
||||
# =============================================================================
|
||||
# Date Format Patterns
|
||||
# =============================================================================
|
||||
|
||||
DATE_PATTERNS: list[Suggestion] = [
|
||||
Suggestion('"%Y-%m-%d"', "ISO format (2026-01-29)", "pattern"),
|
||||
Suggestion('"%d/%m/%Y"', "European (29/01/2026)", "pattern"),
|
||||
Suggestion('"%m/%d/%Y"', "US format (01/29/2026)", "pattern"),
|
||||
Suggestion('"%d %b %Y"', "Short month (29 Jan 2026)", "pattern"),
|
||||
Suggestion('"%d %B %Y"', "Full month (29 January 2026)", "pattern"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Text Transform Values
|
||||
# =============================================================================
|
||||
|
||||
TEXT_TRANSFORMS: list[Suggestion] = [
|
||||
Suggestion('"uppercase"', "UPPERCASE", "value"),
|
||||
Suggestion('"lowercase"', "lowercase", "value"),
|
||||
Suggestion('"capitalize"', "Capitalize Each Word", "value"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Boolean Values
|
||||
# =============================================================================
|
||||
|
||||
BOOLEAN_VALUES: list[Suggestion] = [
|
||||
Suggestion("True", "Boolean true", "literal"),
|
||||
Suggestion("False", "Boolean false", "literal"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Scope Keywords
|
||||
# =============================================================================
|
||||
|
||||
SCOPE_KEYWORDS: list[Suggestion] = [
|
||||
Suggestion("column", "Define column scope", "keyword"),
|
||||
Suggestion("row", "Define row scope", "keyword"),
|
||||
Suggestion("cell", "Define cell scope", "keyword"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Rule Start Keywords
|
||||
# =============================================================================
|
||||
|
||||
RULE_START: list[Suggestion] = [
|
||||
Suggestion("style(", "Apply visual styling", "function"),
|
||||
Suggestion("format(", "Apply value formatting (preset)", "function"),
|
||||
Suggestion("format.", "Apply value formatting (typed)", "function"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# After Style/Format Keywords
|
||||
# =============================================================================
|
||||
|
||||
AFTER_STYLE_OR_FORMAT: list[Suggestion] = [
|
||||
Suggestion("style(", "Apply visual styling", "function"),
|
||||
Suggestion("format(", "Apply value formatting (preset)", "function"),
|
||||
Suggestion("format.", "Apply value formatting (typed)", "function"),
|
||||
Suggestion("if", "Add condition", "keyword"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Style Parameters
|
||||
# =============================================================================
|
||||
|
||||
STYLE_PARAMS: list[Suggestion] = [
|
||||
Suggestion("bold=", "Bold text", "parameter"),
|
||||
Suggestion("italic=", "Italic text", "parameter"),
|
||||
Suggestion("underline=", "Underlined text", "parameter"),
|
||||
Suggestion("strikethrough=", "Strikethrough text", "parameter"),
|
||||
Suggestion("color=", "Text color", "parameter"),
|
||||
Suggestion("background_color=", "Background color", "parameter"),
|
||||
Suggestion("font_size=", "Font size", "parameter"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Format Types
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_TYPES: list[Suggestion] = [
|
||||
Suggestion("number", "Number formatting", "type"),
|
||||
Suggestion("date", "Date formatting", "type"),
|
||||
Suggestion("boolean", "Boolean formatting", "type"),
|
||||
Suggestion("text", "Text transformation", "type"),
|
||||
Suggestion("enum", "Value mapping", "type"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Format Parameters by Type
|
||||
# =============================================================================
|
||||
|
||||
FORMAT_PARAMS_DATE: list[Suggestion] = [
|
||||
Suggestion("format=", "strftime pattern", "parameter"),
|
||||
]
|
||||
|
||||
FORMAT_PARAMS_TEXT: list[Suggestion] = [
|
||||
Suggestion("transform=", "Text transformation", "parameter"),
|
||||
Suggestion("max_length=", "Maximum length", "parameter"),
|
||||
Suggestion("ellipsis=", "Truncation suffix", "parameter"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Condition Keywords
|
||||
# =============================================================================
|
||||
|
||||
CONDITION_START: list[Suggestion] = [
|
||||
Suggestion("value", "Current cell value", "keyword"),
|
||||
Suggestion("col.", "Reference another column", "keyword"),
|
||||
Suggestion("not", "Negate condition", "keyword"),
|
||||
]
|
||||
|
||||
CONDITION_AFTER_NOT: list[Suggestion] = [
|
||||
Suggestion("value", "Current cell value", "keyword"),
|
||||
Suggestion("col.", "Reference another column", "keyword"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Operators
|
||||
# =============================================================================
|
||||
|
||||
COMPARISON_OPERATORS: list[Suggestion] = [
|
||||
Suggestion("==", "Equal", "operator"),
|
||||
Suggestion("!=", "Not equal", "operator"),
|
||||
Suggestion("<", "Less than", "operator"),
|
||||
Suggestion("<=", "Less or equal", "operator"),
|
||||
Suggestion(">", "Greater than", "operator"),
|
||||
Suggestion(">=", "Greater or equal", "operator"),
|
||||
Suggestion("contains", "String contains", "operator"),
|
||||
Suggestion("startswith", "String starts with", "operator"),
|
||||
Suggestion("endswith", "String ends with", "operator"),
|
||||
Suggestion("in", "Value in list", "operator"),
|
||||
Suggestion("between", "Value in range", "operator"),
|
||||
Suggestion("isempty", "Is null or empty", "operator"),
|
||||
Suggestion("isnotempty", "Is not null or empty", "operator"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Operator Value Start
|
||||
# =============================================================================
|
||||
|
||||
OPERATOR_VALUE_BASE: list[Suggestion] = [
|
||||
Suggestion("col.", "Reference another column", "keyword"),
|
||||
Suggestion("True", "Boolean true", "literal"),
|
||||
Suggestion("False", "Boolean false", "literal"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Between Keyword
|
||||
# =============================================================================
|
||||
|
||||
BETWEEN_AND: list[Suggestion] = [
|
||||
Suggestion("and", "Between upper bound", "keyword"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# In List Start
|
||||
# =============================================================================
|
||||
|
||||
IN_LIST_START: list[Suggestion] = [
|
||||
Suggestion("[", "Start list", "syntax"),
|
||||
]
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Metadata provider for DataGrid formatting DSL autocompletion.
|
||||
|
||||
Provides access to DataGrid metadata (columns, values, row counts)
|
||||
for context-aware autocompletion.
|
||||
"""
|
||||
|
||||
from typing import Protocol, Any
|
||||
|
||||
|
||||
class DatagridMetadataProvider(Protocol):
|
||||
"""
|
||||
Protocol for providing DataGrid metadata to the autocompletion engine.
|
||||
|
||||
Implementations must provide access to:
|
||||
- Available DataGrids (tables)
|
||||
- Column names for each DataGrid
|
||||
- Distinct values for each column
|
||||
- Row count for each DataGrid
|
||||
- Style and format presets
|
||||
|
||||
DataGrid names follow the pattern namespace.name (multi-level namespaces).
|
||||
"""
|
||||
|
||||
def list_tables(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available DataGrid names.
|
||||
|
||||
Returns:
|
||||
List of DataGrid names (e.g., ["app.orders", "app.customers"])
|
||||
"""
|
||||
...
|
||||
|
||||
def list_columns(self, table_name: str) -> list[str]:
|
||||
"""
|
||||
Return the column names for a specific DataGrid.
|
||||
|
||||
Args:
|
||||
table_name: The DataGrid name
|
||||
|
||||
Returns:
|
||||
List of column names (e.g., ["id", "amount", "status"])
|
||||
"""
|
||||
...
|
||||
|
||||
def list_column_values(self, table_name, column_name: str) -> list[Any]:
|
||||
"""
|
||||
Return the distinct values for a column in the current DataGrid.
|
||||
|
||||
This is used to suggest values in conditions like `value == |`.
|
||||
|
||||
Args:
|
||||
column_name: The column name
|
||||
|
||||
Returns:
|
||||
List of distinct values in the column
|
||||
"""
|
||||
...
|
||||
|
||||
def get_row_count(self, table_name: str) -> int:
|
||||
"""
|
||||
Return the number of rows in a DataGrid.
|
||||
|
||||
Used to suggest row indices for row scope and cell scope.
|
||||
|
||||
Args:
|
||||
table_name: The DataGrid name
|
||||
|
||||
Returns:
|
||||
Number of rows
|
||||
"""
|
||||
...
|
||||
|
||||
def list_style_presets(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available style preset names.
|
||||
|
||||
Includes default presets (primary, error, etc.) and custom presets.
|
||||
|
||||
Returns:
|
||||
List of style preset names
|
||||
"""
|
||||
...
|
||||
|
||||
def list_format_presets(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available format preset names.
|
||||
|
||||
Includes default presets (EUR, USD, etc.) and custom presets.
|
||||
|
||||
Returns:
|
||||
List of format preset names
|
||||
"""
|
||||
...
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Suggestions generation for the formatting DSL.
|
||||
|
||||
Provides functions to generate appropriate suggestions
|
||||
based on the detected context and scope.
|
||||
"""
|
||||
|
||||
from myfasthtml.core.dsl.types import Suggestion
|
||||
from . import presets
|
||||
from .contexts import Context, DetectedScope
|
||||
from .provider import DatagridMetadataProvider
|
||||
|
||||
|
||||
def get_suggestions(
|
||||
context: Context,
|
||||
scope: DetectedScope,
|
||||
provider: DatagridMetadataProvider,
|
||||
) -> list[Suggestion]:
|
||||
"""
|
||||
Generate suggestions for the given context.
|
||||
|
||||
Args:
|
||||
context: The detected completion context
|
||||
scope: The detected scope
|
||||
provider: Metadata provider for dynamic data
|
||||
|
||||
Returns:
|
||||
List of suggestions
|
||||
"""
|
||||
match context:
|
||||
# =================================================================
|
||||
# Scope-level contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.NONE:
|
||||
return []
|
||||
|
||||
case Context.SCOPE_KEYWORD:
|
||||
return presets.SCOPE_KEYWORDS
|
||||
|
||||
case Context.COLUMN_NAME:
|
||||
return _get_column_suggestions(provider)
|
||||
|
||||
case Context.ROW_INDEX:
|
||||
return _get_row_index_suggestions(provider)
|
||||
|
||||
case Context.CELL_START:
|
||||
return [Suggestion("(", "Start cell coordinates", "syntax")]
|
||||
|
||||
case Context.CELL_COLUMN:
|
||||
return _get_column_suggestions(provider)
|
||||
|
||||
case Context.CELL_ROW:
|
||||
return _get_row_index_suggestions(provider)
|
||||
|
||||
# =================================================================
|
||||
# Rule-level contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.RULE_START:
|
||||
return presets.RULE_START
|
||||
|
||||
# =================================================================
|
||||
# Style contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.STYLE_ARGS:
|
||||
# Presets (with quotes) + params
|
||||
style_presets = _get_style_preset_suggestions_quoted(provider)
|
||||
return style_presets + presets.STYLE_PARAMS
|
||||
|
||||
case Context.STYLE_PRESET:
|
||||
return _get_style_preset_suggestions(provider)
|
||||
|
||||
case Context.STYLE_PARAM:
|
||||
return presets.STYLE_PARAMS
|
||||
|
||||
# =================================================================
|
||||
# Format contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.FORMAT_PRESET:
|
||||
return _get_format_preset_suggestions(provider)
|
||||
|
||||
case Context.FORMAT_TYPE:
|
||||
return presets.FORMAT_TYPES
|
||||
|
||||
case Context.FORMAT_PARAM_DATE:
|
||||
return presets.FORMAT_PARAMS_DATE
|
||||
|
||||
case Context.FORMAT_PARAM_TEXT:
|
||||
return presets.FORMAT_PARAMS_TEXT
|
||||
|
||||
# =================================================================
|
||||
# After style/format
|
||||
# =================================================================
|
||||
|
||||
case Context.AFTER_STYLE_OR_FORMAT:
|
||||
return presets.AFTER_STYLE_OR_FORMAT
|
||||
|
||||
# =================================================================
|
||||
# Condition contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.CONDITION_START:
|
||||
return presets.CONDITION_START
|
||||
|
||||
case Context.CONDITION_AFTER_NOT:
|
||||
return presets.CONDITION_AFTER_NOT
|
||||
|
||||
case Context.COLUMN_REF:
|
||||
return _get_column_suggestions(provider)
|
||||
|
||||
case Context.COLUMN_REF_QUOTED:
|
||||
return _get_column_suggestions_with_closing_quote(provider)
|
||||
|
||||
# =================================================================
|
||||
# Operator contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.OPERATOR:
|
||||
return presets.COMPARISON_OPERATORS
|
||||
|
||||
case Context.OPERATOR_VALUE | Context.BETWEEN_VALUE:
|
||||
# col., True, False + column values
|
||||
base = presets.OPERATOR_VALUE_BASE.copy()
|
||||
base.extend(_get_column_value_suggestions(scope, provider))
|
||||
return base
|
||||
|
||||
case Context.BETWEEN_AND:
|
||||
return presets.BETWEEN_AND
|
||||
|
||||
case Context.IN_LIST_START:
|
||||
return presets.IN_LIST_START
|
||||
|
||||
case Context.IN_LIST_VALUE:
|
||||
return _get_column_value_suggestions(scope, provider)
|
||||
|
||||
# =================================================================
|
||||
# Value contexts
|
||||
# =================================================================
|
||||
|
||||
case Context.BOOLEAN_VALUE:
|
||||
return presets.BOOLEAN_VALUES
|
||||
|
||||
case Context.COLOR_VALUE:
|
||||
return presets.ALL_COLORS
|
||||
|
||||
case Context.DATE_FORMAT_VALUE:
|
||||
return presets.DATE_PATTERNS
|
||||
|
||||
case Context.TRANSFORM_VALUE:
|
||||
return presets.TEXT_TRANSFORMS
|
||||
|
||||
case _:
|
||||
return []
|
||||
|
||||
|
||||
def _get_column_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
|
||||
"""Get column name suggestions from provider."""
|
||||
try:
|
||||
# Try to get columns from the first available table
|
||||
tables = provider.list_tables()
|
||||
if tables:
|
||||
columns = provider.list_columns(tables[0])
|
||||
return [Suggestion(col, "Column", "column") for col in columns]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _get_column_suggestions_with_closing_quote(
|
||||
provider: DatagridMetadataProvider,
|
||||
) -> list[Suggestion]:
|
||||
"""Get column name suggestions with closing quote."""
|
||||
try:
|
||||
tables = provider.list_tables()
|
||||
if tables:
|
||||
columns = provider.list_columns(tables[0])
|
||||
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _get_style_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
|
||||
"""Get style preset suggestions (without quotes)."""
|
||||
suggestions = []
|
||||
|
||||
# Add provider presets if available
|
||||
try:
|
||||
custom_presets = provider.list_style_presets()
|
||||
for preset in custom_presets:
|
||||
# Check if it's already in default presets
|
||||
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
||||
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add default presets (just the name, no quotes - we're inside quotes)
|
||||
for preset in presets.STYLE_PRESETS:
|
||||
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_style_preset_suggestions_quoted(
|
||||
provider: DatagridMetadataProvider,
|
||||
) -> list[Suggestion]:
|
||||
"""Get style preset suggestions with quotes."""
|
||||
suggestions = []
|
||||
|
||||
# Add provider presets if available
|
||||
try:
|
||||
custom_presets = provider.list_style_presets()
|
||||
for preset in custom_presets:
|
||||
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
||||
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add default presets with quotes
|
||||
for preset in presets.STYLE_PRESETS:
|
||||
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_format_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
|
||||
"""Get format preset suggestions (without quotes)."""
|
||||
suggestions = []
|
||||
|
||||
# Add provider presets if available
|
||||
try:
|
||||
custom_presets = provider.list_format_presets()
|
||||
for preset in custom_presets:
|
||||
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
|
||||
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add default presets
|
||||
for preset in presets.FORMAT_PRESETS:
|
||||
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_row_index_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
|
||||
"""Get row index suggestions (first 10 + last)."""
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
tables = provider.list_tables()
|
||||
if tables:
|
||||
row_count = provider.get_row_count(tables[0])
|
||||
if row_count > 0:
|
||||
# First 10 rows
|
||||
for i in range(min(10, row_count)):
|
||||
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
|
||||
|
||||
# Last row if not already included
|
||||
last_index = row_count - 1
|
||||
if last_index >= 10:
|
||||
suggestions.append(
|
||||
Suggestion(str(last_index), f"Last row ({row_count} total)", "index")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback if no provider data
|
||||
if not suggestions:
|
||||
for i in range(10):
|
||||
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_column_value_suggestions(
|
||||
scope: DetectedScope,
|
||||
provider: DatagridMetadataProvider,
|
||||
) -> list[Suggestion]:
|
||||
"""Get column value suggestions based on the current scope."""
|
||||
if not scope.column_name:
|
||||
return []
|
||||
|
||||
try:
|
||||
values = provider.list_column_values(scope.column_name)
|
||||
suggestions = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
# Format value appropriately
|
||||
if isinstance(value, str):
|
||||
label = f'"{value}"'
|
||||
detail = "Text value"
|
||||
elif isinstance(value, bool):
|
||||
label = str(value)
|
||||
detail = "Boolean value"
|
||||
elif isinstance(value, (int, float)):
|
||||
label = str(value)
|
||||
detail = "Numeric value"
|
||||
else:
|
||||
label = f'"{value}"'
|
||||
detail = "Value"
|
||||
|
||||
suggestions.append(Suggestion(label, detail, "value"))
|
||||
|
||||
return suggestions
|
||||
except Exception:
|
||||
return []
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
FormattingDSL definition for the DslEditor control.
|
||||
|
||||
Provides the Lark grammar and derived completions for the
|
||||
DataGrid Formatting DSL.
|
||||
"""
|
||||
|
||||
from myfasthtml.core.dsl.base import DSLDefinition
|
||||
from myfasthtml.core.formatting.dsl.grammar import GRAMMAR
|
||||
|
||||
|
||||
class FormattingDSL(DSLDefinition):
|
||||
"""
|
||||
DSL definition for DataGrid formatting rules.
|
||||
|
||||
Uses the existing Lark grammar from grammar.py.
|
||||
"""
|
||||
|
||||
name: str = "Formatting DSL"
|
||||
|
||||
def get_grammar(self) -> str:
|
||||
"""Return the Lark grammar for formatting DSL."""
|
||||
return GRAMMAR
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
DSL-specific exceptions.
|
||||
"""
|
||||
|
||||
|
||||
class DSLError(Exception):
|
||||
"""Base exception for DSL errors."""
|
||||
pass
|
||||
|
||||
|
||||
class DSLSyntaxError(DSLError):
|
||||
"""
|
||||
Raised when the DSL input has syntax errors.
|
||||
|
||||
Attributes:
|
||||
message: Error description
|
||||
line: Line number where error occurred (1-based)
|
||||
column: Column number where error occurred (1-based)
|
||||
context: The problematic line or snippet
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, line: int = None, column: int = None, context: str = None):
|
||||
self.message = message
|
||||
self.line = line
|
||||
self.column = column
|
||||
self.context = context
|
||||
super().__init__(self._format_message())
|
||||
|
||||
def _format_message(self) -> str:
|
||||
parts = [self.message]
|
||||
if self.line is not None:
|
||||
parts.append(f"at line {self.line}")
|
||||
if self.column is not None:
|
||||
parts[1] = f"at line {self.line}, column {self.column}"
|
||||
if self.context:
|
||||
parts.append(f"\n {self.context}")
|
||||
if self.column is not None:
|
||||
parts.append(f"\n {' ' * (self.column - 1)}^")
|
||||
return " ".join(parts[:2]) + "".join(parts[2:])
|
||||
|
||||
|
||||
class DSLValidationError(DSLError):
|
||||
"""
|
||||
Raised when the DSL is syntactically correct but semantically invalid.
|
||||
|
||||
Examples:
|
||||
- Unknown preset name
|
||||
- Invalid parameter for formatter type
|
||||
- Missing required parameter
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, line: int = None):
|
||||
self.message = message
|
||||
self.line = line
|
||||
super().__init__(f"{message}" + (f" at line {line}" if line else ""))
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Lark grammar for the DataGrid Formatting DSL.
|
||||
|
||||
This grammar is designed to be translatable to Lezer for CodeMirror integration.
|
||||
"""
|
||||
|
||||
GRAMMAR = r"""
|
||||
// ==================== Top-level structure ====================
|
||||
|
||||
start: _NL* scope+
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
scope: scope_header ":" _NL _INDENT rule+ _DEDENT
|
||||
|
||||
scope_header: column_scope
|
||||
| row_scope
|
||||
| cell_scope
|
||||
|
||||
column_scope: "column" column_name
|
||||
row_scope: "row" INTEGER
|
||||
cell_scope: "cell" cell_ref
|
||||
|
||||
column_name: NAME -> name
|
||||
| QUOTED_STRING -> quoted_name
|
||||
|
||||
cell_ref: "(" column_name "," INTEGER ")" -> cell_coords
|
||||
| CELL_ID -> cell_id
|
||||
|
||||
// ==================== Rules ====================
|
||||
|
||||
rule: rule_content _NL
|
||||
|
||||
rule_content: style_expr format_expr? condition?
|
||||
| format_expr style_expr? condition?
|
||||
|
||||
condition: "if" comparison
|
||||
|
||||
// ==================== Comparisons ====================
|
||||
|
||||
comparison: negation? comparison_expr case_modifier?
|
||||
|
||||
negation: "not"
|
||||
|
||||
comparison_expr: binary_comparison
|
||||
| unary_comparison
|
||||
|
||||
binary_comparison: operand operator operand -> binary_comp
|
||||
| operand "in" list -> in_comp
|
||||
| operand "between" operand "and" operand -> between_comp
|
||||
|
||||
unary_comparison: operand "isempty" -> isempty_comp
|
||||
| operand "isnotempty" -> isnotempty_comp
|
||||
|
||||
case_modifier: "(" "case" ")"
|
||||
|
||||
// ==================== Operators ====================
|
||||
|
||||
operator: "==" -> op_eq
|
||||
| "!=" -> op_ne
|
||||
| "<=" -> op_le
|
||||
| "<" -> op_lt
|
||||
| ">=" -> op_ge
|
||||
| ">" -> op_gt
|
||||
| "contains" -> op_contains
|
||||
| "startswith" -> op_startswith
|
||||
| "endswith" -> op_endswith
|
||||
|
||||
// ==================== Operands ====================
|
||||
|
||||
operand: value_ref
|
||||
| column_ref
|
||||
| row_ref
|
||||
| cell_ref_expr
|
||||
| literal
|
||||
| arithmetic
|
||||
| "(" operand ")"
|
||||
|
||||
value_ref: "value"
|
||||
|
||||
column_ref: "col" "." (NAME | QUOTED_STRING)
|
||||
|
||||
row_ref: "row" "." INTEGER
|
||||
|
||||
cell_ref_expr: "cell" "." NAME "-" INTEGER
|
||||
|
||||
literal: QUOTED_STRING -> string_literal
|
||||
| SIGNED_NUMBER -> number_literal
|
||||
| BOOLEAN -> boolean_literal
|
||||
|
||||
arithmetic: operand arith_op operand
|
||||
|
||||
arith_op: "*" -> arith_mul
|
||||
| "/" -> arith_div
|
||||
| "+" -> arith_add
|
||||
| "-" -> arith_sub
|
||||
|
||||
list: "[" [literal ("," literal)*] "]"
|
||||
|
||||
// ==================== Style expression ====================
|
||||
|
||||
style_expr: "style" "(" style_args ")"
|
||||
|
||||
style_args: QUOTED_STRING ("," kwargs)? -> style_with_preset
|
||||
| kwargs -> style_without_preset
|
||||
|
||||
// ==================== Format expression ====================
|
||||
|
||||
format_expr: format_preset
|
||||
| format_typed
|
||||
|
||||
format_preset: "format" "(" QUOTED_STRING ("," kwargs)? ")"
|
||||
|
||||
format_typed: "format" "." format_type "(" kwargs? ")"
|
||||
|
||||
format_type: "number" -> fmt_number
|
||||
| "date" -> fmt_date
|
||||
| "boolean" -> fmt_boolean
|
||||
| "text" -> fmt_text
|
||||
| "enum" -> fmt_enum
|
||||
|
||||
// ==================== Keyword arguments ====================
|
||||
|
||||
kwargs: kwarg ("," kwarg)*
|
||||
|
||||
kwarg: NAME "=" kwarg_value
|
||||
|
||||
kwarg_value: QUOTED_STRING -> kwarg_string
|
||||
| SIGNED_NUMBER -> kwarg_number
|
||||
| BOOLEAN -> kwarg_boolean
|
||||
| dict -> kwarg_dict
|
||||
|
||||
dict: "{" [dict_entry ("," dict_entry)*] "}"
|
||||
|
||||
dict_entry: QUOTED_STRING ":" (QUOTED_STRING | SIGNED_NUMBER | BOOLEAN)
|
||||
|
||||
// ==================== Terminals ====================
|
||||
|
||||
NAME: /[a-zA-Z_][a-zA-Z0-9_]*/
|
||||
QUOTED_STRING: /"[^"]*"/ | /'[^']*'/
|
||||
INTEGER: /[0-9]+/
|
||||
SIGNED_NUMBER: /[+-]?[0-9]+(\.[0-9]+)?/
|
||||
BOOLEAN: "True" | "False" | "true" | "false"
|
||||
CELL_ID: /tcell_[a-zA-Z0-9_-]+/
|
||||
|
||||
// ==================== Whitespace handling ====================
|
||||
|
||||
COMMENT: /#[^\n]*/
|
||||
|
||||
// Newline token includes following whitespace for indentation tracking
|
||||
// This is required by lark's Indenter to detect indentation levels
|
||||
_NL: /(\r?\n[\t ]*)+/
|
||||
|
||||
// Ignore inline whitespace (within a line, not at line start)
|
||||
%ignore /[\t ]+/
|
||||
%ignore COMMENT
|
||||
|
||||
%declare _INDENT _DEDENT
|
||||
"""
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
DSL Parser using lark.
|
||||
|
||||
Handles parsing of the DSL text into an AST.
|
||||
"""
|
||||
from lark import Lark, UnexpectedInput
|
||||
from lark.indenter import Indenter
|
||||
|
||||
from .exceptions import DSLSyntaxError
|
||||
from .grammar import GRAMMAR
|
||||
|
||||
|
||||
class DSLIndenter(Indenter):
|
||||
"""
|
||||
Custom indenter for Python-style indentation.
|
||||
|
||||
Handles INDENT/DEDENT tokens for scoped rules.
|
||||
"""
|
||||
NL_type = "_NL"
|
||||
OPEN_PAREN_types = [] # No multi-line expressions in our DSL
|
||||
CLOSE_PAREN_types = []
|
||||
INDENT_type = "_INDENT"
|
||||
DEDENT_type = "_DEDENT"
|
||||
tab_len = 4
|
||||
|
||||
|
||||
class DSLParser:
|
||||
"""
|
||||
Parser for the DataGrid Formatting DSL.
|
||||
|
||||
Uses lark with custom indentation handling.
|
||||
|
||||
Example:
|
||||
parser = DSLParser()
|
||||
tree = parser.parse('''
|
||||
column amount:
|
||||
style("error") if value < 0
|
||||
format("EUR")
|
||||
''')
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._parser = Lark(
|
||||
GRAMMAR,
|
||||
parser="lalr",
|
||||
postlex=DSLIndenter(),
|
||||
propagate_positions=True,
|
||||
)
|
||||
|
||||
def parse(self, text: str):
|
||||
"""
|
||||
Parse DSL text into an AST.
|
||||
|
||||
Args:
|
||||
text: The DSL text to parse
|
||||
|
||||
Returns:
|
||||
lark.Tree: The parsed AST
|
||||
|
||||
Raises:
|
||||
DSLSyntaxError: If the text has syntax errors
|
||||
"""
|
||||
# Pre-process: replace comment lines with empty lines (preserves line numbers)
|
||||
lines = text.split("\n")
|
||||
lines = ["" if line.strip().startswith("#") else line for line in lines]
|
||||
text = "\n".join(lines)
|
||||
|
||||
# Strip leading whitespace/newlines and ensure text ends with newline
|
||||
text = text.strip()
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
|
||||
try:
|
||||
return self._parser.parse(text)
|
||||
except UnexpectedInput as e:
|
||||
# Extract context for error message
|
||||
context = None
|
||||
if hasattr(e, "get_context"):
|
||||
context = e.get_context(text)
|
||||
|
||||
raise DSLSyntaxError(
|
||||
message=self._format_error_message(e),
|
||||
line=getattr(e, "line", None),
|
||||
column=getattr(e, "column", None),
|
||||
context=context,
|
||||
) from e
|
||||
|
||||
def _format_error_message(self, error: UnexpectedInput) -> str:
|
||||
"""Format a user-friendly error message from lark exception."""
|
||||
if hasattr(error, "expected"):
|
||||
expected = list(error.expected)
|
||||
if len(expected) == 1:
|
||||
return f"Expected {expected[0]}"
|
||||
elif len(expected) <= 5:
|
||||
return f"Expected one of: {', '.join(expected)}"
|
||||
else:
|
||||
return "Unexpected input"
|
||||
|
||||
return str(error)
|
||||
|
||||
|
||||
# Singleton parser instance
|
||||
_parser = None
|
||||
|
||||
|
||||
def get_parser() -> DSLParser:
|
||||
"""Get the singleton parser instance."""
|
||||
global _parser
|
||||
if _parser is None:
|
||||
_parser = DSLParser()
|
||||
return _parser
|
||||
@@ -1,47 +0,0 @@
|
||||
"""
|
||||
Scope dataclasses for DSL output.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..dataclasses import FormatRule
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnScope:
|
||||
"""Scope targeting a column by name."""
|
||||
column: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RowScope:
|
||||
"""Scope targeting a row by index."""
|
||||
row: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellScope:
|
||||
"""
|
||||
Scope targeting a specific cell.
|
||||
|
||||
Can be specified either by:
|
||||
- Coordinates: column + row
|
||||
- Cell ID: cell_id
|
||||
"""
|
||||
column: str = None
|
||||
row: int = None
|
||||
cell_id: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopedRule:
|
||||
"""
|
||||
A format rule with its scope.
|
||||
|
||||
The DSL parser returns a list of ScopedRule objects.
|
||||
|
||||
Attributes:
|
||||
scope: Where the rule applies (ColumnScope, RowScope, or CellScope)
|
||||
rule: The FormatRule (condition + style + formatter)
|
||||
"""
|
||||
scope: ColumnScope | RowScope | CellScope
|
||||
rule: FormatRule
|
||||
@@ -1,430 +0,0 @@
|
||||
"""
|
||||
DSL Transformer.
|
||||
|
||||
Converts lark AST into FormatRule and ScopedRule objects.
|
||||
"""
|
||||
from lark import Transformer
|
||||
|
||||
from .exceptions import DSLValidationError
|
||||
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
|
||||
from ..dataclasses import (
|
||||
Condition,
|
||||
Style,
|
||||
FormatRule,
|
||||
NumberFormatter,
|
||||
DateFormatter,
|
||||
BooleanFormatter,
|
||||
TextFormatter,
|
||||
EnumFormatter,
|
||||
)
|
||||
|
||||
|
||||
class DSLTransformer(Transformer):
|
||||
"""
|
||||
Transforms the lark AST into ScopedRule objects.
|
||||
|
||||
This transformer visits each node in the AST and converts it
|
||||
to the appropriate dataclass.
|
||||
"""
|
||||
|
||||
# ==================== Top-level ====================
|
||||
|
||||
def start(self, items):
|
||||
"""Flatten all scoped rules from all scopes."""
|
||||
result = []
|
||||
for scope_rules in items:
|
||||
result.extend(scope_rules)
|
||||
return result
|
||||
|
||||
# ==================== Scopes ====================
|
||||
|
||||
def scope(self, items):
|
||||
"""Process a scope block, returning list of ScopedRules."""
|
||||
scope_obj = items[0] # scope_header result
|
||||
rules = items[1:] # rule results
|
||||
|
||||
return [ScopedRule(scope=scope_obj, rule=rule) for rule in rules]
|
||||
|
||||
def scope_header(self, items):
|
||||
return items[0]
|
||||
|
||||
def column_scope(self, items):
|
||||
column_name = items[0]
|
||||
return ColumnScope(column=column_name)
|
||||
|
||||
def row_scope(self, items):
|
||||
row_index = int(items[0])
|
||||
return RowScope(row=row_index)
|
||||
|
||||
def cell_scope(self, items):
|
||||
return items[0] # cell_ref result
|
||||
|
||||
def cell_coords(self, items):
|
||||
column_name = items[0]
|
||||
row_index = int(items[1])
|
||||
return CellScope(column=column_name, row=row_index)
|
||||
|
||||
def cell_id(self, items):
|
||||
cell_id = str(items[0])
|
||||
return CellScope(cell_id=cell_id)
|
||||
|
||||
def name(self, items):
|
||||
return str(items[0])
|
||||
|
||||
def quoted_name(self, items):
|
||||
return self._unquote(items[0])
|
||||
|
||||
# ==================== Rules ====================
|
||||
|
||||
def rule(self, items):
|
||||
return items[0] # rule_content result
|
||||
|
||||
def rule_content(self, items):
|
||||
"""Build a FormatRule from style, format, and condition."""
|
||||
style_obj = None
|
||||
formatter_obj = None
|
||||
condition_obj = None
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, Style):
|
||||
style_obj = item
|
||||
elif isinstance(item, (NumberFormatter, DateFormatter, BooleanFormatter,
|
||||
TextFormatter, EnumFormatter)):
|
||||
formatter_obj = item
|
||||
elif isinstance(item, Condition):
|
||||
condition_obj = item
|
||||
|
||||
return FormatRule(
|
||||
condition=condition_obj,
|
||||
style=style_obj,
|
||||
formatter=formatter_obj,
|
||||
)
|
||||
|
||||
# ==================== Conditions ====================
|
||||
|
||||
def condition(self, items):
|
||||
return items[0] # comparison result
|
||||
|
||||
def comparison(self, items):
|
||||
"""Process comparison with optional negation and case modifier."""
|
||||
negate = False
|
||||
case_sensitive = False
|
||||
condition = None
|
||||
|
||||
for item in items:
|
||||
if item == "not":
|
||||
negate = True
|
||||
elif item == "case":
|
||||
case_sensitive = True
|
||||
elif isinstance(item, Condition):
|
||||
condition = item
|
||||
|
||||
if condition:
|
||||
condition.negate = negate
|
||||
condition.case_sensitive = case_sensitive
|
||||
|
||||
return condition
|
||||
|
||||
def negation(self, items):
|
||||
return "not"
|
||||
|
||||
def case_modifier(self, items):
|
||||
return "case"
|
||||
|
||||
def comparison_expr(self, items):
|
||||
return items[0]
|
||||
|
||||
def binary_comparison(self, items):
|
||||
return items[0]
|
||||
|
||||
def unary_comparison(self, items):
|
||||
return items[0]
|
||||
|
||||
def binary_comp(self, items):
|
||||
left, operator, right = items
|
||||
# Handle column reference in value
|
||||
if isinstance(right, dict) and "col" in right:
|
||||
value = right
|
||||
else:
|
||||
value = right
|
||||
return Condition(operator=operator, value=value)
|
||||
|
||||
def in_comp(self, items):
|
||||
operand, values = items
|
||||
return Condition(operator="in", value=values)
|
||||
|
||||
def between_comp(self, items):
|
||||
operand, low, high = items
|
||||
return Condition(operator="between", value=[low, high])
|
||||
|
||||
def isempty_comp(self, items):
|
||||
return Condition(operator="isempty")
|
||||
|
||||
def isnotempty_comp(self, items):
|
||||
return Condition(operator="isnotempty")
|
||||
|
||||
# ==================== Operators ====================
|
||||
|
||||
def op_eq(self, items):
|
||||
return "=="
|
||||
|
||||
def op_ne(self, items):
|
||||
return "!="
|
||||
|
||||
def op_lt(self, items):
|
||||
return "<"
|
||||
|
||||
def op_le(self, items):
|
||||
return "<="
|
||||
|
||||
def op_gt(self, items):
|
||||
return ">"
|
||||
|
||||
def op_ge(self, items):
|
||||
return ">="
|
||||
|
||||
def op_contains(self, items):
|
||||
return "contains"
|
||||
|
||||
def op_startswith(self, items):
|
||||
return "startswith"
|
||||
|
||||
def op_endswith(self, items):
|
||||
return "endswith"
|
||||
|
||||
# ==================== Operands ====================
|
||||
|
||||
def operand(self, items):
|
||||
return items[0]
|
||||
|
||||
def value_ref(self, items):
|
||||
return "value" # Marker for current cell value
|
||||
|
||||
def column_ref(self, items):
|
||||
col_name = items[0]
|
||||
if isinstance(col_name, str) and col_name.startswith('"'):
|
||||
col_name = self._unquote(col_name)
|
||||
return {"col": col_name}
|
||||
|
||||
def row_ref(self, items):
|
||||
row_index = int(items[0])
|
||||
return {"row": row_index}
|
||||
|
||||
def cell_ref_expr(self, items):
|
||||
col_name = str(items[0])
|
||||
row_index = int(items[1])
|
||||
return {"col": col_name, "row": row_index}
|
||||
|
||||
def literal(self, items):
|
||||
return items[0]
|
||||
|
||||
def string_literal(self, items):
|
||||
return self._unquote(items[0])
|
||||
|
||||
def number_literal(self, items):
|
||||
value = str(items[0])
|
||||
if "." in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
|
||||
def boolean_literal(self, items):
|
||||
return str(items[0]).lower() == "true"
|
||||
|
||||
def arithmetic(self, items):
|
||||
left, op, right = items
|
||||
# For now, return as a dict representing the expression
|
||||
# This could be evaluated later or kept as-is for complex comparisons
|
||||
return {"arithmetic": {"left": left, "op": op, "right": right}}
|
||||
|
||||
def arith_mul(self, items):
|
||||
return "*"
|
||||
|
||||
def arith_div(self, items):
|
||||
return "/"
|
||||
|
||||
def arith_add(self, items):
|
||||
return "+"
|
||||
|
||||
def arith_sub(self, items):
|
||||
return "-"
|
||||
|
||||
def list(self, items):
|
||||
return list(items)
|
||||
|
||||
# ==================== Style ====================
|
||||
|
||||
def style_expr(self, items):
|
||||
return items[0] # style_args result
|
||||
|
||||
def style_args(self, items):
|
||||
return items[0]
|
||||
|
||||
def style_with_preset(self, items):
|
||||
preset = self._unquote(items[0])
|
||||
kwargs = items[1] if len(items) > 1 else {}
|
||||
return self._build_style(preset, kwargs)
|
||||
|
||||
def style_without_preset(self, items):
|
||||
kwargs = items[0] if items else {}
|
||||
return self._build_style(None, kwargs)
|
||||
|
||||
def _build_style(self, preset: str, kwargs: dict) -> Style:
|
||||
"""Build a Style object from preset and kwargs."""
|
||||
# Map DSL parameter names to Style attribute names
|
||||
param_map = {
|
||||
"bold": ("font_weight", lambda v: "bold" if v else "normal"),
|
||||
"italic": ("font_style", lambda v: "italic" if v else "normal"),
|
||||
"underline": ("text_decoration", lambda v: "underline" if v else None),
|
||||
"strikethrough": ("text_decoration", lambda v: "line-through" if v else None),
|
||||
"background_color": ("background_color", lambda v: v),
|
||||
"color": ("color", lambda v: v),
|
||||
"font_size": ("font_size", lambda v: v),
|
||||
}
|
||||
|
||||
style_kwargs = {"preset": preset}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key in param_map:
|
||||
attr_name, converter = param_map[key]
|
||||
converted = converter(value)
|
||||
if converted is not None:
|
||||
style_kwargs[attr_name] = converted
|
||||
else:
|
||||
# Pass through unknown params (may be custom)
|
||||
style_kwargs[key] = value
|
||||
|
||||
return Style(**{k: v for k, v in style_kwargs.items() if v is not None})
|
||||
|
||||
# ==================== Format ====================
|
||||
|
||||
def format_expr(self, items):
|
||||
return items[0]
|
||||
|
||||
def format_preset(self, items):
|
||||
preset = self._unquote(items[0])
|
||||
kwargs = items[1] if len(items) > 1 else {}
|
||||
# When using preset, we don't know the type yet
|
||||
# Return a generic formatter with preset
|
||||
return NumberFormatter(preset=preset, **self._filter_number_kwargs(kwargs))
|
||||
|
||||
def format_typed(self, items):
|
||||
format_type = items[0]
|
||||
kwargs = items[1] if len(items) > 1 else {}
|
||||
return self._build_formatter(format_type, kwargs)
|
||||
|
||||
def format_type(self, items):
|
||||
return items[0]
|
||||
|
||||
def fmt_number(self, items):
|
||||
return "number"
|
||||
|
||||
def fmt_date(self, items):
|
||||
return "date"
|
||||
|
||||
def fmt_boolean(self, items):
|
||||
return "boolean"
|
||||
|
||||
def fmt_text(self, items):
|
||||
return "text"
|
||||
|
||||
def fmt_enum(self, items):
|
||||
return "enum"
|
||||
|
||||
def _build_formatter(self, format_type: str, kwargs: dict):
|
||||
"""Build the appropriate Formatter subclass."""
|
||||
if format_type == "number":
|
||||
return NumberFormatter(**self._filter_number_kwargs(kwargs))
|
||||
elif format_type == "date":
|
||||
return DateFormatter(**self._filter_date_kwargs(kwargs))
|
||||
elif format_type == "boolean":
|
||||
return BooleanFormatter(**self._filter_boolean_kwargs(kwargs))
|
||||
elif format_type == "text":
|
||||
return TextFormatter(**self._filter_text_kwargs(kwargs))
|
||||
elif format_type == "enum":
|
||||
return EnumFormatter(**self._filter_enum_kwargs(kwargs))
|
||||
else:
|
||||
raise DSLValidationError(f"Unknown formatter type: {format_type}")
|
||||
|
||||
def _filter_number_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for NumberFormatter."""
|
||||
valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
def _filter_date_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for DateFormatter."""
|
||||
valid_keys = {"format"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
def _filter_boolean_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for BooleanFormatter."""
|
||||
valid_keys = {"true_value", "false_value", "null_value"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
def _filter_text_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for TextFormatter."""
|
||||
valid_keys = {"transform", "max_length", "ellipsis"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
def _filter_enum_kwargs(self, kwargs: dict) -> dict:
|
||||
"""Filter kwargs for EnumFormatter."""
|
||||
valid_keys = {"source", "default", "allow_empty", "empty_label", "order_by"}
|
||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||
|
||||
# ==================== Keyword arguments ====================
|
||||
|
||||
def kwargs(self, items):
|
||||
"""Collect keyword arguments into a dict."""
|
||||
result = {}
|
||||
for item in items:
|
||||
if isinstance(item, tuple):
|
||||
key, value = item
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def kwarg(self, items):
|
||||
key = str(items[0])
|
||||
value = items[1]
|
||||
return (key, value)
|
||||
|
||||
def kwarg_value(self, items):
|
||||
return items[0]
|
||||
|
||||
def kwarg_string(self, items):
|
||||
return self._unquote(items[0])
|
||||
|
||||
def kwarg_number(self, items):
|
||||
value = str(items[0])
|
||||
if "." in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
|
||||
def kwarg_boolean(self, items):
|
||||
return str(items[0]).lower() == "true"
|
||||
|
||||
def kwarg_dict(self, items):
|
||||
return items[0]
|
||||
|
||||
def dict(self, items):
|
||||
"""Build a dict from dict entries."""
|
||||
result = {}
|
||||
for item in items:
|
||||
if isinstance(item, tuple):
|
||||
key, value = item
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def dict_entry(self, items):
|
||||
key = self._unquote(items[0])
|
||||
value = items[1]
|
||||
if isinstance(value, str) and (value.startswith('"') or value.startswith("'")):
|
||||
value = self._unquote(value)
|
||||
return (key, value)
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
def _unquote(self, s) -> str:
|
||||
"""Remove quotes from a quoted string."""
|
||||
s = str(s)
|
||||
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
||||
return s[1:-1]
|
||||
return s
|
||||
@@ -1,152 +0,0 @@
|
||||
from typing import Any, Callable
|
||||
|
||||
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
||||
from myfasthtml.core.formatting.dataclasses import FormatRule
|
||||
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
|
||||
from myfasthtml.core.formatting.style_resolver import StyleResolver
|
||||
|
||||
|
||||
class FormattingEngine:
|
||||
"""
|
||||
Main facade for the formatting system.
|
||||
|
||||
Combines:
|
||||
- ConditionEvaluator: evaluates conditions
|
||||
- StyleResolver: resolves styles to CSS
|
||||
- FormatterResolver: formats values for display
|
||||
- Conflict resolution: handles multiple matching rules
|
||||
|
||||
Usage:
|
||||
engine = FormattingEngine()
|
||||
rules = [
|
||||
FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)),
|
||||
FormatRule(formatter=NumberFormatter(preset="EUR")),
|
||||
]
|
||||
css, formatted = engine.apply_format(rules, cell_value=-5.0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
style_presets: dict = None,
|
||||
formatter_presets: dict = None,
|
||||
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||
):
|
||||
"""
|
||||
Initialize the FormattingEngine.
|
||||
|
||||
Args:
|
||||
style_presets: Custom style presets. If None, uses defaults.
|
||||
formatter_presets: Custom formatter presets. If None, uses defaults.
|
||||
lookup_resolver: Function for resolving enum datagrid sources.
|
||||
"""
|
||||
self._condition_evaluator = ConditionEvaluator()
|
||||
self._style_resolver = StyleResolver(style_presets)
|
||||
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
|
||||
|
||||
def apply_format(
|
||||
self,
|
||||
rules: list[FormatRule],
|
||||
cell_value: Any,
|
||||
row_data: dict = None
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Apply format rules to a cell value.
|
||||
|
||||
Args:
|
||||
rules: List of FormatRule to evaluate
|
||||
cell_value: The cell value to format
|
||||
row_data: Dict of {col_id: value} for column references
|
||||
|
||||
Returns:
|
||||
Tuple of (css_string, formatted_value):
|
||||
- css_string: CSS inline style string, or None if no style
|
||||
- formatted_value: Formatted string, or None if no formatter
|
||||
"""
|
||||
if not rules:
|
||||
return None, None
|
||||
|
||||
# Find all matching rules
|
||||
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
|
||||
|
||||
if not matching_rules:
|
||||
return None, None
|
||||
|
||||
# Resolve conflicts to get the winning rule
|
||||
winning_rule = self._resolve_conflicts(matching_rules)
|
||||
|
||||
if winning_rule is None:
|
||||
return None, None
|
||||
|
||||
# Apply style
|
||||
css_string = None
|
||||
if winning_rule.style:
|
||||
css_string = self._style_resolver.to_css_string(winning_rule.style)
|
||||
if css_string == "":
|
||||
css_string = None
|
||||
|
||||
# Apply formatter
|
||||
formatted_value = None
|
||||
if winning_rule.formatter:
|
||||
formatted_value = self._formatter_resolver.resolve(winning_rule.formatter, cell_value)
|
||||
|
||||
return css_string, formatted_value
|
||||
|
||||
def _get_matching_rules(
|
||||
self,
|
||||
rules: list[FormatRule],
|
||||
cell_value: Any,
|
||||
row_data: dict = None
|
||||
) -> list[FormatRule]:
|
||||
"""
|
||||
Get all rules that match the current cell.
|
||||
|
||||
A rule matches if:
|
||||
- It has no condition (unconditional)
|
||||
- Its condition evaluates to True
|
||||
"""
|
||||
matching = []
|
||||
|
||||
for rule in rules:
|
||||
if rule.condition is None:
|
||||
# Unconditional rule always matches
|
||||
matching.append(rule)
|
||||
elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
|
||||
# Conditional rule matches
|
||||
matching.append(rule)
|
||||
|
||||
return matching
|
||||
|
||||
def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
|
||||
"""
|
||||
Resolve conflicts when multiple rules match.
|
||||
|
||||
Resolution logic:
|
||||
1. Specificity = 1 if rule has condition, 0 otherwise
|
||||
2. Higher specificity wins
|
||||
3. At equal specificity, last rule wins entirely (no fusion)
|
||||
|
||||
Args:
|
||||
matching_rules: List of rules that matched
|
||||
|
||||
Returns:
|
||||
The winning FormatRule, or None if no rules
|
||||
"""
|
||||
if not matching_rules:
|
||||
return None
|
||||
|
||||
if len(matching_rules) == 1:
|
||||
return matching_rules[0]
|
||||
|
||||
# Calculate specificity for each rule
|
||||
# Specificity = 1 if has condition, 0 otherwise
|
||||
def get_specificity(rule: FormatRule) -> int:
|
||||
return 1 if rule.condition is not None else 0
|
||||
|
||||
# Find the maximum specificity
|
||||
max_specificity = max(get_specificity(rule) for rule in matching_rules)
|
||||
|
||||
# Filter to rules with max specificity
|
||||
top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity]
|
||||
|
||||
# Last rule wins among equal specificity
|
||||
return top_rules[-1]
|
||||
@@ -1,350 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import (
|
||||
Formatter,
|
||||
NumberFormatter,
|
||||
DateFormatter,
|
||||
BooleanFormatter,
|
||||
TextFormatter,
|
||||
EnumFormatter,
|
||||
ConstantFormatter,
|
||||
)
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS
|
||||
|
||||
|
||||
# Error indicator when formatting fails
|
||||
FORMAT_ERROR = "\u26a0" # ⚠
|
||||
|
||||
|
||||
class BaseFormatterResolver(ABC):
|
||||
"""Base class for all formatter resolvers."""
|
||||
|
||||
@abstractmethod
|
||||
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||
"""Format a value using the given formatter."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter:
|
||||
"""Apply preset values to create a fully configured formatter."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NumberFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for NumberFormatter."""
|
||||
|
||||
def resolve(self, formatter: NumberFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
# Convert to float and apply multiplier
|
||||
num = float(value) * formatter.multiplier
|
||||
|
||||
# Round to precision
|
||||
if formatter.precision > 0:
|
||||
num = round(num, formatter.precision)
|
||||
else:
|
||||
num = int(round(num))
|
||||
|
||||
# Format with decimal separator
|
||||
if formatter.precision > 0:
|
||||
int_part = int(num)
|
||||
dec_part = abs(num - int_part)
|
||||
dec_str = f"{dec_part:.{formatter.precision}f}"[2:] # Remove "0."
|
||||
else:
|
||||
int_part = int(num)
|
||||
dec_str = ""
|
||||
|
||||
# Format integer part with thousands separator
|
||||
int_str = self._format_with_thousands(abs(int_part), formatter.thousands_sep)
|
||||
|
||||
# Handle negative
|
||||
if num < 0:
|
||||
int_str = "-" + int_str
|
||||
|
||||
# Combine parts
|
||||
if dec_str:
|
||||
result = f"{int_str}{formatter.decimal_sep}{dec_str}"
|
||||
else:
|
||||
result = int_str
|
||||
|
||||
return f"{formatter.prefix}{result}{formatter.suffix}"
|
||||
|
||||
def _format_with_thousands(self, num: int, sep: str) -> str:
|
||||
"""Format an integer with thousands separator."""
|
||||
if not sep:
|
||||
return str(num)
|
||||
|
||||
result = ""
|
||||
s = str(num)
|
||||
for i, digit in enumerate(reversed(s)):
|
||||
if i > 0 and i % 3 == 0:
|
||||
result = sep + result
|
||||
result = digit + result
|
||||
return result
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> NumberFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, NumberFormatter):
|
||||
return NumberFormatter(
|
||||
preset=formatter.preset,
|
||||
prefix=formatter.prefix if formatter.prefix != "" else preset.get("prefix", ""),
|
||||
suffix=formatter.suffix if formatter.suffix != "" else preset.get("suffix", ""),
|
||||
thousands_sep=formatter.thousands_sep if formatter.thousands_sep != "" else preset.get("thousands_sep", ""),
|
||||
decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."),
|
||||
precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0),
|
||||
multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0),
|
||||
)
|
||||
else:
|
||||
return NumberFormatter(
|
||||
preset=formatter.preset,
|
||||
prefix=preset.get("prefix", ""),
|
||||
suffix=preset.get("suffix", ""),
|
||||
thousands_sep=preset.get("thousands_sep", ""),
|
||||
decimal_sep=preset.get("decimal_sep", "."),
|
||||
precision=preset.get("precision", 0),
|
||||
multiplier=preset.get("multiplier", 1.0),
|
||||
)
|
||||
|
||||
|
||||
class DateFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for DateFormatter."""
|
||||
|
||||
def resolve(self, formatter: DateFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime(formatter.format)
|
||||
elif isinstance(value, str):
|
||||
dt = datetime.fromisoformat(value)
|
||||
return dt.strftime(formatter.format)
|
||||
else:
|
||||
raise TypeError(f"Cannot format {type(value)} as date")
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> DateFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, DateFormatter):
|
||||
return DateFormatter(
|
||||
preset=formatter.preset,
|
||||
format=formatter.format if formatter.format != "%Y-%m-%d" else preset.get("format", "%Y-%m-%d"),
|
||||
)
|
||||
else:
|
||||
return DateFormatter(
|
||||
preset=formatter.preset,
|
||||
format=preset.get("format", "%Y-%m-%d"),
|
||||
)
|
||||
|
||||
|
||||
class BooleanFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for BooleanFormatter."""
|
||||
|
||||
def resolve(self, formatter: BooleanFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return formatter.null_value
|
||||
if value is True or value == 1:
|
||||
return formatter.true_value
|
||||
if value is False or value == 0:
|
||||
return formatter.false_value
|
||||
return formatter.null_value
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> BooleanFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, BooleanFormatter):
|
||||
return BooleanFormatter(
|
||||
preset=formatter.preset,
|
||||
true_value=formatter.true_value if formatter.true_value != "true" else preset.get("true_value", "true"),
|
||||
false_value=formatter.false_value if formatter.false_value != "false" else preset.get("false_value", "false"),
|
||||
null_value=formatter.null_value if formatter.null_value != "" else preset.get("null_value", ""),
|
||||
)
|
||||
else:
|
||||
return BooleanFormatter(
|
||||
preset=formatter.preset,
|
||||
true_value=preset.get("true_value", "true"),
|
||||
false_value=preset.get("false_value", "false"),
|
||||
null_value=preset.get("null_value", ""),
|
||||
)
|
||||
|
||||
|
||||
class TextFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for TextFormatter."""
|
||||
|
||||
def resolve(self, formatter: TextFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
text = str(value)
|
||||
|
||||
# Apply transformation
|
||||
if formatter.transform == "uppercase":
|
||||
text = text.upper()
|
||||
elif formatter.transform == "lowercase":
|
||||
text = text.lower()
|
||||
elif formatter.transform == "capitalize":
|
||||
text = text.capitalize()
|
||||
|
||||
# Apply truncation
|
||||
if formatter.max_length and len(text) > formatter.max_length:
|
||||
text = text[:formatter.max_length] + formatter.ellipsis
|
||||
|
||||
return text
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> TextFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, TextFormatter):
|
||||
return TextFormatter(
|
||||
preset=formatter.preset,
|
||||
transform=formatter.transform or preset.get("transform"),
|
||||
max_length=formatter.max_length or preset.get("max_length"),
|
||||
ellipsis=formatter.ellipsis if formatter.ellipsis != "..." else preset.get("ellipsis", "..."),
|
||||
)
|
||||
else:
|
||||
return TextFormatter(
|
||||
preset=formatter.preset,
|
||||
transform=preset.get("transform"),
|
||||
max_length=preset.get("max_length"),
|
||||
ellipsis=preset.get("ellipsis", "..."),
|
||||
)
|
||||
|
||||
|
||||
class EnumFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for EnumFormatter."""
|
||||
|
||||
def __init__(self, lookup_resolver: Callable[[str, str, str], dict] = None):
|
||||
self.lookup_resolver = lookup_resolver
|
||||
|
||||
def resolve(self, formatter: EnumFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return formatter.default
|
||||
|
||||
source = formatter.source
|
||||
if not source:
|
||||
return str(value)
|
||||
|
||||
source_type = source.get("type")
|
||||
|
||||
if source_type == "mapping":
|
||||
mapping = source.get("value", {})
|
||||
return mapping.get(value, formatter.default)
|
||||
|
||||
elif source_type == "datagrid":
|
||||
if not self.lookup_resolver:
|
||||
return str(value)
|
||||
|
||||
grid_id = source.get("value")
|
||||
value_col = source.get("value_column")
|
||||
display_col = source.get("display_column")
|
||||
|
||||
mapping = self.lookup_resolver(grid_id, value_col, display_col)
|
||||
return mapping.get(value, formatter.default)
|
||||
|
||||
return str(value)
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> EnumFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, EnumFormatter):
|
||||
return formatter
|
||||
else:
|
||||
return EnumFormatter(
|
||||
preset=formatter.preset,
|
||||
source=preset.get("source", {}),
|
||||
default=preset.get("default", ""),
|
||||
)
|
||||
|
||||
|
||||
class ConstantFormatterResolver(BaseFormatterResolver):
|
||||
|
||||
def resolve(self, formatter: ConstantFormatter, value: Any) -> str:
|
||||
return formatter.value
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter:
|
||||
return formatter
|
||||
|
||||
|
||||
class FormatterResolver:
|
||||
"""
|
||||
Main resolver that dispatches to the appropriate formatter resolver.
|
||||
|
||||
This is a facade that delegates formatting to specialized resolvers
|
||||
based on the formatter type.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formatter_presets: dict = None,
|
||||
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||
):
|
||||
self.formatter_presets = formatter_presets or DEFAULT_FORMATTER_PRESETS
|
||||
self.lookup_resolver = lookup_resolver
|
||||
|
||||
# Registry of resolvers by formatter type
|
||||
self._resolvers: dict[type, BaseFormatterResolver] = {
|
||||
NumberFormatter: NumberFormatterResolver(),
|
||||
DateFormatter: DateFormatterResolver(),
|
||||
BooleanFormatter: BooleanFormatterResolver(),
|
||||
TextFormatter: TextFormatterResolver(),
|
||||
EnumFormatter: EnumFormatterResolver(lookup_resolver),
|
||||
ConstantFormatter: ConstantFormatterResolver()
|
||||
}
|
||||
|
||||
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||
"""
|
||||
Apply formatter to a value.
|
||||
|
||||
Args:
|
||||
formatter: The Formatter to apply
|
||||
value: The value to format
|
||||
|
||||
Returns:
|
||||
Formatted string for display, or FORMAT_ERROR on failure
|
||||
"""
|
||||
if formatter is None:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
try:
|
||||
# Get the appropriate resolver
|
||||
resolver = self._get_resolver(formatter)
|
||||
if resolver is None:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
# Apply preset if specified
|
||||
formatter = resolver.apply_preset(formatter, self.formatter_presets)
|
||||
|
||||
# Resolve the value
|
||||
return resolver.resolve(formatter, value)
|
||||
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return FORMAT_ERROR
|
||||
|
||||
def _get_resolver(self, formatter: Formatter) -> BaseFormatterResolver | None:
|
||||
"""Get the appropriate resolver for a formatter."""
|
||||
# Direct type match
|
||||
formatter_type = type(formatter)
|
||||
if formatter_type in self._resolvers:
|
||||
return self._resolvers[formatter_type]
|
||||
|
||||
# Base Formatter with preset - determine type from preset
|
||||
if formatter_type == Formatter and formatter.preset:
|
||||
preset = self.formatter_presets.get(formatter.preset, {})
|
||||
preset_type = preset.get("type")
|
||||
|
||||
type_mapping = {
|
||||
"number": NumberFormatter,
|
||||
"date": DateFormatter,
|
||||
"boolean": BooleanFormatter,
|
||||
"text": TextFormatter,
|
||||
"enum": EnumFormatter,
|
||||
}
|
||||
|
||||
target_type = type_mapping.get(preset_type)
|
||||
if target_type:
|
||||
return self._resolvers.get(target_type)
|
||||
|
||||
return None
|
||||
@@ -1,76 +0,0 @@
|
||||
# === Style Presets (DaisyUI 5) ===
|
||||
# Keys use CSS property names (with hyphens)
|
||||
|
||||
DEFAULT_STYLE_PRESETS = {
|
||||
"primary": {
|
||||
"background-color": "var(--color-primary)",
|
||||
"color": "var(--color-primary-content)",
|
||||
},
|
||||
"secondary": {
|
||||
"background-color": "var(--color-secondary)",
|
||||
"color": "var(--color-secondary-content)",
|
||||
},
|
||||
"accent": {
|
||||
"background-color": "var(--color-accent)",
|
||||
"color": "var(--color-accent-content)",
|
||||
},
|
||||
"neutral": {
|
||||
"background-color": "var(--color-neutral)",
|
||||
"color": "var(--color-neutral-content)",
|
||||
},
|
||||
"info": {
|
||||
"background-color": "var(--color-info)",
|
||||
"color": "var(--color-info-content)",
|
||||
},
|
||||
"success": {
|
||||
"background-color": "var(--color-success)",
|
||||
"color": "var(--color-success-content)",
|
||||
},
|
||||
"warning": {
|
||||
"background-color": "var(--color-warning)",
|
||||
"color": "var(--color-warning-content)",
|
||||
},
|
||||
"error": {
|
||||
"background-color": "var(--color-error)",
|
||||
"color": "var(--color-error-content)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# === Formatter Presets ===
|
||||
|
||||
DEFAULT_FORMATTER_PRESETS = {
|
||||
"EUR": {
|
||||
"type": "number",
|
||||
"suffix": " €",
|
||||
"thousands_sep": " ",
|
||||
"decimal_sep": ",",
|
||||
"precision": 2,
|
||||
},
|
||||
"USD": {
|
||||
"type": "number",
|
||||
"prefix": "$",
|
||||
"thousands_sep": ",",
|
||||
"decimal_sep": ".",
|
||||
"precision": 2,
|
||||
},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"suffix": "%",
|
||||
"precision": 1,
|
||||
"multiplier": 100,
|
||||
},
|
||||
"short_date": {
|
||||
"type": "date",
|
||||
"format": "%d/%m/%Y",
|
||||
},
|
||||
"iso_date": {
|
||||
"type": "date",
|
||||
"format": "%Y-%m-%d",
|
||||
},
|
||||
"yes_no": {
|
||||
"type": "boolean",
|
||||
"true_value": "Yes",
|
||||
"false_value": "No",
|
||||
},
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
from myfasthtml.core.formatting.dataclasses import Style
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS
|
||||
|
||||
|
||||
# Mapping from Python attribute names to CSS property names
|
||||
PROPERTY_NAME_MAP = {
|
||||
"background_color": "background-color",
|
||||
"color": "color",
|
||||
"font_weight": "font-weight",
|
||||
"font_style": "font-style",
|
||||
"font_size": "font-size",
|
||||
"text_decoration": "text-decoration",
|
||||
}
|
||||
|
||||
|
||||
class StyleResolver:
|
||||
"""Resolves styles by applying presets and explicit properties."""
|
||||
|
||||
def __init__(self, style_presets: dict = None):
|
||||
"""
|
||||
Initialize the StyleResolver.
|
||||
|
||||
Args:
|
||||
style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS.
|
||||
"""
|
||||
self.style_presets = style_presets or DEFAULT_STYLE_PRESETS
|
||||
|
||||
def resolve(self, style: Style) -> dict:
|
||||
"""
|
||||
Resolve a Style to CSS properties dict.
|
||||
|
||||
Logic:
|
||||
1. If preset is defined, load preset properties
|
||||
2. Override with explicit properties (non-None values)
|
||||
3. Convert Python names to CSS names
|
||||
|
||||
Args:
|
||||
style: The Style object to resolve
|
||||
|
||||
Returns:
|
||||
Dict of CSS properties, e.g. {"background-color": "red", "color": "white"}
|
||||
"""
|
||||
if style is None:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
|
||||
# Apply preset first
|
||||
if style.preset and style.preset in self.style_presets:
|
||||
preset_props = self.style_presets[style.preset]
|
||||
for css_name, value in preset_props.items():
|
||||
result[css_name] = value
|
||||
|
||||
# Override with explicit properties
|
||||
for py_name, css_name in PROPERTY_NAME_MAP.items():
|
||||
value = getattr(style, py_name, None)
|
||||
if value is not None:
|
||||
result[css_name] = value
|
||||
|
||||
return result
|
||||
|
||||
def to_css_string(self, style: Style) -> str:
|
||||
"""
|
||||
Resolve a Style to a CSS inline string.
|
||||
|
||||
Args:
|
||||
style: The Style object to resolve
|
||||
|
||||
Returns:
|
||||
CSS string, e.g. "background-color: red; color: white;"
|
||||
"""
|
||||
props = self.resolve(style)
|
||||
if not props:
|
||||
return ""
|
||||
return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
|
||||
@@ -3,8 +3,7 @@ import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.constants import NO_DEFAULT_VALUE
|
||||
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
|
||||
from myfasthtml.core.utils import pascal_to_snake
|
||||
|
||||
logger = logging.getLogger("InstancesManager")
|
||||
|
||||
@@ -68,7 +67,6 @@ class BaseInstance:
|
||||
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()
|
||||
self._bound_commands = {}
|
||||
|
||||
if auto_register:
|
||||
InstancesManager.register(self._session, self)
|
||||
@@ -82,26 +80,16 @@ class BaseInstance:
|
||||
def get_parent(self) -> Optional['BaseInstance']:
|
||||
return self._parent
|
||||
|
||||
def get_safe_parent_key(self):
|
||||
return self.get_parent_full_id() if self.get_parent() else self.get_full_id()
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||
|
||||
def get_parent_full_id(self) -> Optional[str]:
|
||||
def get_full_parent_id(self) -> Optional[str]:
|
||||
parent = self.get_parent()
|
||||
return parent.get_full_id() if parent else None
|
||||
|
||||
def bind_command(self, command, command_to_bind):
|
||||
command_name = command.name if hasattr(command, "name") else command
|
||||
self._bound_commands.setdefault(command_name, []).append(command_to_bind)
|
||||
|
||||
def get_bound_commands(self, command_name):
|
||||
return self._bound_commands.get(command_name, [])
|
||||
|
||||
@classmethod
|
||||
def compute_prefix(cls):
|
||||
return f"mf-{pascal_to_snake(cls.__name__)}"
|
||||
@@ -116,8 +104,8 @@ class BaseInstance:
|
||||
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||
return _id
|
||||
|
||||
if _id.startswith(("-", "#")) and parent is not None:
|
||||
return f"{parent.get_id()}{_id}"
|
||||
if _id.startswith("-") and parent is not None:
|
||||
return f"{parent.get_prefix()}{_id}"
|
||||
|
||||
return _id
|
||||
|
||||
@@ -145,11 +133,8 @@ class UniqueInstance(BaseInstance):
|
||||
parent: Optional[BaseInstance] = None,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True,
|
||||
on_init=None):
|
||||
auto_register: bool = True):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
if on_init is not None:
|
||||
on_init()
|
||||
|
||||
|
||||
class MultipleInstance(BaseInstance):
|
||||
@@ -184,57 +169,18 @@ class InstancesManager:
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def get(session: dict, instance_id: str, default=NO_DEFAULT_VALUE):
|
||||
def get(session: dict, instance_id: str):
|
||||
"""
|
||||
Get or create an instance of the given type (from its id)
|
||||
:param session:
|
||||
:param instance_id:
|
||||
:param default:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
except KeyError:
|
||||
if default is NO_DEFAULT_VALUE:
|
||||
raise
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def get_by_type(session: dict, cls: type):
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
|
||||
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
|
||||
assert len(res) > 0, f"No instance of type {cls.__name__} found"
|
||||
return res[0]
|
||||
|
||||
@staticmethod
|
||||
def dynamic_get(session, component_parent: tuple, component: tuple):
|
||||
component_type, component_id = component
|
||||
|
||||
# 1. Check if component already exists
|
||||
existing = InstancesManager.get(session, component_id, None)
|
||||
if existing is not None:
|
||||
logger.debug(f"Component {component_id} already exists, returning existing instance")
|
||||
return existing
|
||||
|
||||
# 2. Component doesn't exist, create it
|
||||
parent_type, parent_id = component_parent
|
||||
|
||||
# parent should always exist
|
||||
parent = InstancesManager.get(session, parent_id)
|
||||
|
||||
real_component_type = snake_to_pascal(component_type.removeprefix("mf-"))
|
||||
component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}"
|
||||
cls = get_class(component_full_type)
|
||||
logger.debug(f"Creating new component {component_id} of type {real_component_type}")
|
||||
return cls(parent, _id=component_id)
|
||||
key = (InstancesManager.get_session_id(session), instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
|
||||
@staticmethod
|
||||
def get_session_id(session):
|
||||
if isinstance(session, str):
|
||||
return session
|
||||
if session is None:
|
||||
return "** NOT LOGGED IN **"
|
||||
if "user_info" not in session:
|
||||
|
||||
@@ -3,7 +3,6 @@ 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.
|
||||
@@ -150,7 +149,6 @@ def from_parent_child_list(
|
||||
id_getter: Callable = None,
|
||||
label_getter: Callable = None,
|
||||
parent_getter: Callable = None,
|
||||
ghost_label_getter: Callable = lambda node: str(node),
|
||||
ghost_color: str = GHOST_COLOR,
|
||||
root_color: str | None = ROOT_COLOR
|
||||
) -> tuple[list, list]:
|
||||
@@ -162,7 +160,6 @@ def from_parent_child_list(
|
||||
id_getter: callback to extract node ID
|
||||
label_getter: callback to extract node label
|
||||
parent_getter: callback to extract parent ID
|
||||
ghost_label_getter: callback to extract label for ghost nodes
|
||||
ghost_color: color for ghost nodes (referenced parents)
|
||||
root_color: color for root nodes (nodes without parent)
|
||||
|
||||
@@ -227,7 +224,7 @@ def from_parent_child_list(
|
||||
ghost_nodes.add(parent_id)
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": ghost_label_getter(parent_id),
|
||||
"label": str(parent_id),
|
||||
"color": ghost_color
|
||||
})
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Optimized FastHTML-compatible elements that generate HTML directly.
|
||||
|
||||
These classes bypass FastHTML's overhead for performance-critical rendering
|
||||
by generating HTML strings directly instead of creating full FastHTML objects.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fastcore.xml import FT
|
||||
from fasthtml.common import NotStr
|
||||
from fasthtml.components import Span
|
||||
|
||||
from myfasthtml.core.constants import NO_DEFAULT_VALUE
|
||||
|
||||
|
||||
class OptimizedFt:
|
||||
"""Lightweight FastHTML-compatible element that generates HTML directly."""
|
||||
|
||||
ATTR_MAP = {
|
||||
"cls": "class",
|
||||
"_id": "id",
|
||||
}
|
||||
|
||||
def __init__(self, tag, *args, **kwargs):
|
||||
self.tag = tag
|
||||
self.children = args
|
||||
self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=128)
|
||||
def safe_attr(attr_name):
|
||||
"""Convert Python attribute names to HTML attribute names."""
|
||||
attr_name = attr_name.replace("hx_", "hx-")
|
||||
attr_name = attr_name.replace("data_", "data-")
|
||||
return OptimizedFt.ATTR_MAP.get(attr_name, attr_name)
|
||||
|
||||
@staticmethod
|
||||
def to_html_helper(item):
|
||||
"""Convert any item to HTML string."""
|
||||
if item is None:
|
||||
return ""
|
||||
elif isinstance(item, str):
|
||||
return item
|
||||
elif isinstance(item, (int, float, bool)):
|
||||
return str(item)
|
||||
elif isinstance(item, OptimizedFt):
|
||||
return item.to_html()
|
||||
elif isinstance(item, NotStr):
|
||||
return str(item)
|
||||
elif isinstance(item, FT):
|
||||
return str(item)
|
||||
else:
|
||||
raise Exception(f"Unsupported type: {type(item)}, {item=}")
|
||||
|
||||
def to_html(self):
|
||||
"""Generate HTML string."""
|
||||
# Build attributes
|
||||
attrs_list = []
|
||||
for k, v in self.attrs.items():
|
||||
if v is False:
|
||||
continue # Skip False attributes
|
||||
if v is True:
|
||||
attrs_list.append(k) # Boolean attribute
|
||||
else:
|
||||
# No need to escape v since we control the values (width, IDs, etc.)
|
||||
attrs_list.append(f'{k}="{v}"')
|
||||
|
||||
attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else ''
|
||||
|
||||
# Build children HTML
|
||||
children_html = ''.join(self.to_html_helper(child) for child in self.children)
|
||||
|
||||
return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>'
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML compatibility - returns NotStr to avoid double escaping."""
|
||||
return NotStr(self.to_html())
|
||||
|
||||
def __str__(self):
|
||||
return self.to_html()
|
||||
|
||||
def get(self, attr_name, default=NO_DEFAULT_VALUE):
|
||||
try:
|
||||
return self.attrs[self.safe_attr(attr_name)]
|
||||
except KeyError:
|
||||
if default is NO_DEFAULT_VALUE:
|
||||
raise
|
||||
return default
|
||||
|
||||
|
||||
class OptimizedDiv(OptimizedFt):
|
||||
"""Optimized Div element."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__("div", *args, **kwargs)
|
||||
@@ -1,4 +1,3 @@
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -10,13 +9,10 @@ from rich.table import Table
|
||||
from starlette.routing import Mount
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.dsl.types import Position
|
||||
from myfasthtml.core.dsls import DslsManager
|
||||
from myfasthtml.core.formatting.dsl import DSLSyntaxError
|
||||
from myfasthtml.test.MyFT import MyFT
|
||||
|
||||
utils_app, utils_rt = fast_app()
|
||||
logger = logging.getLogger("Routing")
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
|
||||
def mount_if_not_exists(app, path: str, sub_app):
|
||||
@@ -266,86 +262,6 @@ def snake_to_pascal(name: str) -> str:
|
||||
return ''.join(word.capitalize() for word in parts if word)
|
||||
|
||||
|
||||
def flatten(*args):
|
||||
"""
|
||||
Flattens nested lists or tuples into a single list. This utility function takes
|
||||
any number of arguments, iterating recursively through any nested lists or
|
||||
tuples, and returns a flat list containing all the elements.
|
||||
|
||||
:param args: Arbitrary number of arguments, which can include nested lists or
|
||||
tuples to be flattened.
|
||||
:type args: Any
|
||||
:return: A flat list containing all the elements from the input, preserving the
|
||||
order of elements as they are recursively extracted from nested
|
||||
structures.
|
||||
:rtype: list
|
||||
"""
|
||||
res = []
|
||||
for arg in args:
|
||||
if isinstance(arg, (list, tuple)):
|
||||
res.extend(flatten(*arg))
|
||||
else:
|
||||
res.append(arg)
|
||||
return res
|
||||
|
||||
|
||||
def make_html_id(s: str | None) -> str | None:
|
||||
"""
|
||||
Creates a valid html id
|
||||
:param s:
|
||||
:return:
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
s = str(s).strip()
|
||||
# Replace spaces and special characters with hyphens or remove them
|
||||
s = re.sub(r'[^a-zA-Z0-9_-]', '-', s)
|
||||
|
||||
# Ensure the ID starts with a letter or underscore
|
||||
if not re.match(r'^[a-zA-Z_]', s):
|
||||
s = 'id_' + s # Add a prefix if it doesn't
|
||||
|
||||
# Collapse multiple consecutive hyphens into one
|
||||
s = re.sub(r'-+', '-', s)
|
||||
|
||||
# Replace trailing hyphens with underscores
|
||||
s = re.sub(r'-+$', '_', s)
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def make_safe_id(s: str | None):
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_'
|
||||
return res.lower() # no uppercase
|
||||
|
||||
|
||||
def get_class(qualified_class_name: str):
|
||||
"""
|
||||
Dynamically loads and returns a class type from its fully qualified name.
|
||||
Note that the class is not instantiated.
|
||||
|
||||
:param qualified_class_name: Fully qualified name of the class (e.g., 'some.module.ClassName').
|
||||
:return: The class object.
|
||||
:raises ImportError: If the module cannot be imported.
|
||||
:raises AttributeError: If the class cannot be resolved in the module.
|
||||
"""
|
||||
module_name, class_name = qualified_class_name.rsplit(".", 1)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ModuleNotFoundError as e:
|
||||
raise ImportError(f"Could not import module '{module_name}' for '{qualified_class_name}': {e}")
|
||||
|
||||
if not hasattr(module, class_name):
|
||||
raise AttributeError(f"Component '{class_name}' not found in '{module.__name__}'.")
|
||||
|
||||
return getattr(module, class_name)
|
||||
|
||||
|
||||
@utils_rt(Routes.Commands)
|
||||
def post(session, c_id: str, client_response: dict = None):
|
||||
"""
|
||||
@@ -383,44 +299,3 @@ def post(session, b_id: str, values: dict):
|
||||
return res
|
||||
|
||||
raise ValueError(f"Binding with ID '{b_id}' not found.")
|
||||
|
||||
|
||||
@utils_rt(Routes.Completions)
|
||||
def get(session, e_id: str, text: str, line: int, ch: int):
|
||||
"""
|
||||
Default routes for Domaine Specific Languages completion
|
||||
:param session:
|
||||
:param e_id: engine_id
|
||||
:param text:
|
||||
:param line:
|
||||
:param ch:
|
||||
:return:
|
||||
"""
|
||||
logger.debug(f"Entering {Routes.Completions} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
|
||||
completion = DslsManager.get_completion_engine(e_id)
|
||||
result = completion.get_completions(text, Position(line, ch))
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@utils_rt(Routes.Validations)
|
||||
def get(session, e_id: str, text: str, line: int, ch: int):
|
||||
"""
|
||||
Default routes for Domaine Specific Languages syntax validation
|
||||
:param session:
|
||||
:param e_id:
|
||||
:param text:
|
||||
:param line:
|
||||
:param ch:
|
||||
:return:
|
||||
"""
|
||||
logger.debug(f"Entering {Routes.Validations} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
|
||||
validation = DslsManager.get_validation_parser(e_id)
|
||||
try:
|
||||
validation.parse(text)
|
||||
return {"errors": []}
|
||||
except DSLSyntaxError as e:
|
||||
return {"errors": [{
|
||||
"line": e.line or 1,
|
||||
"column": e.column or 1,
|
||||
"message": e.message
|
||||
}]}
|
||||
|
||||
@@ -48,4 +48,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
@@ -49,14 +49,8 @@ def get():
|
||||
mk.manage_binding(datalist, Binding(data))
|
||||
mk.manage_binding(label_elt, Binding(data))
|
||||
|
||||
add_button = mk.button("Add", command=Command("Add",
|
||||
"Add a suggestion",
|
||||
None,
|
||||
add_suggestion).bind(data))
|
||||
remove_button = mk.button("Remove", command=Command("Remove",
|
||||
"Remove a suggestion",
|
||||
None,
|
||||
remove_suggestion).bind(data))
|
||||
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
|
||||
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
|
||||
|
||||
return Div(
|
||||
add_button,
|
||||
@@ -69,4 +63,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
@@ -44,4 +44,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
@@ -37,4 +37,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user