Compare commits
9 Commits
WorkingOnD
...
AddingCont
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3924b5ca | |||
| 8f2528787a | |||
| 7c701a9116 | |||
| e96ac5ddfd | |||
| 378775cdf9 | |||
| e34d675e38 | |||
| 93cb477c21 | |||
| 96ed447eae | |||
| 1be75263ad |
@@ -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
|
||||
|
||||
@@ -201,351 +201,190 @@ class TestControlRender:
|
||||
|
||||
### UTR-11: Required Reading for Control Render Tests
|
||||
|
||||
---
|
||||
**Before writing ANY render tests for Controls, you MUST:**
|
||||
|
||||
#### **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.**
|
||||
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
|
||||
2. **Understand the key concepts**:
|
||||
- How `matches()` and `find()` work
|
||||
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
|
||||
- How to test only what matters (not every detail)
|
||||
- How to read error messages with `^^^` markers
|
||||
3. **Apply the best practices** detailed below
|
||||
|
||||
---
|
||||
|
||||
### **TEST FILE STRUCTURE**
|
||||
#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)**
|
||||
|
||||
---
|
||||
**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern.
|
||||
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
**Les trois étapes :**
|
||||
1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global
|
||||
2. **Définir la structure attendue** avec `expected = ...`
|
||||
3. **Comparer** avec `assert matches(element, expected)`
|
||||
|
||||
**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.
|
||||
**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure.
|
||||
|
||||
**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:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ BON - Pattern en trois étapes
|
||||
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
|
||||
# Étape 1 : Extraire l'élément à tester
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Step 2: Define the expected structure
|
||||
# Étape 2 : Définir la structure attendue
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
# Étape 3 : Comparer
|
||||
assert matches(header, expected)
|
||||
|
||||
# ❌ À ÉVITER - Tout imbriqué en une ligne
|
||||
def test_header_has_two_sides(self, layout):
|
||||
assert matches(
|
||||
find_one(layout.render(), Header(cls=Contains("mf-layout-header"))),
|
||||
Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"))
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SEARCH FOR ELEMENTS**
|
||||
**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental.
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
#### **COMMENT CHERCHER LES ÉLÉMENTS**
|
||||
|
||||
**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).
|
||||
#### **UTR-11.2 : Privilégier la recherche par ID**
|
||||
|
||||
**Example:**
|
||||
**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut.
|
||||
|
||||
**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique).
|
||||
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - search by ID
|
||||
# ✅ BON - recherche par ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
# ❌ À ÉVITER - recherche par classe quand un ID existe
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte**
|
||||
|
||||
**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
|
||||
**Principe :**
|
||||
- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète
|
||||
- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence
|
||||
|
||||
**Examples:**
|
||||
**Exemples :**
|
||||
```python
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
# ✅ BON - find_one pour structure unique
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ GOOD - find for counting
|
||||
# ✅ BON - find pour compter
|
||||
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**
|
||||
#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`**
|
||||
|
||||
**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()`.
|
||||
**Principe :**
|
||||
- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`.
|
||||
- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`.
|
||||
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces.
|
||||
|
||||
**Examples:**
|
||||
**Exemples :**
|
||||
```python
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
# ✅ BON - Contains pour cls (une ou plusieurs classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ GOOD - Contains for style
|
||||
# ✅ BON - Contains pour style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ AVOID - exact class test
|
||||
# ❌ À ÉVITER - test exact des classes
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ AVOID - exact complete style test
|
||||
# ❌ À ÉVITER - test exact du style complet
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône**
|
||||
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
|
||||
**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu.
|
||||
|
||||
**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:**
|
||||
**Le paramètre `name` :**
|
||||
- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique
|
||||
- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle.
|
||||
- **JAMAIS `name="svg"`** : Cela causera des échecs de test
|
||||
|
||||
**Exemples :**
|
||||
```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>
|
||||
from myfasthtml.icons.fluent import panel_right_expand20_regular
|
||||
|
||||
# ✅ BON - Tester une icône spécifique
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
TestIcon("panel_right_expand20_regular"),
|
||||
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
|
||||
# ✅ BON - Tester la présence de n'importe quelle icône
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
TestIcon(""), # Accepte n'importe quelle icône
|
||||
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
|
||||
# ❌ À ÉVITER - name="svg"
|
||||
expected = Div(TestIcon("svg")) # ERREUR : causera un échec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript**
|
||||
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
|
||||
**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet.
|
||||
|
||||
**Example:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
# ✅ BON - TestScript avec fragment important
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ AVOID - testing all script content
|
||||
# ❌ À ÉVITER - tester tout le contenu du script
|
||||
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".
|
||||
#### **COMMENT DOCUMENTER LES TESTS**
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
#### **UTR-11.7 : Justifier le choix des éléments testés**
|
||||
|
||||
---
|
||||
**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ?
|
||||
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**.
|
||||
|
||||
**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:**
|
||||
**Exemples :**
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
@@ -579,33 +418,33 @@ def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
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
|
||||
**Points clés :**
|
||||
- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.)
|
||||
- Pas besoin de suivre une formulation rigide
|
||||
- L'important est la **justification du choix**, pas le format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
#### **UTR-11.8 : Tests de comptage avec messages explicites**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu.
|
||||
|
||||
**Example:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - explanatory message
|
||||
# ✅ BON - message explicatif
|
||||
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
|
||||
# ❌ À ÉVITER - pas de message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **OTHER IMPORTANT RULES**
|
||||
#### **AUTRES RÈGLES IMPORTANTES**
|
||||
|
||||
---
|
||||
|
||||
@@ -616,7 +455,7 @@ assert len(resizers) == 1
|
||||
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)
|
||||
- Justification section explaining why tested elements matter (see UTR-11.7)
|
||||
- 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`)
|
||||
@@ -640,29 +479,23 @@ assert len(resizers) == 1
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
#### **Résumé : Les 8 règles UTR-11**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
**Pattern fondamental**
|
||||
- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
|
||||
|
||||
**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
|
||||
**Comment chercher**
|
||||
- **UTR-11.2** : Privilégier recherche par ID
|
||||
- **UTR-11.3** : `find_one()` vs `find()` selon contexte
|
||||
|
||||
**How to search**
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
**Comment spécifier**
|
||||
- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style`
|
||||
- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes
|
||||
- **UTR-11.6** : `TestScript()` pour JavaScript
|
||||
|
||||
**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()`
|
||||
**Comment documenter**
|
||||
- **UTR-11.7** : Justifier le choix des éléments testés
|
||||
- **UTR-11.8** : Messages explicites pour `assert len()`
|
||||
|
||||
---
|
||||
|
||||
@@ -670,139 +503,19 @@ assert len(resizers) == 1
|
||||
- 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)
|
||||
- Always include justification documentation (see UTR-11.7)
|
||||
|
||||
---
|
||||
|
||||
### 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-12: 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 +532,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:
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.5.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
|
||||
|
||||
20
src/app.py
20
src/app.py
@@ -1,11 +1,7 @@
|
||||
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
|
||||
@@ -17,7 +13,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 +34,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.
|
||||
@@ -88,9 +82,7 @@ def create_sample_treeview(parent):
|
||||
|
||||
@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")
|
||||
|
||||
@@ -130,16 +122,8 @@ 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.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "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,10 +1,5 @@
|
||||
: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;
|
||||
@@ -26,18 +21,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 +43,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,16 +59,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;
|
||||
@@ -449,12 +439,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 +460,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 +474,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 +490,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 ************* */
|
||||
/* *********************************************** */
|
||||
@@ -705,140 +663,57 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Common properties for side panels */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
.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;
|
||||
}
|
||||
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
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;
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
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;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/*!* Properties container *!*/
|
||||
/* Properties container */
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/*!* Group card - using DaisyUI card styling *!*/
|
||||
/* 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);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-properties-group-container {
|
||||
display: inline-block; /* important */
|
||||
min-width: max-content; /* important */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/*!* Group header - gradient using DaisyUI primary color *!*/
|
||||
/* 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);
|
||||
@@ -847,24 +722,20 @@
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/*!* Group content area *!*/
|
||||
/* Group content area */
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/*!* Property row *!*/
|
||||
/* Property row */
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -876,314 +747,22 @@
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/*!* Property key - normal font *!*/
|
||||
/* 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 *!*/
|
||||
/* Property value - monospace font */
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,7 +1,7 @@
|
||||
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):
|
||||
@@ -9,18 +9,22 @@ 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 +36,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,72 +1,21 @@
|
||||
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 fasthtml.components import Div
|
||||
|
||||
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, \
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
|
||||
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):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
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.row_index: bool = False
|
||||
self.columns: list[DataGridColumnState] = []
|
||||
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
|
||||
self.headers: list[DataGridHeaderFooterConf] = []
|
||||
@@ -75,21 +24,12 @@ class DatagridState(DbObject):
|
||||
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):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
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
|
||||
@@ -97,769 +37,23 @@ class DatagridSettings(DbObject):
|
||||
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
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
def __init__(self, parent, conf=None, save_state=None, _id=None):
|
||||
def __init__(self, parent, settings=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._settings = DatagridSettings(self).update(settings)
|
||||
self._state = DatagridState(self)
|
||||
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;"
|
||||
self._id
|
||||
)
|
||||
|
||||
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,253 +1,56 @@
|
||||
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.TreeView import TreeView
|
||||
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.core.instances import MultipleInstance, InstancesManager
|
||||
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)
|
||||
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
|
||||
|
||||
def new_grid(self):
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.new_grid)
|
||||
return Command("NewGrid", "New grid", 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")
|
||||
def open_from_excel(self, tab_id, get_content_callback):
|
||||
excel_content = get_content_callback()
|
||||
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
|
||||
|
||||
|
||||
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
|
||||
class DataGridsManager(MultipleInstance):
|
||||
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.tree = TreeView(self, _id="-treeview")
|
||||
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))
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
|
||||
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
|
||||
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 open_from_excel(self, tab_id, excel_content):
|
||||
df = pd.read_excel(excel_content)
|
||||
content = df.to_html(index=False)
|
||||
self._tabs_manager.switch(tab_id, content)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._tree,
|
||||
Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid"),
|
||||
cls="flex"
|
||||
),
|
||||
self.tree,
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -29,43 +23,17 @@ 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"
|
||||
)
|
||||
Represents a dropdown component that can be toggled open or closed. This class is used
|
||||
to create interactive dropdown elements, allowing for container and button customization.
|
||||
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||
handling user interactions.
|
||||
"""
|
||||
|
||||
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 +43,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()
|
||||
@@ -25,25 +25,14 @@ class FileUploadState(DbObject):
|
||||
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):
|
||||
@@ -60,26 +49,16 @@ class FileUpload(MultipleInstance):
|
||||
super().__init__(parent, _id=_id, **kwargs)
|
||||
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)
|
||||
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 +66,16 @@ 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 +99,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):
|
||||
|
||||
@@ -3,7 +3,7 @@ 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):
|
||||
@@ -12,8 +12,7 @@ class InstancesDebugger(SingleInstance):
|
||||
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}")
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
@@ -37,7 +36,7 @@ class InstancesDebugger(SingleInstance):
|
||||
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,7 +2,7 @@ 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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Keyboard(MultipleInstance):
|
||||
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
|
||||
|
||||
|
||||
@@ -37,11 +37,7 @@ class LayoutState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer",
|
||||
f"Toggle {side} layout drawer",
|
||||
self._owner,
|
||||
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):
|
||||
"""
|
||||
@@ -54,11 +50,12 @@ class Commands(BaseCommands):
|
||||
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):
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
|
||||
|
||||
@@ -12,112 +12,17 @@ class Mouse(MultipleInstance):
|
||||
|
||||
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,11 +29,12 @@ 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):
|
||||
@@ -102,38 +47,19 @@ class Panel(MultipleInstance):
|
||||
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 +67,41 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return Div(self._right, id=self._ids.right)
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return Div(self._left, id=self._ids.left)
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
|
||||
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)
|
||||
)
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
|
||||
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")
|
||||
|
||||
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(Div(self._left, id=f"{self._id}_l"), 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,
|
||||
|
||||
@@ -16,38 +16,21 @@ class Properties(MultipleInstance):
|
||||
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(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
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"
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key"),
|
||||
Div(str(v), cls="mf-properties-value", title=str(v)),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in proxy.as_dict().items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
|
||||
@@ -14,12 +14,10 @@ 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):
|
||||
@@ -38,15 +36,13 @@ class Search(MultipleInstance):
|
||||
: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 +61,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 +97,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,22 @@ 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
|
||||
try:
|
||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error while retrieving tab content: {e}")
|
||||
return Div("Tab not found.")
|
||||
|
||||
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 +120,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 +144,73 @@ 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
|
||||
logger.debug(f" Content already exists. Just switch.")
|
||||
return self._mk_tabs_controller()
|
||||
|
||||
def change_tab_content(self, tab_id, label, component, activate=True):
|
||||
def switch_tab(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)
|
||||
return self.show_tab(tab_id) #
|
||||
|
||||
def close_tab(self, tab_id: str):
|
||||
"""
|
||||
@@ -260,12 +220,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 +234,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 +249,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 +259,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 +273,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 +308,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 +325,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 +344,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 +366,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
|
||||
|
||||
@@ -438,29 +396,20 @@ class TabsManager(MultipleInstance):
|
||||
|
||||
# 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_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
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
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# 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
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
@@ -484,7 +433,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:
|
||||
@@ -415,7 +343,7 @@ class TreeView(MultipleInstance):
|
||||
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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
|
||||
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -8,8 +8,6 @@ class DataGridRowState:
|
||||
row_id: int
|
||||
visible: bool = True
|
||||
height: int | None = None
|
||||
format: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGridColumnState:
|
||||
@@ -18,8 +16,8 @@ class DataGridColumnState:
|
||||
title: str = None
|
||||
type: ColumnType = ColumnType.Text
|
||||
visible: bool = True
|
||||
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
|
||||
format: list = field(default_factory=list) #
|
||||
usable: bool = True
|
||||
width: int = DEFAULT_COLUMN_WIDTH
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,7 +28,7 @@ class DatagridEditionState:
|
||||
|
||||
@dataclass
|
||||
class DatagridSelectionState:
|
||||
selected: tuple[int, int] | None = None # column first, then row
|
||||
selected: tuple[int, int] | None = None
|
||||
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))
|
||||
@@ -42,6 +40,8 @@ class DataGridHeaderFooterConf:
|
||||
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridView:
|
||||
name: str
|
||||
|
||||
@@ -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(
|
||||
@@ -59,7 +52,7 @@ class mk:
|
||||
can_hover=False,
|
||||
tooltip=None,
|
||||
cls='',
|
||||
command: Command | CommandTemplate = None,
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
@@ -85,7 +78,6 @@ 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)
|
||||
|
||||
@@ -100,10 +92,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 +109,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 +130,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,14 @@
|
||||
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 +25,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"
|
||||
@@ -208,22 +99,49 @@ class Command:
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = {
|
||||
return {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": self.default_kwargs
|
||||
"values": {}
|
||||
}
|
||||
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) if callback else {}
|
||||
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:
|
||||
@@ -236,59 +154,70 @@ class Command:
|
||||
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
|
||||
|
||||
res = {}
|
||||
for k, v in self._callback_parameters.items():
|
||||
if k in all_args:
|
||||
res[k] = self._cast_parameter(k, all_args[k])
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
return res
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
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,21 +1,13 @@
|
||||
from enum import Enum
|
||||
|
||||
NO_DEFAULT_VALUE = object()
|
||||
DEFAULT_COLUMN_WIDTH = 100
|
||||
|
||||
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):
|
||||
@@ -25,7 +17,7 @@ class ColumnType(Enum):
|
||||
Datetime = "DateTime"
|
||||
Bool = "Boolean"
|
||||
Choice = "Choice"
|
||||
Enum = "Enum"
|
||||
List = "List"
|
||||
|
||||
|
||||
class ViewType(Enum):
|
||||
|
||||
@@ -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 owner.get_full_id()
|
||||
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
|
||||
@@ -144,15 +118,3 @@ class DbObject:
|
||||
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}"
|
||||
|
||||
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,22 +169,16 @@ 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
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
|
||||
@staticmethod
|
||||
def get_by_type(session: dict, cls: type):
|
||||
@@ -209,28 +188,6 @@ class InstancesManager:
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def get_session_id(session):
|
||||
if isinstance(session, str):
|
||||
|
||||
@@ -150,7 +150,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 +161,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 +225,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)
|
||||
|
||||
@@ -43,4 +43,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)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5010)
|
||||
serve(port=5002)
|
||||
|
||||
@@ -11,10 +11,7 @@ def say_hello():
|
||||
|
||||
|
||||
# Create the command
|
||||
hello_command = Command("say_hello",
|
||||
"Responds with a greeting",
|
||||
None,
|
||||
say_hello)
|
||||
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
|
||||
|
||||
# Create the app
|
||||
app, rt = create_app(protect_routes=False)
|
||||
@@ -26,4 +23,4 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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