Compare commits
14 Commits
872d110f07
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| 0620cb678b | |||
| d7ec99c3d9 | |||
| 778e5ac69d | |||
| 9abb9dddfe | |||
| 3083f3b1fd | |||
| 05d4e5cd89 | |||
| e31d9026ce | |||
| 3abfab8e97 | |||
| 7f3e6270a2 | |||
| 0bd56c7f09 | |||
| 3c2c07ebfc | |||
| 06e81fe72a | |||
| ba2b6e672a | |||
| 191ead1c89 |
442
.claude/commands/developer-control.md
Normal file
442
.claude/commands/developer-control.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# 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,6 +237,7 @@ 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 unit testing mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -10,4 +10,6 @@ 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,10 +1,18 @@
|
||||
# Technical Writer Persona
|
||||
# Technical Writer Mode
|
||||
|
||||
You are now acting as a **Technical Writer** specialized in user-facing documentation.
|
||||
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
|
||||
|
||||
## Your Role
|
||||
## 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
|
||||
|
||||
Focus on creating and improving **user documentation** for the MyFastHtml library:
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
@@ -18,47 +26,317 @@ Focus on creating and improving **user documentation** for the MyFastHtml librar
|
||||
- Code comments
|
||||
- CLAUDE.md (handled by developers)
|
||||
|
||||
## Documentation Principles
|
||||
## Technical Writer Rules (TW)
|
||||
|
||||
**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)
|
||||
### TW-1: Standard Documentation Structure
|
||||
|
||||
**Structure:**
|
||||
- Start with the problem being solved
|
||||
- Show minimal working example
|
||||
- Explain key concepts
|
||||
- Provide variations and advanced usage
|
||||
- Link to related documentation
|
||||
Every component documentation MUST follow this structure in order:
|
||||
|
||||
**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)
|
||||
| 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 |
|
||||
|
||||
## Communication Style
|
||||
**Introduction template:**
|
||||
```markdown
|
||||
## Introduction
|
||||
|
||||
**Conversations:** French or English (match user's language)
|
||||
**Written documentation:** English only
|
||||
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
|
||||
|
||||
## Workflow
|
||||
**Key features:**
|
||||
|
||||
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
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
## Style Evolution
|
||||
**Common use cases:**
|
||||
|
||||
The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time.
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
- Use case 3
|
||||
```
|
||||
|
||||
## Exiting This Persona
|
||||
**Quick Start template:**
|
||||
```markdown
|
||||
## Quick Start
|
||||
|
||||
To return to normal mode:
|
||||
- Use `/developer` to switch to developer mode
|
||||
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
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -258,7 +258,7 @@ def test_i_can_render_component_with_no_data(self, component):
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
|
||||
|
||||
---
|
||||
|
||||
@@ -410,8 +410,19 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` or equivalent wraps the icon in a Div → use `TestIcon()`
|
||||
3. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
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
|
||||
@@ -420,25 +431,30 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Example 1: Wrapped icon (typically with mk.icon())
|
||||
# Example 1: Icon via mk.icon() - wrapper is Div (default)
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><NotStr .../></div>
|
||||
# Rendered: <div><svg .../></div>
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: Direct icon (used without helper)
|
||||
# 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><NotStr .../></span>
|
||||
# Rendered: <span><svg .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# Example 3: Verify any wrapped icon
|
||||
# Example 4: Verify any wrapped icon
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
cls=Contains("icon-wrapper")
|
||||
@@ -446,7 +462,10 @@ expected = Div(
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`, try `TestIconNotStr()` and vice-versa. The error message will show you the actual structure.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -467,11 +486,60 @@ expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
|
||||
|
||||
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
|
||||
|
||||
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
# ❌ FAILS - Form() has default enctype that may not exist in actual form
|
||||
form = find_one(details, Form()) # AssertionError: Found 0 elements
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
# ✅ WORKS - Remove the default enctype attribute
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
form = find_one(details, expected_form)
|
||||
```
|
||||
|
||||
**Alternative - Search with specific attribute:**
|
||||
```python
|
||||
# ✅ ALSO WORKS - Search by a known attribute
|
||||
form = find_one(details, Form(cls=Contains("my-form-class")))
|
||||
# But still need to delete enctype if Form() is used as pattern
|
||||
```
|
||||
|
||||
**Complete example:**
|
||||
```python
|
||||
def test_column_details_contains_form(self, component):
|
||||
"""Test that column details contains a form with required fields."""
|
||||
details = component.mk_column_details(col_def)
|
||||
|
||||
# Create Form pattern and remove default enctype
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
|
||||
form = find_one(details, expected_form)
|
||||
assert form is not None
|
||||
|
||||
# Now search within the found form
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
```
|
||||
|
||||
**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Justify the choice of tested elements**
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
@@ -518,7 +586,7 @@ def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.10: Count tests with explicit messages**
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
|
||||
@@ -548,7 +616,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.9)
|
||||
- Justification section explaining why tested elements matter (see UTR-11.10)
|
||||
- List of important elements/attributes being tested with explanations (in English)
|
||||
|
||||
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||
@@ -572,7 +640,7 @@ assert len(resizers) == 1
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 11 UTR-11 sub-rules**
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
@@ -590,10 +658,11 @@ assert len(resizers) == 1
|
||||
- **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.9**: Justify the choice of tested elements
|
||||
- **UTR-11.10**: Explicit messages for `assert len()`
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
---
|
||||
|
||||
@@ -601,7 +670,7 @@ 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.9)
|
||||
- Always include justification documentation (see UTR-11.10)
|
||||
|
||||
---
|
||||
|
||||
@@ -685,6 +754,56 @@ assert matches(element, expected)
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
@@ -700,5 +819,6 @@ 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
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -108,6 +108,17 @@ 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
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -20,10 +20,13 @@ 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: clean-build clean-tests clean-app
|
||||
|
||||
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=5002)
|
||||
serve(port=5010)
|
||||
|
||||
|
||||
```
|
||||
@@ -86,7 +86,7 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
```
|
||||
|
||||
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
|
||||
|
||||
1411
docs/DataGrid Formatting DSL.md
Normal file
1411
docs/DataGrid Formatting DSL.md
Normal file
File diff suppressed because it is too large
Load Diff
540
docs/DataGrid Formatting.md
Normal file
540
docs/DataGrid Formatting.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# 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
Normal file
601
docs/DataGrid.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# 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
Normal file
557
docs/Dropdown.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# 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,12 +176,55 @@ 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,6 +64,70 @@ 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)
|
||||
@@ -150,16 +214,155 @@ The library automatically adds these parameters to every HTMX request:
|
||||
|
||||
## Python Integration
|
||||
|
||||
### Basic Usage
|
||||
### Mouse Class
|
||||
|
||||
The `Mouse` class provides a convenient way to add mouse support to elements.
|
||||
|
||||
```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": json.dumps({"mode": "multi"})
|
||||
"hx-vals": {"mode": "multi"}
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/item/context-menu",
|
||||
@@ -168,41 +371,7 @@ 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)}')"
|
||||
Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')")
|
||||
```
|
||||
|
||||
## Behavior Details
|
||||
|
||||
188
docs/Panel.md
188
docs/Panel.md
@@ -9,6 +9,7 @@ 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
|
||||
@@ -108,10 +109,37 @@ The Panel component consists of three zones with optional side panels:
|
||||
| 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, top right corner |
|
||||
| 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
|
||||
@@ -196,7 +224,7 @@ panel = Panel(parent=root_instance)
|
||||
|
||||
### Panel Configuration
|
||||
|
||||
By default, both left and right panels are enabled. You can customize this with `PanelConf`:
|
||||
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
@@ -218,6 +246,49 @@ 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.
|
||||
|
||||
@@ -321,8 +392,8 @@ You can control panels programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle panel visibility
|
||||
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left
|
||||
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right
|
||||
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")
|
||||
@@ -335,8 +406,8 @@ These commands are typically used with buttons or other interactive elements:
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Add buttons to toggle panels
|
||||
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False))
|
||||
show_left_btn = mk.button("Show Left", command=panel.commands.toggle_side("left", True))
|
||||
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(
|
||||
@@ -364,21 +435,25 @@ panel.set_main(
|
||||
|
||||
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-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 |
|
||||
| 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:**
|
||||
|
||||
@@ -641,13 +716,13 @@ panel.set_right(
|
||||
# Create control buttons
|
||||
toggle_left_btn = mk.button(
|
||||
"Toggle Left Panel",
|
||||
command=panel.commands.toggle_side("left", False),
|
||||
command=panel.commands.set_side_visible("left", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
toggle_right_btn = mk.button(
|
||||
"Toggle Right Panel",
|
||||
command=panel.commands.toggle_side("right", False),
|
||||
command=panel.commands.set_side_visible("right", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
@@ -657,8 +732,8 @@ show_all_btn = mk.button(
|
||||
"show_all",
|
||||
"Show all panels",
|
||||
lambda: (
|
||||
panel.toggle_side("left", True),
|
||||
panel.toggle_side("right", True)
|
||||
panel.toggle_side("left", True),
|
||||
panel.toggle_side("right", True)
|
||||
)
|
||||
),
|
||||
cls="btn btn-sm btn-primary"
|
||||
@@ -668,17 +743,17 @@ show_all_btn = mk.button(
|
||||
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"
|
||||
)
|
||||
)
|
||||
@@ -696,10 +771,16 @@ This section contains technical details for developers working on the Panel comp
|
||||
|
||||
The Panel component uses `PanelConf` dataclass for configuration:
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|----------|---------|----------------------------|---------|
|
||||
| `left` | boolean | Enable/disable left panel | `True` |
|
||||
| `right` | boolean | Enable/disable right panel | `True` |
|
||||
| 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
|
||||
|
||||
@@ -735,10 +816,40 @@ codebase.
|
||||
|
||||
### 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)
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ ├── Div(id="{id}_cl")
|
||||
│ │ └── [Left content]
|
||||
│ └── Div (resizer-left)
|
||||
@@ -749,7 +860,7 @@ Div(id="{id}", cls="mf-panel")
|
||||
│ └── 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)
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ └── Div(id="{id}_cr")
|
||||
│ └── [Right content]
|
||||
└── Script # initResizer('{id}')
|
||||
@@ -757,11 +868,12 @@ Div(id="{id}", cls="mf-panel")
|
||||
|
||||
**Note:**
|
||||
|
||||
- Left panel: hide icon, then content, then resizer (resizer on right edge)
|
||||
- Right panel: resizer, then hide icon, then content (resizer on left edge)
|
||||
- Hide icons are positioned at panel root level (not inside content div)
|
||||
- Main content has an outer wrapper and inner content div with ID
|
||||
- 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
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies = [
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
"lark",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -33,6 +33,7 @@ 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
|
||||
@@ -80,6 +81,7 @@ 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
|
||||
|
||||
17
src/app.py
17
src/app.py
@@ -17,6 +17,7 @@ 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
|
||||
@@ -38,22 +39,6 @@ app, rt = create_app(protect_routes=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
|
||||
14
src/myfasthtml/assets/Readme.md
Normal file
14
src/myfasthtml/assets/Readme.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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
Normal file
1
src/myfasthtml/assets/codemirror.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/lint.min.css
vendored
Normal file
1
src/myfasthtml/assets/lint.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.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
Normal file
1
src/myfasthtml/assets/lint.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!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)})});
|
||||
@@ -2,6 +2,8 @@
|
||||
--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;
|
||||
@@ -24,21 +26,18 @@
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
@@ -46,14 +45,12 @@
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-32 {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -62,6 +59,16 @@
|
||||
* 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;
|
||||
@@ -462,13 +469,12 @@
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 200px;
|
||||
/*max-height: 400px;*/
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-dropdown-wrapper {
|
||||
position: relative; /* CRUCIAL for the anchor */
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
@@ -476,15 +482,19 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0px;
|
||||
z-index: 1;
|
||||
width: 200px;
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
/*opacity: 0;*/
|
||||
/*transition: opacity 0.2s ease-in-out;*/
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
@@ -492,6 +502,36 @@
|
||||
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 ************* */
|
||||
/* *********************************************** */
|
||||
@@ -739,6 +779,38 @@
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panel with title - grid layout for header + scrollable content */
|
||||
.mf-panel-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Override absolute positioning for hide icon when inside header */
|
||||
.mf-panel-header .mf-panel-hide-icon {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.mf-panel-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Remove padding-top when using title layout */
|
||||
.mf-panel-left.mf-panel-with-title,
|
||||
.mf-panel-right.mf-panel-with-title {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
@@ -851,7 +923,7 @@
|
||||
.dt2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Cell */
|
||||
@@ -928,6 +1000,34 @@
|
||||
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 ******** */
|
||||
/* *********************************************** */
|
||||
@@ -1071,3 +1171,19 @@
|
||||
.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
Normal file
1
src/myfasthtml/assets/placeholder.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!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
Normal file
1
src/myfasthtml/assets/show-hint.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.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
Normal file
1
src/myfasthtml/assets/show-hint.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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.network_utils import from_parent_child_list
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
|
||||
52
src/myfasthtml/controls/CycleStateControl.py
Normal file
52
src/myfasthtml/controls/CycleStateControl.py
Normal file
@@ -0,0 +1,52 @@
|
||||
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,24 +1,38 @@
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.common import NotStr
|
||||
from fasthtml.components import *
|
||||
from pandas import DataFrame
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
||||
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
||||
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
||||
from myfasthtml.controls.DslEditor import DslEditorConf
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
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
|
||||
@@ -39,10 +53,17 @@ def _mk_bool_cached(_value):
|
||||
))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridConf:
|
||||
namespace: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
|
||||
|
||||
class DatagridState(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
with self.initializing():
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}#state", save_state=save_state)
|
||||
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
|
||||
@@ -54,17 +75,21 @@ 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):
|
||||
def __init__(self, owner, save_state, name, namespace):
|
||||
with self.initializing():
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state)
|
||||
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
|
||||
@@ -94,27 +119,189 @@ class Commands(BaseCommands):
|
||||
self._owner,
|
||||
self._owner.set_column_width
|
||||
).htmx(target=None)
|
||||
|
||||
|
||||
def move_column(self):
|
||||
return Command("MoveColumn",
|
||||
"Move column to new position",
|
||||
self._owner,
|
||||
self._owner.move_column
|
||||
).htmx(target=None)
|
||||
|
||||
def filter(self):
|
||||
return Command("Filter",
|
||||
"Filter Grid",
|
||||
self._owner,
|
||||
self._owner.filter
|
||||
)
|
||||
|
||||
def change_selection_mode(self):
|
||||
return Command("ChangeSelectionMode",
|
||||
"Change selection mode",
|
||||
self._owner,
|
||||
self._owner.change_selection_mode
|
||||
)
|
||||
|
||||
def on_click(self):
|
||||
return Command("OnClick",
|
||||
"Click on the table",
|
||||
self._owner,
|
||||
self._owner.on_click
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def toggle_columns_manager(self):
|
||||
return Command("ToggleColumnsManager",
|
||||
"Hide/Show Columns Manager",
|
||||
self._owner,
|
||||
self._owner.toggle_columns_manager
|
||||
).htmx(target=None)
|
||||
|
||||
def toggle_formatting_editor(self):
|
||||
return Command("ToggleFormattingEditor",
|
||||
"Hide/Show Formatting Editor",
|
||||
self._owner,
|
||||
self._owner.toggle_formatting_editor
|
||||
).htmx(target=None)
|
||||
|
||||
def on_column_changed(self):
|
||||
return Command("OnColumnChanged",
|
||||
"Column definition changed",
|
||||
self._owner,
|
||||
self._owner.on_column_changed
|
||||
)
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
||||
def __init__(self, parent, conf=None, save_state=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._settings = settings or DatagridSettings(self, save_state=save_state)
|
||||
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
|
||||
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
|
||||
self._state = DatagridState(self, save_state=self._settings.save_state)
|
||||
self._formatting_engine = FormattingEngine()
|
||||
self.commands = Commands(self)
|
||||
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
||||
|
||||
# add Panel
|
||||
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="#panel")
|
||||
self._panel.set_side_visible("right", False) # the right Panel always starts closed
|
||||
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
||||
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
||||
|
||||
# add DataGridQuery
|
||||
self._datagrid_filter = DataGridQuery(self)
|
||||
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
||||
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
|
||||
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
|
||||
# add Selection Selector
|
||||
selection_types = {
|
||||
"row": mk.icon(row, tooltip="Row selection"),
|
||||
"column": mk.icon(column, tooltip="Column selection"),
|
||||
"cell": mk.icon(grid, tooltip="Cell selection")
|
||||
}
|
||||
self._selection_mode_selector = CycleStateControl(self, controls=selection_types, save_state=False)
|
||||
self._selection_mode_selector.bind_command("CycleState", self.commands.change_selection_mode())
|
||||
|
||||
# add columns manager
|
||||
self._columns_manager = DataGridColumnsManager(self)
|
||||
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
|
||||
|
||||
editor_conf = DslEditorConf()
|
||||
self._formatting_editor = DataGridFormattingEditor(self,
|
||||
conf=editor_conf,
|
||||
dsl=FormattingDSL(),
|
||||
save_state=self._settings.save_state,
|
||||
_id="#formatting_editor")
|
||||
|
||||
# other definitions
|
||||
self._mouse_support = {
|
||||
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
}
|
||||
|
||||
logger.debug(f"DataGrid '{self._get_full_name()}' with id='{self._id}' created.")
|
||||
|
||||
@property
|
||||
def _df(self):
|
||||
return self._state.ne_df
|
||||
|
||||
def _apply_sort(self, df):
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
sorted_columns = []
|
||||
sorted_asc = []
|
||||
for sort_def in self._state.sorted:
|
||||
if sort_def.direction != 0:
|
||||
sorted_columns.append(sort_def.column_id)
|
||||
asc = sort_def.direction == 1
|
||||
sorted_asc.append(asc)
|
||||
|
||||
if sorted_columns:
|
||||
df = df.sort_values(by=sorted_columns, ascending=sorted_asc)
|
||||
|
||||
return df
|
||||
|
||||
def _apply_filter(self, df):
|
||||
if df is None:
|
||||
return None
|
||||
|
||||
for col_id, values in self._state.filtered.items():
|
||||
if col_id == FILTER_INPUT_CID:
|
||||
if values is not None:
|
||||
if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER:
|
||||
visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns]
|
||||
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
|
||||
else:
|
||||
pass # we return all the row (but we will keep the highlight)
|
||||
|
||||
else:
|
||||
df = df[df[col_id].astype(str).isin(values)]
|
||||
return df
|
||||
|
||||
def _get_filtered_df(self):
|
||||
if self._df is None:
|
||||
return DataFrame()
|
||||
|
||||
df = self._df.copy()
|
||||
df = self._apply_sort(df) # need to keep the real type to sort
|
||||
df = self._apply_filter(df)
|
||||
self._state.ns_total_rows = len(df)
|
||||
|
||||
return df
|
||||
|
||||
def _get_element_id_from_pos(self, selection_mode, pos):
|
||||
# pos => (column, row)
|
||||
|
||||
if pos is None or pos == (None, None):
|
||||
return None
|
||||
elif selection_mode == "row":
|
||||
return f"trow_{self._id}-{pos[1]}"
|
||||
elif selection_mode == "column":
|
||||
return f"tcol_{self._id}-{pos[0]}"
|
||||
else:
|
||||
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
|
||||
|
||||
def _get_pos_from_element_id(self, element_id):
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
if element_id.startswith("tcell_"):
|
||||
parts = element_id.split("-")
|
||||
return int(parts[-2]), int(parts[-1])
|
||||
|
||||
return None
|
||||
|
||||
def _update_current_position(self, pos):
|
||||
self._state.selection.last_selected = self._state.selection.selected
|
||||
self._state.selection.selected = pos
|
||||
self._state.save()
|
||||
|
||||
def _get_full_name(self):
|
||||
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
|
||||
|
||||
def init_from_dataframe(self, df, init_state=True):
|
||||
|
||||
def _get_column_type(dtype):
|
||||
@@ -163,6 +350,24 @@ class DataGrid(MultipleInstance):
|
||||
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:
|
||||
@@ -170,10 +375,55 @@ class DataGrid(MultipleInstance):
|
||||
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=}")
|
||||
@@ -181,13 +431,13 @@ class DataGrid(MultipleInstance):
|
||||
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
|
||||
@@ -196,14 +446,14 @@ class DataGrid(MultipleInstance):
|
||||
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
|
||||
@@ -211,20 +461,69 @@ class DataGrid(MultipleInstance):
|
||||
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, name="dt2-header-title"),
|
||||
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),
|
||||
@@ -233,8 +532,8 @@ class DataGrid(MultipleInstance):
|
||||
data_tooltip=col_def.title,
|
||||
cls="dt2-cell dt2-resizable flex",
|
||||
)
|
||||
|
||||
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
|
||||
|
||||
header_class = "dt2-row dt2-header"
|
||||
return Div(
|
||||
*[_mk_header(col_def) for col_def in self._state.columns],
|
||||
cls=header_class,
|
||||
@@ -244,34 +543,35 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
|
||||
"""
|
||||
OPTIMIZED: Generate cell content with minimal object creation.
|
||||
- Uses plain strings instead of Label objects when possible
|
||||
- Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups
|
||||
- Avoids html.escape when not necessary
|
||||
- Uses cached boolean HTML (_mk_bool_cached)
|
||||
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):
|
||||
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:
|
||||
# OPTIMIZATION: Return plain HTML string instead of Label object
|
||||
# Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size)
|
||||
return NotStr(f'<span class="{css_class} truncate">{value_str}</span>')
|
||||
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">{value_str}</span>')
|
||||
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
|
||||
|
||||
# Has highlighting - need to use Span objects
|
||||
# Add "truncate text-sm" to match mk.label() behavior
|
||||
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=f"{css_class} dt2-highlight-1"))
|
||||
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") if len(res) > 1 else res[0]
|
||||
|
||||
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]
|
||||
@@ -284,8 +584,16 @@ class DataGrid(MultipleInstance):
|
||||
if column_type == ColumnType.RowIndex:
|
||||
return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>')
|
||||
|
||||
# Convert value to string
|
||||
value_str = str(value)
|
||||
# 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):
|
||||
@@ -293,20 +601,17 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
# Number or Text type
|
||||
if column_type == ColumnType.Number:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-number")
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-number", css_string)
|
||||
else:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-text")
|
||||
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.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return OptimizedDiv(cls="dt2-col-hidden")
|
||||
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)
|
||||
@@ -315,6 +620,7 @@ class DataGrid(MultipleInstance):
|
||||
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):
|
||||
@@ -322,7 +628,7 @@ class DataGrid(MultipleInstance):
|
||||
OPTIMIZED: Extract filter keyword once instead of 10,000 times.
|
||||
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
|
||||
"""
|
||||
df = self._df # self._get_filtered_df()
|
||||
df = self._get_filtered_df()
|
||||
start = page_index * DATAGRID_PAGE_SIZE
|
||||
end = start + DATAGRID_PAGE_SIZE
|
||||
if self._state.ns_total_rows > end:
|
||||
@@ -344,6 +650,13 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
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),
|
||||
@@ -363,29 +676,12 @@ class DataGrid(MultipleInstance):
|
||||
id=f"tf_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table(self):
|
||||
def mk_table_wrapper(self):
|
||||
return Div(
|
||||
# Grid table with header, body, footer
|
||||
Div(
|
||||
# Header container - no scroll
|
||||
Div(
|
||||
self.mk_headers(),
|
||||
cls="dt2-header-container"
|
||||
),
|
||||
# Body container - scroll via JS, scrollbars hidden
|
||||
Div(
|
||||
self.mk_body(),
|
||||
cls="dt2-body-container",
|
||||
id=f"tb_{self._id}"
|
||||
),
|
||||
# Footer container - no scroll
|
||||
Div(
|
||||
self.mk_footers(),
|
||||
cls="dt2-footer-container"
|
||||
),
|
||||
cls="dt2-table",
|
||||
id=f"t_{self._id}"
|
||||
),
|
||||
self.mk_selection_manager(),
|
||||
|
||||
self.mk_table(),
|
||||
|
||||
# Custom scrollbars overlaid
|
||||
Div(
|
||||
# Vertical scrollbar wrapper (right side)
|
||||
@@ -404,6 +700,45 @@ class DataGrid(MultipleInstance):
|
||||
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,
|
||||
@@ -435,9 +770,6 @@ class DataGrid(MultipleInstance):
|
||||
appropriate default content or styling is applied.
|
||||
:rtype: Div | None
|
||||
"""
|
||||
if not col_def.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
@@ -470,14 +802,64 @@ class DataGrid(MultipleInstance):
|
||||
if self._state.ne_df is None:
|
||||
return Div("No data to display !")
|
||||
|
||||
from myfasthtml.controls.DataGridFilter import DataGridFilter
|
||||
return Div(
|
||||
Div(DataGridFilter(self), cls="mb-2"),
|
||||
self.mk_table(),
|
||||
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,
|
||||
style="height: 100%;"
|
||||
cls="grid",
|
||||
style="height: 100%; grid-template-rows: auto 1fr;"
|
||||
)
|
||||
|
||||
def render_partial(self, fragment="cell"):
|
||||
"""
|
||||
|
||||
:param fragment: cell | body
|
||||
:param redraw_scrollbars:
|
||||
:return:
|
||||
"""
|
||||
res = []
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initDataGridScrollbars('{self._id}');",
|
||||
}
|
||||
|
||||
if fragment == "body":
|
||||
body_container = self.mk_body_wrapper()
|
||||
body_container.attrs.update(extra_attr)
|
||||
res.append(body_container)
|
||||
|
||||
elif fragment == "table":
|
||||
table = self.mk_table()
|
||||
table.attrs.update(extra_attr)
|
||||
res.append(table)
|
||||
|
||||
res.append(self.mk_selection_manager())
|
||||
|
||||
return tuple(res)
|
||||
|
||||
def dispose(self):
|
||||
pass
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
remove DBEngine entries
|
||||
:return:
|
||||
"""
|
||||
# self._state.delete()
|
||||
# self._settings.delete()
|
||||
pass
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
196
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
196
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
@@ -0,0 +1,196 @@
|
||||
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,76 +0,0 @@
|
||||
import logging
|
||||
|
||||
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")
|
||||
|
||||
filter_type = {
|
||||
"filter": filter20_regular,
|
||||
"search": search20_regular,
|
||||
"ai": brain_circuit20_regular
|
||||
}
|
||||
|
||||
|
||||
class DataGridFilterState(DbObject):
|
||||
def __init__(self, owner):
|
||||
with self.initializing():
|
||||
super().__init__(owner)
|
||||
self.filter_type: str = "filter"
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def change_filter_type(self):
|
||||
return Command("ChangeFilterType",
|
||||
"Change filter type",
|
||||
self._owner,
|
||||
self._owner.change_filter_type).htmx(target=f"#{self._id}")
|
||||
|
||||
def on_filter_changed(self):
|
||||
return Command("FilterChanged",
|
||||
"Filter changed",
|
||||
self._owner,
|
||||
self._owner.filter_changed).htmx(target=None)
|
||||
|
||||
|
||||
class DataGridFilter(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id or "-filter")
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridFilterState(self)
|
||||
|
||||
def change_filter_type(self):
|
||||
keys = list(filter_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 filter_changed(self, f):
|
||||
logger.debug(f"filter_changed {f=}")
|
||||
return self
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.label(
|
||||
Input(name="f",
|
||||
placeholder="Search...",
|
||||
**self.commands.on_filter_changed().get_htmx_params(escaped=True)),
|
||||
icon=mk.icon(filter_type[self._state.filter_type], command=self.commands.change_filter_type()),
|
||||
cls="input input-sm flex gap-2"
|
||||
),
|
||||
mk.icon(dismiss_circle20_regular, size=24),
|
||||
# Keyboard(self, _id="-keyboard").add("enter", self.commands.on_filter_changed()),
|
||||
cls="flex",
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
7
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
7
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from myfasthtml.controls.DslEditor import DslEditor
|
||||
|
||||
|
||||
class DataGridFormattingEditor(DslEditor):
|
||||
|
||||
def on_dsl_change(self, dsl):
|
||||
pass
|
||||
97
src/myfasthtml/controls/DataGridQuery.py
Normal file
97
src/myfasthtml/controls/DataGridQuery.py
Normal file
@@ -0,0 +1,97 @@
|
||||
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()
|
||||
@@ -6,15 +6,21 @@ import pandas as pd
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
||||
from myfasthtml.core.dsls import DslsManager
|
||||
from myfasthtml.core.formatting.dsl.completion.engine import FormattingCompletionEngine
|
||||
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
|
||||
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
|
||||
@@ -71,7 +77,8 @@ class Commands(BaseCommands):
|
||||
key="SelectNode")
|
||||
|
||||
|
||||
class DataGridsManager(MultipleInstance):
|
||||
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
if not getattr(self, "_is_new_instance", False):
|
||||
# Skip __init__ if instance already existed
|
||||
@@ -82,6 +89,16 @@ class DataGridsManager(MultipleInstance):
|
||||
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)
|
||||
@@ -92,12 +109,16 @@ class DataGridsManager(MultipleInstance):
|
||||
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())
|
||||
dg = DataGrid(self._tabs_manager, save_state=True) # first time the Datagrid is created
|
||||
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=file_upload.get_file_basename(),
|
||||
name=file_upload.get_sheet_name(),
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=dg.get_id()
|
||||
@@ -106,7 +127,7 @@ class DataGridsManager(MultipleInstance):
|
||||
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, Panel(self).set_main(dg))
|
||||
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)
|
||||
@@ -137,15 +158,75 @@ class DataGridsManager(MultipleInstance):
|
||||
|
||||
# Recreate the DataGrid with its saved state
|
||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
|
||||
|
||||
# Wrap in Panel
|
||||
return Panel(self).set_main(dg)
|
||||
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()),
|
||||
|
||||
@@ -29,18 +29,43 @@ class DropdownState:
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
"""
|
||||
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.
|
||||
Interactive dropdown component that toggles open/closed on button click.
|
||||
|
||||
Provides automatic close behavior when clicking outside or pressing ESC.
|
||||
Supports configurable positioning relative to the trigger button.
|
||||
|
||||
Args:
|
||||
parent: Parent instance (required).
|
||||
content: Content to display in the dropdown panel.
|
||||
button: Trigger element that toggles the dropdown.
|
||||
_id: Custom ID for the instance.
|
||||
position: Vertical position relative to button.
|
||||
- "below" (default): Dropdown appears below the button.
|
||||
- "above": Dropdown appears above the button.
|
||||
align: Horizontal alignment relative to button.
|
||||
- "left" (default): Aligns to the left edge of the button.
|
||||
- "right": Aligns to the right edge of the button.
|
||||
- "center": Centers relative to the button.
|
||||
|
||||
Example:
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu"),
|
||||
content=Ul(Li("Option 1"), Li("Option 2")),
|
||||
position="below",
|
||||
align="right"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
|
||||
def __init__(self, parent, content=None, button=None, _id=None,
|
||||
position="below", align="left"):
|
||||
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
|
||||
@@ -50,57 +75,32 @@ class Dropdown(MultipleInstance):
|
||||
self._state.opened = False
|
||||
return self._mk_content()
|
||||
|
||||
def on_click(self, combination, is_inside: bool):
|
||||
def on_click(self, combination, is_inside: bool, is_button: bool = False):
|
||||
if combination == "click":
|
||||
self._state.opened = is_inside
|
||||
if is_button:
|
||||
self._state.opened = not self._state.opened
|
||||
else:
|
||||
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 {'is-visible' if self._state.opened else ''}",
|
||||
cls=f"mf-dropdown {position_cls} {align_cls} {'is-visible' if self._state.opened else ''}",
|
||||
id=f"{self._id}-content"),
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
Div(
|
||||
Div(self.button) if self.button else Div("None"),
|
||||
Div(self.button if self.button else "None", cls="mf-dropdown-btn"),
|
||||
self._mk_content(),
|
||||
cls="mf-dropdown-wrapper"
|
||||
),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||
Mouse(self, "-mouse").add("click", self.commands.click(), hx_vals="js:getDropdownExtra()"),
|
||||
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';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
|
||||
203
src/myfasthtml/controls/DslEditor.py
Normal file
203
src/myfasthtml/controls/DslEditor.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
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()
|
||||
@@ -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.network_utils import from_parent_child_list
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
|
||||
@@ -12,17 +12,112 @@ 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):
|
||||
self.combinations[sequence] = command
|
||||
|
||||
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,
|
||||
}
|
||||
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: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||
str_combinations = {
|
||||
sequence: self._build_htmx_params(data)
|
||||
for sequence, data in self.combinations.items()
|
||||
}
|
||||
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
@@ -42,6 +42,12 @@ class PanelIds:
|
||||
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):
|
||||
@@ -55,12 +61,19 @@ class PanelState(DbObject):
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_side(self, side: Literal["left", "right"], visible: bool = None):
|
||||
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, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
@@ -89,7 +102,7 @@ class Panel(MultipleInstance):
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
@@ -110,7 +123,7 @@ class Panel(MultipleInstance):
|
||||
|
||||
return self._mk_panel(side)
|
||||
|
||||
def toggle_side(self, side, visible):
|
||||
def set_side_visible(self, side, visible):
|
||||
if side == "left":
|
||||
self._state.left_visible = visible
|
||||
else:
|
||||
@@ -118,6 +131,10 @@ class Panel(MultipleInstance):
|
||||
|
||||
return self._mk_panel(side), self._mk_show_icon(side)
|
||||
|
||||
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)
|
||||
|
||||
def set_main(self, main):
|
||||
self._main = main
|
||||
return self
|
||||
@@ -130,6 +147,14 @@ class Panel(MultipleInstance):
|
||||
self._left = left
|
||||
return Div(self._left, id=self._ids.left)
|
||||
|
||||
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:
|
||||
@@ -137,6 +162,8 @@ class Panel(MultipleInstance):
|
||||
|
||||
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}",
|
||||
@@ -147,32 +174,64 @@ class Panel(MultipleInstance):
|
||||
hide_icon = mk.icon(
|
||||
subtract20_regular,
|
||||
size=20,
|
||||
command=self.commands.toggle_side(side, False),
|
||||
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 side == "left":
|
||||
return Div(
|
||||
if show_title:
|
||||
header = Div(
|
||||
Div(title),
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
id=self._ids.panel(side)
|
||||
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:
|
||||
return Div(
|
||||
resizer,
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
if side == "left":
|
||||
return Div(
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
def _mk_main(self):
|
||||
return Div(
|
||||
@@ -196,12 +255,16 @@ class Panel(MultipleInstance):
|
||||
if not enabled:
|
||||
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.toggle_side(side, True),
|
||||
command=self.commands.set_side_visible(side, True),
|
||||
cls=icon_cls,
|
||||
id=f"{self._id}_show_{side}"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,8 @@ class Search(MultipleInstance):
|
||||
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 ?
|
||||
template: Callable[[Any], Any] = None, # once filtered, what to render ?
|
||||
max_height: int = 400):
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items based on specific criteria.
|
||||
|
||||
@@ -64,8 +65,9 @@ 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 Div
|
||||
self.template = template or (lambda x: Div(self.get_attr(x)))
|
||||
self.commands = Commands(self)
|
||||
self.max_height = max_height
|
||||
|
||||
def set_items(self, items):
|
||||
self.items = items
|
||||
@@ -106,6 +108,7 @@ 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}",
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ class DataGridRowState:
|
||||
row_id: int
|
||||
visible: bool = True
|
||||
height: int | None = None
|
||||
format: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataGridColumnState:
|
||||
@@ -16,8 +18,8 @@ class DataGridColumnState:
|
||||
title: str = None
|
||||
type: ColumnType = ColumnType.Text
|
||||
visible: bool = True
|
||||
usable: bool = True
|
||||
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
|
||||
format: list = field(default_factory=list) #
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,7 +30,7 @@ class DatagridEditionState:
|
||||
|
||||
@dataclass
|
||||
class DatagridSelectionState:
|
||||
selected: tuple[int, int] | None = None
|
||||
selected: tuple[int, int] | None = None # column first, then row
|
||||
last_selected: tuple[int, int] | None = None
|
||||
selection_mode: str = None # valid values are "row", "column" or None for "cell"
|
||||
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
|
||||
@@ -40,8 +42,6 @@ class DataGridHeaderFooterConf:
|
||||
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridView:
|
||||
name: str
|
||||
|
||||
@@ -2,7 +2,14 @@ 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.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:
|
||||
@@ -78,6 +85,7 @@ 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)
|
||||
|
||||
@@ -95,7 +103,7 @@ class mk:
|
||||
command: Command | CommandTemplate = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex truncate", cls, kwargs)
|
||||
merged_cls = merge_classes("flex truncate items-center", "mf-button" if command else None, 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)
|
||||
@@ -137,3 +145,19 @@ class mk:
|
||||
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,
|
||||
}
|
||||
|
||||
82
src/myfasthtml/core/DataGridsRegistry.py
Normal file
82
src/myfasthtml/core/DataGridsRegistry.py
Normal file
@@ -0,0 +1,82 @@
|
||||
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()}
|
||||
@@ -14,6 +14,7 @@ logger = logging.getLogger("Commands")
|
||||
|
||||
AUTO_SWAP_OOB = "__auto_swap_oob__"
|
||||
|
||||
|
||||
class Command:
|
||||
"""
|
||||
Represents the base command class for defining executable actions.
|
||||
@@ -99,7 +100,7 @@ class Command:
|
||||
def get_key(self):
|
||||
return self._key
|
||||
|
||||
def get_htmx_params(self, escaped=False):
|
||||
def get_htmx_params(self, escaped=False, values_encode=None):
|
||||
res = {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-swap": "outerHTML",
|
||||
@@ -120,6 +121,9 @@ class Command:
|
||||
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
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
|
||||
@@ -14,6 +14,8 @@ FILTER_INPUT_CID = "__filter_input__"
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
Bindings = "/bindings"
|
||||
Completions = "/completions"
|
||||
Validations = "/validations"
|
||||
|
||||
|
||||
class ColumnType(Enum):
|
||||
@@ -23,7 +25,7 @@ class ColumnType(Enum):
|
||||
Datetime = "DateTime"
|
||||
Bool = "Boolean"
|
||||
Choice = "Choice"
|
||||
List = "List"
|
||||
Enum = "Enum"
|
||||
|
||||
|
||||
class ViewType(Enum):
|
||||
|
||||
19
src/myfasthtml/core/dbengine_utils.py
Normal file
19
src/myfasthtml/core/dbengine_utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
@@ -14,7 +14,8 @@ class DbManager(SingleInstance):
|
||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(parent, auto_register=auto_register)
|
||||
|
||||
self.db = DbEngine(root=root)
|
||||
if not hasattr(self, "db"): # hack to manage singleton inheritance
|
||||
self.db = DbEngine(root=root)
|
||||
|
||||
def save(self, entry, obj):
|
||||
self.db.save(self.get_tenant(), self.get_user(), entry, obj)
|
||||
@@ -42,7 +43,9 @@ class DbObject:
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
|
||||
self._owner = owner
|
||||
self._name = name or owner.get_full_id()
|
||||
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._db_manager = db_manager or DbManager(self._owner)
|
||||
self._save_state = save_state
|
||||
|
||||
|
||||
0
src/myfasthtml/core/dsl/__init__.py
Normal file
0
src/myfasthtml/core/dsl/__init__.py
Normal file
88
src/myfasthtml/core/dsl/base.py
Normal file
88
src/myfasthtml/core/dsl/base.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
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)
|
||||
172
src/myfasthtml/core/dsl/base_completion.py
Normal file
172
src/myfasthtml/core/dsl/base_completion.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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__
|
||||
38
src/myfasthtml/core/dsl/base_provider.py
Normal file
38
src/myfasthtml/core/dsl/base_provider.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
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"])
|
||||
"""
|
||||
...
|
||||
256
src/myfasthtml/core/dsl/lark_to_lezer.py
Normal file
256
src/myfasthtml/core/dsl/lark_to_lezer.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
103
src/myfasthtml/core/dsl/types.py
Normal file
103
src/myfasthtml/core/dsl/types.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
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 = ""
|
||||
226
src/myfasthtml/core/dsl/utils.py
Normal file
226
src/myfasthtml/core/dsl/utils.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
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
|
||||
31
src/myfasthtml/core/dsls.py
Normal file
31
src/myfasthtml/core/dsls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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
src/myfasthtml/core/formatting/__init__.py
Normal file
1
src/myfasthtml/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Formatting module for DataGrid
|
||||
200
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
200
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
168
src/myfasthtml/core/formatting/dataclasses.py
Normal file
168
src/myfasthtml/core/formatting/dataclasses.py
Normal file
@@ -0,0 +1,168 @@
|
||||
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
|
||||
69
src/myfasthtml/core/formatting/dsl/__init__.py
Normal file
69
src/myfasthtml/core/formatting/dsl/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
323
src/myfasthtml/core/formatting/dsl/completion/contexts.py
Normal file
323
src/myfasthtml/core/formatting/dsl/completion/contexts.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
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
|
||||
109
src/myfasthtml/core/formatting/dsl/completion/engine.py
Normal file
109
src/myfasthtml/core/formatting/dsl/completion/engine.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
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)
|
||||
245
src/myfasthtml/core/formatting/dsl/completion/presets.py
Normal file
245
src/myfasthtml/core/formatting/dsl/completion/presets.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
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"),
|
||||
]
|
||||
94
src/myfasthtml/core/formatting/dsl/completion/provider.py
Normal file
94
src/myfasthtml/core/formatting/dsl/completion/provider.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
...
|
||||
311
src/myfasthtml/core/formatting/dsl/completion/suggestions.py
Normal file
311
src/myfasthtml/core/formatting/dsl/completion/suggestions.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
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 []
|
||||
23
src/myfasthtml/core/formatting/dsl/definition.py
Normal file
23
src/myfasthtml/core/formatting/dsl/definition.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
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
|
||||
55
src/myfasthtml/core/formatting/dsl/exceptions.py
Normal file
55
src/myfasthtml/core/formatting/dsl/exceptions.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
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 ""))
|
||||
159
src/myfasthtml/core/formatting/dsl/grammar.py
Normal file
159
src/myfasthtml/core/formatting/dsl/grammar.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
111
src/myfasthtml/core/formatting/dsl/parser.py
Normal file
111
src/myfasthtml/core/formatting/dsl/parser.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
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
|
||||
47
src/myfasthtml/core/formatting/dsl/scopes.py
Normal file
47
src/myfasthtml/core/formatting/dsl/scopes.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
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
|
||||
430
src/myfasthtml/core/formatting/dsl/transformer.py
Normal file
430
src/myfasthtml/core/formatting/dsl/transformer.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
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
|
||||
152
src/myfasthtml/core/formatting/engine.py
Normal file
152
src/myfasthtml/core/formatting/engine.py
Normal file
@@ -0,0 +1,152 @@
|
||||
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]
|
||||
350
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
350
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
@@ -0,0 +1,350 @@
|
||||
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
|
||||
76
src/myfasthtml/core/formatting/presets.py
Normal file
76
src/myfasthtml/core/formatting/presets.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# === 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",
|
||||
},
|
||||
}
|
||||
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
@@ -0,0 +1,75 @@
|
||||
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()) + ";"
|
||||
@@ -116,8 +116,8 @@ class BaseInstance:
|
||||
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||
return _id
|
||||
|
||||
if _id.startswith("-") and parent is not None:
|
||||
return f"{parent.get_prefix()}{_id}"
|
||||
if _id.startswith(("-", "#")) and parent is not None:
|
||||
return f"{parent.get_id()}{_id}"
|
||||
|
||||
return _id
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ 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
|
||||
|
||||
@@ -46,6 +48,8 @@ class 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=}")
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ 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()
|
||||
@@ -311,6 +314,7 @@ def make_html_id(s: str | None) -> str | None:
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def make_safe_id(s: str | None):
|
||||
if s is None:
|
||||
return None
|
||||
@@ -341,6 +345,7 @@ def get_class(qualified_class_name: str):
|
||||
|
||||
return getattr(module, class_name)
|
||||
|
||||
|
||||
@utils_rt(Routes.Commands)
|
||||
def post(session, c_id: str, client_response: dict = None):
|
||||
"""
|
||||
@@ -378,3 +383,44 @@ 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=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -69,4 +69,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -44,4 +44,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -37,4 +37,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -43,4 +43,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -44,4 +44,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -30,4 +30,4 @@ def get():
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_routes(app)
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -26,4 +26,4 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -25,4 +25,4 @@ def index():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
15
src/myfasthtml/examples/formatter_config.py
Normal file
15
src/myfasthtml/examples/formatter_config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app(protect_routes=False, live=True)
|
||||
|
||||
|
||||
@rt("/")
|
||||
def get_homepage():
|
||||
return Div("Hello, FastHtml my!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5010)
|
||||
@@ -12,4 +12,4 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
@@ -33,6 +33,7 @@ def get_asset_content(filename):
|
||||
|
||||
def create_app(daisyui: Optional[bool] = True,
|
||||
vis: Optional[bool] = True,
|
||||
code_mirror: Optional[bool] = True,
|
||||
protect_routes: Optional[bool] = True,
|
||||
mount_auth_app: Optional[bool] = False,
|
||||
base_url: Optional[str] = None,
|
||||
@@ -41,8 +42,15 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
Creates and configures a FastHtml application with optional support for daisyUI themes and
|
||||
authentication routes.
|
||||
|
||||
:param daisyui: Flag to enable or disable inclusion of daisyUI-related assets for styling.
|
||||
Defaults to False.
|
||||
:param daisyui: Flag to enable or disable inclusion of daisyUI (https://daisyui.com/).
|
||||
Defaults to True.
|
||||
|
||||
:param vis: Flag to enable or disable inclusion of Vis network (https://visjs.org/)
|
||||
Defaults to True.
|
||||
|
||||
:param code_mirror: Flag to enable or disable inclusion of Code Mirror (https://codemirror.net/)
|
||||
Defaults to True.
|
||||
|
||||
:param protect_routes: Flag to enable or disable routes protection based on authentication.
|
||||
Defaults to True.
|
||||
:param mount_auth_app: Flag to enable or disable mounting of authentication routes.
|
||||
@@ -70,6 +78,20 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
Script(src="/myfasthtml/vis-network.min.js"),
|
||||
]
|
||||
|
||||
if code_mirror:
|
||||
hdrs += [
|
||||
Script(src="/myfasthtml/codemirror.min.js"),
|
||||
Link(href="/myfasthtml/codemirror.min.css", rel="stylesheet", type="text/css"),
|
||||
|
||||
Script(src="/myfasthtml/placeholder.min.js"),
|
||||
|
||||
Script(src="/myfasthtml/show-hint.min.js"),
|
||||
Link(href="/myfasthtml/show-hint.min.css", rel="stylesheet", type="text/css"),
|
||||
|
||||
Script(src="/myfasthtml/lint.min.js"),
|
||||
Link(href="/myfasthtml/lint.min.css", rel="stylesheet", type="text/css"),
|
||||
]
|
||||
|
||||
beforeware = create_auth_beforeware() if protect_routes else None
|
||||
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
||||
|
||||
@@ -80,13 +102,14 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
# Serve assets
|
||||
@app.get("/myfasthtml/{filename:path}.{ext:static}")
|
||||
def serve_assets(filename: str, ext: str):
|
||||
logger.debug(f"Serving asset: {filename=}, {ext=}")
|
||||
path = filename + "." + ext
|
||||
try:
|
||||
content = get_asset_content(path)
|
||||
|
||||
if filename.endswith('.css'):
|
||||
if ext == '.css':
|
||||
return Response(content, media_type="text/css")
|
||||
elif filename.endswith('.js'):
|
||||
elif ext == 'js':
|
||||
return Response(content, media_type="application/javascript")
|
||||
else:
|
||||
return Response(content)
|
||||
|
||||
@@ -186,8 +186,9 @@ class TestObject:
|
||||
|
||||
|
||||
class TestIcon(TestObject):
|
||||
def __init__(self, name: Optional[str] = '', command=None):
|
||||
super().__init__("div")
|
||||
def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
|
||||
super().__init__(wrapper)
|
||||
self.wrapper = wrapper
|
||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||
self.children = [
|
||||
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||
@@ -196,7 +197,7 @@ class TestIcon(TestObject):
|
||||
self.attrs |= command.get_htmx_params()
|
||||
|
||||
def __str__(self):
|
||||
return f'<div><svg name="{self.name}" .../></div>'
|
||||
return f'<{self.wrapper}><svg name="{self.name}" .../></{self.wrapper}>'
|
||||
|
||||
|
||||
class TestIconNotStr(TestObject):
|
||||
|
||||
469
tests/controls/test_datagrid_columns_manager.py
Normal file
469
tests/controls/test_datagrid_columns_manager.py
Normal file
@@ -0,0 +1,469 @@
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fasthtml.common import Div, Input, Label, Form, Fieldset, Select
|
||||
|
||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager, Commands
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.instances import InstancesManager, MultipleInstance
|
||||
from myfasthtml.test.matcher import (
|
||||
matches, find_one, find, Contains, TestIcon, TestObject
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockDatagridState:
|
||||
"""Mock state object that mimics DatagridState."""
|
||||
columns: list = field(default_factory=list)
|
||||
|
||||
|
||||
class MockDataGrid(MultipleInstance):
|
||||
"""Mock DataGrid parent for testing DataGridColumnsManager."""
|
||||
|
||||
def __init__(self, parent, columns=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._state = MockDatagridState(columns=columns or [])
|
||||
self._save_state_called = False
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def save_state(self):
|
||||
self._save_state_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_datagrid(root_instance):
|
||||
"""Create a mock DataGrid with sample columns."""
|
||||
columns = [
|
||||
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True),
|
||||
DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True),
|
||||
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False),
|
||||
]
|
||||
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
|
||||
InstancesManager.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def columns_manager(mock_datagrid):
|
||||
"""Create a DataGridColumnsManager instance for testing."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
yield DataGridColumnsManager(mock_datagrid)
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestDataGridColumnsManagerBehaviour:
|
||||
"""Tests for DataGridColumnsManager behavior and logic."""
|
||||
|
||||
# =========================================================================
|
||||
# Initialization
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_create_columns_manager(self, mock_datagrid):
|
||||
"""Test that DataGridColumnsManager can be created with a DataGrid parent."""
|
||||
cm = DataGridColumnsManager(mock_datagrid)
|
||||
|
||||
assert cm is not None
|
||||
assert cm._parent == mock_datagrid
|
||||
assert isinstance(cm.commands, Commands)
|
||||
|
||||
# =========================================================================
|
||||
# Columns Property
|
||||
# =========================================================================
|
||||
|
||||
def test_columns_property_returns_parent_state_columns(self, columns_manager, mock_datagrid):
|
||||
"""Test that columns property returns columns from parent's state."""
|
||||
columns = columns_manager.columns
|
||||
|
||||
assert columns == mock_datagrid.get_state().columns
|
||||
assert len(columns) == 3
|
||||
assert columns[0].col_id == "name"
|
||||
|
||||
# =========================================================================
|
||||
# Get Column Definition
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_get_existing_column_by_id(self, columns_manager):
|
||||
"""Test finding an existing column by its ID."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
|
||||
assert col_def is not None
|
||||
assert col_def.col_id == "name"
|
||||
assert col_def.title == "Name"
|
||||
|
||||
def test_i_cannot_get_nonexistent_column(self, columns_manager):
|
||||
"""Test that getting a nonexistent column returns None."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("nonexistent")
|
||||
|
||||
assert col_def is None
|
||||
|
||||
# =========================================================================
|
||||
# Toggle Column Visibility
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
|
||||
("name", True, False), # visible -> hidden
|
||||
("email", False, True), # hidden -> visible
|
||||
])
|
||||
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
|
||||
"""Test toggling column visibility from visible to hidden and vice versa."""
|
||||
col_def = columns_manager._get_col_def_from_col_id(col_id)
|
||||
assert col_def.visible == initial_visible
|
||||
|
||||
columns_manager.toggle_column(col_id)
|
||||
|
||||
assert col_def.visible == expected_visible
|
||||
|
||||
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
|
||||
"""Test that toggle_column calls save_state on parent."""
|
||||
mock_datagrid._save_state_called = False
|
||||
|
||||
columns_manager.toggle_column("name")
|
||||
|
||||
assert mock_datagrid._save_state_called is True
|
||||
|
||||
def test_toggle_column_returns_column_label(self, columns_manager):
|
||||
"""Test that toggle_column returns the updated column label."""
|
||||
result = columns_manager.toggle_column("name")
|
||||
|
||||
# Result should be a Div with the column label structure
|
||||
assert result is not None
|
||||
assert hasattr(result, 'tag')
|
||||
|
||||
def test_i_cannot_toggle_nonexistent_column(self, columns_manager):
|
||||
"""Test that toggling a nonexistent column returns an error message."""
|
||||
result = columns_manager.toggle_column("nonexistent")
|
||||
|
||||
expected = Div("Column 'nonexistent' not found")
|
||||
assert matches(result, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Show Column Details
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_show_column_details_for_existing_column(self, columns_manager):
|
||||
"""Test that show_column_details returns the details form for an existing column."""
|
||||
result = columns_manager.show_column_details("name")
|
||||
|
||||
# Should contain a Form - check by finding form tag in children
|
||||
expected = Form()
|
||||
del(expected.attrs["enctype"]) # hack. We don't know why enctype is added
|
||||
forms = find(result, expected)
|
||||
assert len(forms) == 1, "Should contain exactly one form"
|
||||
|
||||
def test_i_cannot_show_details_for_nonexistent_column(self, columns_manager):
|
||||
"""Test that showing details for nonexistent column returns error message."""
|
||||
result = columns_manager.show_column_details("nonexistent")
|
||||
|
||||
expected = Div("Column 'nonexistent' not found")
|
||||
assert matches(result, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Show All Columns
|
||||
# =========================================================================
|
||||
|
||||
def test_show_all_columns_returns_search_component(self, columns_manager):
|
||||
"""Test that show_all_columns returns a Search component."""
|
||||
result = columns_manager.show_all_columns()
|
||||
|
||||
assert isinstance(result, Search)
|
||||
|
||||
def test_show_all_columns_contains_all_columns(self, columns_manager):
|
||||
"""Test that show_all_columns Search contains all columns."""
|
||||
result = columns_manager.show_all_columns()
|
||||
|
||||
assert len(result.items) == 3
|
||||
|
||||
# =========================================================================
|
||||
# Update Column
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_update_column_title(self, columns_manager):
|
||||
"""Test updating a column's title via client_response."""
|
||||
client_response = {"title": "New Name"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.title == "New Name"
|
||||
|
||||
def test_i_can_update_column_visibility_via_form(self, columns_manager):
|
||||
"""Test updating column visibility via checkbox form value."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.visible is True
|
||||
|
||||
# Unchecked checkbox sends nothing, checked sends "on"
|
||||
client_response = {"visible": "off"} # Not "on" means unchecked
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
assert col_def.visible is False
|
||||
|
||||
# Check it back on
|
||||
client_response = {"visible": "on"}
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
assert col_def.visible is True
|
||||
|
||||
def test_i_can_update_column_type(self, columns_manager):
|
||||
"""Test updating a column's type."""
|
||||
client_response = {"type": "Number"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.type == ColumnType.Number
|
||||
|
||||
def test_i_can_update_column_width(self, columns_manager):
|
||||
"""Test updating a column's width."""
|
||||
client_response = {"width": "200"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.width == 200
|
||||
|
||||
def test_update_column_saves_state(self, columns_manager, mock_datagrid):
|
||||
"""Test that update_column calls save_state on parent."""
|
||||
mock_datagrid._save_state_called = False
|
||||
|
||||
columns_manager.update_column("name", {"title": "Updated"})
|
||||
|
||||
assert mock_datagrid._save_state_called is True
|
||||
|
||||
def test_update_column_ignores_unknown_attributes(self, columns_manager):
|
||||
"""Test that update_column ignores attributes not in DataGridColumnState."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
original_title = col_def.title
|
||||
|
||||
client_response = {"unknown_attr": "value", "title": "New Title"}
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
# unknown_attr should be ignored, title should be updated
|
||||
assert col_def.title == "New Title"
|
||||
assert not hasattr(col_def, "unknown_attr")
|
||||
|
||||
def test_i_cannot_update_nonexistent_column(self, columns_manager):
|
||||
"""Test that updating nonexistent column returns mk_all_columns result."""
|
||||
result = columns_manager.update_column("nonexistent", {"title": "Test"})
|
||||
|
||||
# Should return the all columns view (Search component)
|
||||
assert isinstance(result, Search)
|
||||
|
||||
|
||||
class TestDataGridColumnsManagerRender:
|
||||
"""Tests for DataGridColumnsManager HTML rendering."""
|
||||
|
||||
@pytest.fixture
|
||||
def columns_manager(self, mock_datagrid):
|
||||
"""Create a fresh DataGridColumnsManager for render tests."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
cm = DataGridColumnsManager(mock_datagrid)
|
||||
yield cm
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
# =========================================================================
|
||||
# Global Structure
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_render_columns_manager_with_columns(self, columns_manager):
|
||||
"""Test that DataGridColumnsManager renders with correct global structure.
|
||||
|
||||
Why these elements matter:
|
||||
- id: Required for HTMX targeting in commands
|
||||
- Contains Search component: Main content for column list
|
||||
"""
|
||||
html = columns_manager.render()
|
||||
|
||||
expected = Div(
|
||||
TestObject(Search), # Search component
|
||||
id=columns_manager._id,
|
||||
)
|
||||
|
||||
assert matches(html, expected)
|
||||
|
||||
# =========================================================================
|
||||
# mk_column_label
|
||||
# =========================================================================
|
||||
|
||||
def test_column_label_has_checkbox_and_details_navigation(self, columns_manager):
|
||||
"""Test that column label contains checkbox and navigation to details.
|
||||
|
||||
Why these elements matter:
|
||||
- Checkbox (Input type=checkbox): Controls column visibility
|
||||
- Label with column ID: Identifies the column
|
||||
- Chevron icon: Indicates navigation to details
|
||||
- id with tcolman_ prefix: Required for HTMX swap targeting
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
|
||||
# Should have the correct ID pattern
|
||||
expected = Div(
|
||||
id=f"tcolman_{columns_manager._id}-name",
|
||||
cls=Contains("flex"),
|
||||
)
|
||||
assert matches(label, expected)
|
||||
|
||||
# Should contain a checkbox
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
assert checkbox is not None
|
||||
|
||||
# Should contain chevron icon for navigation
|
||||
chevron = find_one(label, TestIcon("chevron_right20_regular"))
|
||||
assert chevron is not None
|
||||
|
||||
def test_column_label_checkbox_is_checked_when_visible(self, columns_manager):
|
||||
"""Test that checkbox is checked when column is visible.
|
||||
|
||||
Why this matters:
|
||||
- checked attribute: Reflects current visibility state
|
||||
- User can see which columns are visible
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.visible is True
|
||||
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
|
||||
# Checkbox should have checked attribute
|
||||
assert checkbox.attrs.get("checked") is True
|
||||
|
||||
def test_column_label_checkbox_is_unchecked_when_hidden(self, columns_manager):
|
||||
"""Test that checkbox is unchecked when column is hidden.
|
||||
|
||||
Why this matters:
|
||||
- No checked attribute: Indicates column is hidden
|
||||
- Visual feedback for user
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("email")
|
||||
assert col_def.visible is False
|
||||
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
|
||||
# Checkbox should not have checked attribute (or it should be False/None)
|
||||
checked = checkbox.attrs.get("checked")
|
||||
assert checked is None or checked is False
|
||||
|
||||
# =========================================================================
|
||||
# mk_column_details
|
||||
# =========================================================================
|
||||
|
||||
def test_column_details_contains_all_form_fields(self, columns_manager):
|
||||
"""Test that column details form contains all required fields.
|
||||
|
||||
Why these elements matter:
|
||||
- col_id field (readonly): Shows column identifier
|
||||
- title field: Editable column display name
|
||||
- visible checkbox: Toggle visibility
|
||||
- type select: Change column type
|
||||
- width input: Set column width
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
# Should contain Form
|
||||
form = Form()
|
||||
del form.attrs["enctype"]
|
||||
form = find_one(details, form)
|
||||
assert form is not None
|
||||
|
||||
# Should contain all required input fields
|
||||
col_id_input = find_one(form, Input(name="col_id"))
|
||||
assert col_id_input is not None
|
||||
assert col_id_input.attrs.get("readonly") is True
|
||||
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
|
||||
visible_checkbox = find_one(form, Input(name="visible", type="checkbox"))
|
||||
assert visible_checkbox is not None
|
||||
|
||||
type_select = find_one(form, Select(name="type"))
|
||||
assert type_select is not None
|
||||
|
||||
width_input = find_one(form, Input(name="width", type="number"))
|
||||
assert width_input is not None
|
||||
|
||||
def test_column_details_has_back_button(self, columns_manager):
|
||||
"""Test that column details has a back button to return to all columns.
|
||||
|
||||
Why this matters:
|
||||
- Back navigation: User can return to column list
|
||||
- Chevron left icon: Visual indicator of back action
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
# Should contain back chevron icon
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span"))
|
||||
assert back_icon is not None
|
||||
|
||||
def test_column_details_form_has_fieldset_with_legend(self, columns_manager):
|
||||
"""Test that column details form has a fieldset with legend.
|
||||
|
||||
Why this matters:
|
||||
- Fieldset groups related fields
|
||||
- Legend provides context ("Column details")
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
fieldset = find_one(details, Fieldset(legend="Column details"))
|
||||
assert fieldset is not None
|
||||
|
||||
# =========================================================================
|
||||
# mk_all_columns
|
||||
# =========================================================================
|
||||
|
||||
def test_all_columns_uses_search_component(self, columns_manager):
|
||||
"""Test that mk_all_columns returns a Search component.
|
||||
|
||||
Why this matters:
|
||||
- Search component: Enables filtering columns by name
|
||||
- items_names="Columns": Labels the search appropriately
|
||||
"""
|
||||
result = columns_manager.mk_all_columns()
|
||||
|
||||
assert isinstance(result, Search)
|
||||
assert result.items_names == "Columns"
|
||||
|
||||
def test_all_columns_search_has_correct_configuration(self, columns_manager):
|
||||
"""Test that Search component is configured correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- items: Contains all column definitions
|
||||
- get_attr: Extracts col_id for search matching
|
||||
- template: Uses mk_column_label for rendering
|
||||
"""
|
||||
result = columns_manager.mk_all_columns()
|
||||
|
||||
# Should have all 3 columns
|
||||
assert len(result.items) == 3
|
||||
|
||||
# get_attr should return col_id
|
||||
col_def = result.items[0]
|
||||
assert result.get_attr(col_def) == col_def.col_id
|
||||
|
||||
def test_all_columns_renders_all_column_labels(self, columns_manager):
|
||||
"""Test that all columns render produces labels for all columns.
|
||||
|
||||
Why this matters:
|
||||
- All columns visible in list
|
||||
- Each column has its label rendered
|
||||
"""
|
||||
search = columns_manager.mk_all_columns()
|
||||
rendered = search.render()
|
||||
|
||||
# Should find 3 column labels in the results
|
||||
results_div = find_one(rendered, Div(id=f"{search._id}-results"))
|
||||
assert results_div is not None
|
||||
|
||||
# Each column should have a label with tcolman_ prefix
|
||||
for col_id in ["name", "age", "email"]:
|
||||
label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}"))
|
||||
assert label is not None, f"Column label for '{col_id}' should be present"
|
||||
@@ -16,194 +16,250 @@ def cleanup_db():
|
||||
|
||||
class TestPanelBehaviour:
|
||||
"""Tests for Panel behavior and logic."""
|
||||
|
||||
|
||||
# 1. Creation and initialization
|
||||
|
||||
|
||||
def test_i_can_create_panel_with_default_config(self, root_instance):
|
||||
"""Test that a Panel can be created with default configuration."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
|
||||
assert panel is not None
|
||||
assert panel.conf.left is False
|
||||
assert panel.conf.right is True
|
||||
|
||||
|
||||
def test_i_can_create_panel_with_custom_config(self, root_instance):
|
||||
"""Test that a Panel accepts a custom PanelConf."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
|
||||
assert panel.conf.left is False
|
||||
assert panel.conf.right is True
|
||||
|
||||
|
||||
def test_panel_has_default_state_after_creation(self, root_instance):
|
||||
"""Test that _state has correct initial values."""
|
||||
panel = Panel(root_instance)
|
||||
state = panel._state
|
||||
|
||||
|
||||
assert state.left_visible is True
|
||||
assert state.right_visible is True
|
||||
assert state.left_width == 250
|
||||
assert state.right_width == 250
|
||||
|
||||
|
||||
def test_panel_creates_commands_instance(self, root_instance):
|
||||
"""Test that panel.commands exists and is of type Commands."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
|
||||
assert panel.commands is not None
|
||||
assert panel.commands.__class__.__name__ == "Commands"
|
||||
|
||||
|
||||
# 2. Content management
|
||||
|
||||
|
||||
def test_i_can_set_main_content(self, root_instance):
|
||||
"""Test that set_main() stores content in _main."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Main content")
|
||||
|
||||
|
||||
panel.set_main(content)
|
||||
|
||||
|
||||
assert panel._main == content
|
||||
|
||||
|
||||
def test_set_main_returns_self(self, root_instance):
|
||||
"""Test that set_main() returns self for method chaining."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Main content")
|
||||
|
||||
|
||||
result = panel.set_main(content)
|
||||
|
||||
|
||||
assert result is panel
|
||||
|
||||
|
||||
def test_i_can_set_left_content(self, root_instance):
|
||||
"""Test that set_left() stores content in _left."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Left content")
|
||||
|
||||
|
||||
panel.set_left(content)
|
||||
|
||||
|
||||
assert panel._left == content
|
||||
|
||||
|
||||
def test_i_can_set_right_content(self, root_instance):
|
||||
"""Test that set_right() stores content in _right."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Right content")
|
||||
|
||||
|
||||
panel.set_right(content)
|
||||
|
||||
|
||||
assert panel._right == content
|
||||
|
||||
|
||||
# 3. Toggle visibility
|
||||
|
||||
|
||||
def test_i_can_hide_left_panel(self, root_instance):
|
||||
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.toggle_side("left", False)
|
||||
|
||||
|
||||
panel.set_side_visible("left", False)
|
||||
|
||||
assert panel._state.left_visible is False
|
||||
|
||||
|
||||
def test_i_can_show_left_panel(self, root_instance):
|
||||
"""Test that toggle_side('left', True) sets _state.left_visible to True."""
|
||||
panel = Panel(root_instance)
|
||||
panel._state.left_visible = False
|
||||
|
||||
panel.toggle_side("left", True)
|
||||
|
||||
|
||||
panel.set_side_visible("left", True)
|
||||
|
||||
assert panel._state.left_visible is True
|
||||
|
||||
|
||||
def test_i_can_hide_right_panel(self, root_instance):
|
||||
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.toggle_side("right", False)
|
||||
|
||||
|
||||
panel.set_side_visible("right", False)
|
||||
|
||||
assert panel._state.right_visible is False
|
||||
|
||||
|
||||
def test_i_can_show_right_panel(self, root_instance):
|
||||
"""Test that toggle_side('right', True) sets _state.right_visible to True."""
|
||||
panel = Panel(root_instance)
|
||||
panel._state.right_visible = False
|
||||
|
||||
panel.toggle_side("right", True)
|
||||
|
||||
|
||||
panel.set_side_visible("right", True)
|
||||
|
||||
assert panel._state.right_visible is True
|
||||
|
||||
|
||||
def test_set_side_visible_returns_panel_and_icon(self, root_instance):
|
||||
"""Test that set_side_visible() returns a tuple (panel_element, show_icon_element)."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
result = panel.set_side_visible("left", False)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.parametrize("side, initial_visible, expected_visible", [
|
||||
("left", True, False), # left visible → hidden
|
||||
("left", False, True), # left hidden → visible
|
||||
("right", True, False), # right visible → hidden
|
||||
("right", False, True), # right hidden → visible
|
||||
])
|
||||
def test_i_can_toggle_panel_visibility(self, root_instance, side, initial_visible, expected_visible):
|
||||
"""Test that toggle_side() inverts the visibility state."""
|
||||
panel = Panel(root_instance)
|
||||
if side == "left":
|
||||
panel._state.left_visible = initial_visible
|
||||
else:
|
||||
panel._state.right_visible = initial_visible
|
||||
|
||||
panel.toggle_side(side)
|
||||
|
||||
if side == "left":
|
||||
assert panel._state.left_visible is expected_visible
|
||||
else:
|
||||
assert panel._state.right_visible is expected_visible
|
||||
|
||||
def test_toggle_side_returns_panel_and_icon(self, root_instance):
|
||||
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
result = panel.toggle_side("left", False)
|
||||
|
||||
|
||||
result = panel.toggle_side("left")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# 4. Width management
|
||||
|
||||
|
||||
def test_i_can_update_left_panel_width(self, root_instance):
|
||||
"""Test that update_side_width('left', 300) sets _state.left_width to 300."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
|
||||
panel.update_side_width("left", 300)
|
||||
|
||||
|
||||
assert panel._state.left_width == 300
|
||||
|
||||
|
||||
def test_i_can_update_right_panel_width(self, root_instance):
|
||||
"""Test that update_side_width('right', 400) sets _state.right_width to 400."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
|
||||
panel.update_side_width("right", 400)
|
||||
|
||||
|
||||
assert panel._state.right_width == 400
|
||||
|
||||
|
||||
def test_update_width_returns_panel_element(self, root_instance):
|
||||
"""Test that update_side_width() returns a panel element."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
|
||||
result = panel.update_side_width("right", 300)
|
||||
|
||||
|
||||
assert result is not None
|
||||
|
||||
|
||||
# 5. Configuration
|
||||
|
||||
|
||||
def test_disabled_left_panel_returns_none(self, root_instance):
|
||||
"""Test that _mk_panel('left') returns None when conf.left=False."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
|
||||
result = panel._mk_panel("left")
|
||||
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_disabled_right_panel_returns_none(self, root_instance):
|
||||
"""Test that _mk_panel('right') returns None when conf.right=False."""
|
||||
custom_conf = PanelConf(left=True, right=False)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
|
||||
result = panel._mk_panel("right")
|
||||
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_disabled_panel_show_icon_returns_none(self, root_instance):
|
||||
"""Test that _mk_show_icon() returns None when the panel is disabled."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
|
||||
result = panel._mk_show_icon("left")
|
||||
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.parametrize("side, conf_kwargs", [
|
||||
("left", {"show_display_left": False}),
|
||||
("right", {"show_display_right": False}),
|
||||
])
|
||||
def test_show_icon_returns_none_when_show_display_disabled(self, root_instance, side, conf_kwargs):
|
||||
"""Test that _mk_show_icon() returns None when show_display is disabled."""
|
||||
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
result = panel._mk_show_icon(side)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPanelRender:
|
||||
"""Tests for Panel HTML rendering."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def panel(self, root_instance):
|
||||
panel = Panel(root_instance, PanelConf(True, True))
|
||||
"""Panel with titles (default behavior)."""
|
||||
panel = Panel(root_instance, PanelConf(left=True, right=True))
|
||||
panel.set_main(Div("Main content"))
|
||||
panel.set_left(Div("Left content"))
|
||||
panel.set_right(Div("Right content"))
|
||||
return panel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def panel_no_title(self, root_instance):
|
||||
"""Panel without titles (legacy behavior)."""
|
||||
panel = Panel(root_instance, PanelConf(
|
||||
left=True, right=True,
|
||||
show_left_title=False, show_right_title=False
|
||||
))
|
||||
panel.set_main(Div("Main content"))
|
||||
panel.set_left(Div("Left content"))
|
||||
panel.set_right(Div("Right content"))
|
||||
return panel
|
||||
|
||||
# 1. Global structure (UTR-11.1 - FIRST TEST)
|
||||
|
||||
|
||||
def test_i_can_render_panel_with_default_state(self, panel):
|
||||
"""Test that Panel renders with correct global structure.
|
||||
|
||||
@@ -220,32 +276,68 @@ class TestPanelRender:
|
||||
id=panel._id,
|
||||
cls="mf-panel"
|
||||
)
|
||||
|
||||
|
||||
assert matches(panel.render(), expected)
|
||||
|
||||
|
||||
# 2. Left panel
|
||||
|
||||
def test_left_panel_renders_with_correct_structure(self, panel):
|
||||
"""Test that left panel has content div before resizer.
|
||||
|
||||
def test_left_panel_renders_with_title_structure(self, panel):
|
||||
"""Test that left panel with title has header + scrollable content.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (content then resizer): Critical for positioning resizer on the right side
|
||||
- id: Required for HTMX targeting during toggle/resize operations
|
||||
- cls Contains "mf-panel-left": CSS class for left panel styling
|
||||
- mf-panel-body: Grid container for header + content layout
|
||||
- mf-panel-header: Contains title and hide icon
|
||||
- mf-panel-content: Scrollable content area
|
||||
- mf-panel-with-title: Removes default padding-top
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
# Step 1: Validate left panel global structure
|
||||
|
||||
expected = Div(
|
||||
TestIcon("subtract20_regular"),
|
||||
Div(id=panel.get_ids().left), # content div, tested in detail later
|
||||
Div(cls=Contains("mf-panel-body")), # body with header + content
|
||||
Div(cls=Contains("mf-resizer-left")), # resizer
|
||||
id=panel.get_ids().panel("left"),
|
||||
cls=Contains("mf-panel-left", "mf-panel-with-title")
|
||||
)
|
||||
|
||||
assert matches(left_panel, expected)
|
||||
|
||||
def test_left_panel_header_contains_title_and_icon(self, panel):
|
||||
"""Test that left panel header has title and hide icon.
|
||||
|
||||
Why these elements matter:
|
||||
- Title: Displays the panel title (left aligned)
|
||||
- Hide icon: Allows user to collapse the panel (right aligned)
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
|
||||
|
||||
expected = Div(
|
||||
Div("Left"), # title
|
||||
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
|
||||
cls="mf-panel-header"
|
||||
)
|
||||
|
||||
assert matches(header, expected)
|
||||
|
||||
def test_left_panel_renders_without_title_structure(self, panel_no_title):
|
||||
"""Test that left panel without title has legacy structure.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (hide icon, content, resizer): Legacy layout without header
|
||||
- No mf-panel-with-title class
|
||||
"""
|
||||
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
|
||||
|
||||
expected = Div(
|
||||
TestIcon("subtract20_regular"),
|
||||
Div(id=panel_no_title.get_ids().left),
|
||||
Div(cls=Contains("mf-resizer-left")),
|
||||
id=panel_no_title.get_ids().panel("left"),
|
||||
cls=Contains("mf-panel-left")
|
||||
)
|
||||
|
||||
|
||||
assert matches(left_panel, expected)
|
||||
|
||||
|
||||
def test_left_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||
"""Test that left panel has 'mf-hidden' class when not visible.
|
||||
|
||||
@@ -254,11 +346,11 @@ class TestPanelRender:
|
||||
"""
|
||||
panel._state.left_visible = False
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
|
||||
expected = Div(cls=Contains("mf-hidden"))
|
||||
|
||||
|
||||
assert matches(left_panel, expected)
|
||||
|
||||
|
||||
def test_left_panel_does_not_render_when_disabled(self, panel):
|
||||
"""Test that render() does not contain left panel when conf.left=False.
|
||||
|
||||
@@ -267,34 +359,70 @@ class TestPanelRender:
|
||||
"""
|
||||
panel.conf.left = False
|
||||
rendered = panel.render()
|
||||
|
||||
|
||||
# Verify left panel is not present
|
||||
left_panels = find(rendered, Div(id=panel.get_ids().panel("left")))
|
||||
assert len(left_panels) == 0, "Left panel should not be present when conf.left=False"
|
||||
|
||||
|
||||
# 3. Right panel
|
||||
|
||||
def test_right_panel_renders_with_correct_structure(self, panel):
|
||||
"""Test that right panel has resizer before content div.
|
||||
|
||||
def test_right_panel_renders_with_title_structure(self, panel):
|
||||
"""Test that right panel with title has header + scrollable content.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side
|
||||
- id: Required for HTMX targeting during toggle/resize operations
|
||||
- cls Contains "mf-panel-right": CSS class for right panel styling
|
||||
- mf-panel-body: Grid container for header + content layout
|
||||
- mf-panel-header: Contains title and hide icon
|
||||
- mf-panel-content: Scrollable content area
|
||||
- mf-panel-with-title: Removes default padding-top
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
# Step 1: Validate right panel global structure
|
||||
|
||||
expected = Div(
|
||||
Div(cls=Contains("mf-resizer-right")), # resizer
|
||||
TestIcon("subtract20_regular"), # hide icon
|
||||
Div(id=panel.get_ids().right), # content div, tested in detail later
|
||||
Div(cls=Contains("mf-panel-body")), # body with header + content
|
||||
id=panel.get_ids().panel("right"),
|
||||
cls=Contains("mf-panel-right", "mf-panel-with-title")
|
||||
)
|
||||
|
||||
assert matches(right_panel, expected)
|
||||
|
||||
def test_right_panel_header_contains_title_and_icon(self, panel):
|
||||
"""Test that right panel header has title and hide icon.
|
||||
|
||||
Why these elements matter:
|
||||
- Title: Displays the panel title (left aligned)
|
||||
- Hide icon: Allows user to collapse the panel (right aligned)
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
|
||||
|
||||
expected = Div(
|
||||
Div("Right"), # title
|
||||
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
|
||||
cls="mf-panel-header"
|
||||
)
|
||||
|
||||
assert matches(header, expected)
|
||||
|
||||
def test_right_panel_renders_without_title_structure(self, panel_no_title):
|
||||
"""Test that right panel without title has legacy structure.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (resizer, hide icon, content): Legacy layout without header
|
||||
- No mf-panel-with-title class
|
||||
"""
|
||||
right_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("right")))
|
||||
|
||||
expected = Div(
|
||||
Div(cls=Contains("mf-resizer-right")),
|
||||
TestIcon("subtract20_regular"),
|
||||
Div(id=panel_no_title.get_ids().right),
|
||||
id=panel_no_title.get_ids().panel("right"),
|
||||
cls=Contains("mf-panel-right")
|
||||
)
|
||||
|
||||
|
||||
assert matches(right_panel, expected)
|
||||
|
||||
|
||||
def test_right_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||
"""Test that right panel has 'mf-hidden' class when not visible.
|
||||
|
||||
@@ -303,11 +431,11 @@ class TestPanelRender:
|
||||
"""
|
||||
panel._state.right_visible = False
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
|
||||
expected = Div(cls=Contains("mf-hidden"))
|
||||
|
||||
|
||||
assert matches(right_panel, expected)
|
||||
|
||||
|
||||
def test_right_panel_does_not_render_when_disabled(self, panel):
|
||||
"""Test that render() does not contain right panel when conf.right=False.
|
||||
|
||||
@@ -316,13 +444,13 @@ class TestPanelRender:
|
||||
"""
|
||||
panel.conf.right = False
|
||||
rendered = panel.render()
|
||||
|
||||
|
||||
# Verify right panel is not present
|
||||
right_panels = find(rendered, Div(id=panel.get_ids().panel("right")))
|
||||
assert len(right_panels) == 0, "Right panel should not be present when conf.right=False"
|
||||
|
||||
|
||||
# 4. Resizers
|
||||
|
||||
|
||||
def test_left_panel_has_resizer_with_correct_attributes(self, panel):
|
||||
"""Test that left panel resizer has required attributes.
|
||||
|
||||
@@ -334,16 +462,16 @@ class TestPanelRender:
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
resizer = find_one(left_panel, Div(cls=Contains("mf-resizer-left")))
|
||||
|
||||
|
||||
expected = Div(
|
||||
data_side="left",
|
||||
cls=Contains("mf-resizer", "mf-resizer-left")
|
||||
)
|
||||
|
||||
|
||||
assert matches(resizer, expected)
|
||||
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||
assert "data-command-id" in resizer.attrs
|
||||
|
||||
|
||||
def test_right_panel_has_resizer_with_correct_attributes(self, panel):
|
||||
"""Test that right panel resizer has required attributes.
|
||||
|
||||
@@ -355,58 +483,75 @@ class TestPanelRender:
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
resizer = find_one(right_panel, Div(cls=Contains("mf-resizer-right")))
|
||||
|
||||
|
||||
expected = Div(
|
||||
data_side="right",
|
||||
cls=Contains("mf-resizer", "mf-resizer-right")
|
||||
)
|
||||
|
||||
|
||||
assert matches(resizer, expected)
|
||||
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||
assert "data-command-id" in resizer.attrs
|
||||
|
||||
|
||||
# 5. Icons
|
||||
|
||||
def test_hide_icon_in_left_panel_has_correct_command(self, panel):
|
||||
"""Test that hide icon in left panel triggers toggle_side command.
|
||||
|
||||
def test_hide_icon_in_left_panel_header(self, panel):
|
||||
"""Test that hide icon in left panel header has correct structure.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
|
||||
- Icon is inside header when title is shown
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
# Find the hide icon (should be wrapped by mk.icon)
|
||||
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
|
||||
|
||||
# Verify it contains the subtract icon
|
||||
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
|
||||
|
||||
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("subtract20_regular"),
|
||||
cls=Contains("mf-panel-hide-icon")
|
||||
)
|
||||
assert matches(hide_icons[0], expected)
|
||||
|
||||
def test_hide_icon_in_right_panel_has_correct_command(self, panel):
|
||||
"""Test that hide icon in right panel triggers toggle_side command.
|
||||
|
||||
def test_hide_icon_in_right_panel_header(self, panel):
|
||||
"""Test that hide icon in right panel header has correct structure.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
|
||||
- Icon is inside header when title is shown
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
# Find the hide icon (should be wrapped by mk.icon)
|
||||
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Right panel should contain exactly one hide icon"
|
||||
|
||||
# Verify it contains the subtract icon
|
||||
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
|
||||
|
||||
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("subtract20_regular"),
|
||||
cls=Contains("mf-panel-hide-icon")
|
||||
)
|
||||
assert matches(hide_icons[0], expected)
|
||||
|
||||
def test_hide_icon_in_panel_without_title(self, panel_no_title):
|
||||
"""Test that hide icon is at root level when no title.
|
||||
|
||||
Why these elements matter:
|
||||
- Hide icon should be direct child of panel (legacy behavior)
|
||||
"""
|
||||
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
|
||||
|
||||
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Panel should contain exactly one hide icon"
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("subtract20_regular"),
|
||||
cls=Contains("mf-panel-hide-icon")
|
||||
)
|
||||
assert matches(hide_icons[0], expected)
|
||||
|
||||
def test_show_icon_left_is_hidden_when_panel_visible(self, panel):
|
||||
"""Test that show icon has 'hidden' class when left panel is visible.
|
||||
|
||||
@@ -415,14 +560,14 @@ class TestPanelRender:
|
||||
- id: Required for HTMX swap-oob targeting
|
||||
"""
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||
|
||||
|
||||
expected = Div(
|
||||
cls=Contains("hidden"),
|
||||
id=f"{panel._id}_show_left"
|
||||
)
|
||||
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
|
||||
def test_show_icon_left_is_visible_when_panel_hidden(self, panel):
|
||||
"""Test that show icon is positioned left when left panel is hidden.
|
||||
|
||||
@@ -432,15 +577,15 @@ class TestPanelRender:
|
||||
"""
|
||||
panel._state.left_visible = False
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("more_horizontal20_regular"),
|
||||
cls=Contains("mf-panel-show-icon-left"),
|
||||
id=f"{panel._id}_show_left"
|
||||
)
|
||||
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
|
||||
def test_show_icon_right_is_visible_when_panel_hidden(self, panel):
|
||||
"""Test that show icon is positioned right when right panel is hidden.
|
||||
|
||||
@@ -450,17 +595,37 @@ class TestPanelRender:
|
||||
"""
|
||||
panel._state.right_visible = False
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_right"))
|
||||
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("more_horizontal20_regular"),
|
||||
cls=Contains("mf-panel-show-icon-right"),
|
||||
id=f"{panel._id}_show_right"
|
||||
)
|
||||
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
@pytest.mark.parametrize("side, conf_kwargs", [
|
||||
("left", {"show_display_left": False}),
|
||||
("right", {"show_display_right": False}),
|
||||
])
|
||||
def test_show_icon_not_in_main_panel_when_show_display_disabled(self, root_instance, side, conf_kwargs):
|
||||
"""Test that show icon is not rendered when show_display is disabled.
|
||||
|
||||
Why these elements matter:
|
||||
- Absence of show icon: When show_display_* is False, the icon should not exist
|
||||
- This prevents users from showing the panel via UI (only programmatically)
|
||||
"""
|
||||
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
panel.set_main(Div("Main content"))
|
||||
|
||||
rendered = panel.render()
|
||||
show_icons = find(rendered, Div(id=f"{panel._id}_show_{side}"))
|
||||
|
||||
assert len(show_icons) == 0, f"Show icon for {side} should not be present when show_display_{side}=False"
|
||||
|
||||
# 6. Main panel
|
||||
|
||||
|
||||
def test_main_panel_contains_show_icons_and_content(self, panel):
|
||||
"""Test that main panel contains show icons and content in correct order.
|
||||
|
||||
@@ -473,10 +638,10 @@ class TestPanelRender:
|
||||
# Find all Divs with cls="mf-panel-main" (there are 2: outer wrapper and inner content)
|
||||
main_panels = find(panel.render(), Div(cls=Contains("mf-panel-main")))
|
||||
assert len(main_panels) == 2, "Should find outer wrapper and inner content div"
|
||||
|
||||
|
||||
# The outer wrapper is the first one (depth-first search)
|
||||
main_panel = main_panels[0]
|
||||
|
||||
|
||||
# Step 1: Validate main panel structure
|
||||
expected = Div(
|
||||
Div(id=f"{panel._id}_show_left"), # show icon left
|
||||
@@ -484,11 +649,11 @@ class TestPanelRender:
|
||||
Div(id=f"{panel._id}_show_right"), # show icon right
|
||||
cls="mf-panel-main"
|
||||
)
|
||||
|
||||
|
||||
assert matches(main_panel, expected)
|
||||
|
||||
|
||||
# 7. Script
|
||||
|
||||
|
||||
def test_init_resizer_script_is_present(self, panel):
|
||||
"""Test that initResizer script is present with correct panel ID.
|
||||
|
||||
@@ -496,7 +661,7 @@ class TestPanelRender:
|
||||
- Script content: Must call initResizer with panel ID for resize functionality
|
||||
"""
|
||||
script = find_one(panel.render(), Script())
|
||||
|
||||
|
||||
expected = TestScript(f"initResizer('{panel._id}');")
|
||||
|
||||
|
||||
assert matches(script, expected)
|
||||
|
||||
0
tests/core/dsl/__init__.py
Normal file
0
tests/core/dsl/__init__.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal file
137
tests/core/dsl/test_base_completion.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Tests for BaseCompletionEngine.
|
||||
|
||||
Uses a mock implementation to test the abstract base class functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Any
|
||||
|
||||
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
|
||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
|
||||
|
||||
|
||||
class MockProvider:
|
||||
"""Mock metadata provider for testing."""
|
||||
|
||||
def get_style_presets(self) -> list[str]:
|
||||
return ["custom_highlight"]
|
||||
|
||||
def get_format_presets(self) -> list[str]:
|
||||
return ["CHF"]
|
||||
|
||||
|
||||
class MockCompletionEngine(BaseCompletionEngine):
|
||||
"""Mock completion engine for testing base class functionality."""
|
||||
|
||||
def __init__(self, provider: BaseMetadataProvider, suggestions: list[Suggestion] = None):
|
||||
super().__init__(provider)
|
||||
self._suggestions = suggestions or []
|
||||
self._scope = None
|
||||
self._context = "test_context"
|
||||
|
||||
def detect_scope(self, text: str, current_line: int) -> Any:
|
||||
return self._scope
|
||||
|
||||
def detect_context(self, text: str, cursor: Position, scope: Any) -> Any:
|
||||
return self._context
|
||||
|
||||
def get_suggestions(self, context: Any, scope: Any, prefix: str) -> list[Suggestion]:
|
||||
return self._suggestions
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Filter Suggestions Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_i_can_filter_suggestions_by_prefix():
|
||||
"""Test that _filter_suggestions filters case-insensitively."""
|
||||
provider = MockProvider()
|
||||
engine = MockCompletionEngine(provider)
|
||||
|
||||
suggestions = [
|
||||
Suggestion("primary", "Primary color", "preset"),
|
||||
Suggestion("error", "Error color", "preset"),
|
||||
Suggestion("warning", "Warning color", "preset"),
|
||||
Suggestion("Error", "Title case error", "preset"),
|
||||
]
|
||||
|
||||
# Filter by "err" - should match "error" and "Error" (case-insensitive)
|
||||
filtered = engine._filter_suggestions(suggestions, "err")
|
||||
labels = [s.label for s in filtered]
|
||||
assert "error" in labels
|
||||
assert "Error" in labels
|
||||
assert "primary" not in labels
|
||||
assert "warning" not in labels
|
||||
|
||||
|
||||
def test_i_can_filter_suggestions_empty_prefix():
|
||||
"""Test that empty prefix returns all suggestions."""
|
||||
provider = MockProvider()
|
||||
engine = MockCompletionEngine(provider)
|
||||
|
||||
suggestions = [
|
||||
Suggestion("a"),
|
||||
Suggestion("b"),
|
||||
Suggestion("c"),
|
||||
]
|
||||
|
||||
filtered = engine._filter_suggestions(suggestions, "")
|
||||
assert len(filtered) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Empty Result Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_i_can_get_empty_result():
|
||||
"""Test that _empty_result returns a CompletionResult with no suggestions."""
|
||||
provider = MockProvider()
|
||||
engine = MockCompletionEngine(provider)
|
||||
|
||||
cursor = Position(line=5, ch=10)
|
||||
result = engine._empty_result(cursor)
|
||||
|
||||
assert result.from_pos == cursor
|
||||
assert result.to_pos == cursor
|
||||
assert result.suggestions == []
|
||||
assert result.is_empty is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Comment Skipping Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_i_can_skip_completion_in_comment():
|
||||
"""Test that get_completions returns empty when cursor is in a comment."""
|
||||
provider = MockProvider()
|
||||
suggestions = [Suggestion("should_not_appear")]
|
||||
engine = MockCompletionEngine(provider, suggestions)
|
||||
|
||||
text = "# This is a comment"
|
||||
cursor = Position(line=0, ch=15) # Inside the comment
|
||||
|
||||
result = engine.get_completions(text, cursor)
|
||||
|
||||
assert result.is_empty is True
|
||||
assert len(result.suggestions) == 0
|
||||
|
||||
|
||||
def test_i_can_get_completions_outside_comment():
|
||||
"""Test that get_completions works when cursor is not in a comment."""
|
||||
provider = MockProvider()
|
||||
suggestions = [Suggestion("style"), Suggestion("format")]
|
||||
engine = MockCompletionEngine(provider, suggestions)
|
||||
|
||||
# Cursor at space (ch=5) so prefix is empty and all suggestions are returned
|
||||
text = "text # comment"
|
||||
cursor = Position(line=0, ch=5) # At empty space, before comment
|
||||
|
||||
result = engine.get_completions(text, cursor)
|
||||
|
||||
assert result.is_empty is False
|
||||
assert len(result.suggestions) == 2
|
||||
172
tests/core/dsl/test_lark_to_lezer.py
Normal file
172
tests/core/dsl/test_lark_to_lezer.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Tests for lark_to_lezer module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.dsl.lark_to_lezer import (
|
||||
extract_completions_from_grammar,
|
||||
lark_to_lezer_grammar,
|
||||
)
|
||||
|
||||
# Sample grammars for testing
|
||||
SIMPLE_GRAMMAR = r'''
|
||||
start: rule+
|
||||
rule: "if" condition
|
||||
condition: "value" operator literal
|
||||
operator: "==" -> op_eq
|
||||
| "!=" -> op_ne
|
||||
| "contains" -> op_contains
|
||||
literal: QUOTED_STRING -> string_literal
|
||||
| BOOLEAN -> boolean_literal
|
||||
QUOTED_STRING: /"[^"]*"/
|
||||
BOOLEAN: "True" | "False"
|
||||
'''
|
||||
|
||||
GRAMMAR_WITH_KEYWORDS = r'''
|
||||
start: scope+
|
||||
scope: "column" NAME ":" rule
|
||||
| "row" INTEGER ":" rule
|
||||
| "cell" cell_ref ":" rule
|
||||
rule: style_expr condition?
|
||||
condition: "if" "not"? comparison
|
||||
comparison: operand "and" operand
|
||||
| operand "or" operand
|
||||
style_expr: "style" "(" args ")"
|
||||
operand: "value" | literal
|
||||
'''
|
||||
|
||||
GRAMMAR_WITH_TYPES = r'''
|
||||
format_type: "number" -> fmt_number
|
||||
| "date" -> fmt_date
|
||||
| "boolean" -> fmt_boolean
|
||||
| "text" -> fmt_text
|
||||
| "enum" -> fmt_enum
|
||||
'''
|
||||
|
||||
|
||||
class TestExtractCompletions:
|
||||
"""Tests for extract_completions_from_grammar function."""
|
||||
|
||||
def test_i_can_extract_keywords_from_grammar(self):
|
||||
"""Test that keywords like if, not, and are extracted."""
|
||||
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
|
||||
|
||||
assert "if" in completions["keywords"]
|
||||
assert "not" in completions["keywords"]
|
||||
assert "column" in completions["keywords"]
|
||||
assert "row" in completions["keywords"]
|
||||
assert "cell" in completions["keywords"]
|
||||
assert "value" in completions["keywords"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator",
|
||||
["==", "!=", "contains"],
|
||||
)
|
||||
def test_i_can_extract_operators_from_grammar(self, operator):
|
||||
"""Test that operators are extracted from grammar."""
|
||||
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
|
||||
|
||||
assert operator in completions["operators"]
|
||||
|
||||
def test_i_can_extract_functions_from_grammar(self):
|
||||
"""Test that function-like constructs are extracted."""
|
||||
completions = extract_completions_from_grammar(GRAMMAR_WITH_KEYWORDS)
|
||||
|
||||
assert "style" in completions["functions"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"type_name",
|
||||
["number", "date", "boolean", "text", "enum"],
|
||||
)
|
||||
def test_i_can_extract_types_from_grammar(self, type_name):
|
||||
"""Test that type names are extracted from format_type rule."""
|
||||
completions = extract_completions_from_grammar(GRAMMAR_WITH_TYPES)
|
||||
|
||||
assert type_name in completions["types"]
|
||||
|
||||
@pytest.mark.parametrize("literal", [
|
||||
"True",
|
||||
"False"
|
||||
])
|
||||
def test_i_can_extract_literals_from_grammar(self, literal):
|
||||
"""Test that literal values like True/False are extracted."""
|
||||
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
|
||||
|
||||
assert literal in completions["literals"]
|
||||
|
||||
def test_i_can_extract_completions_returns_all_categories(self):
|
||||
"""Test that all completion categories are present in result."""
|
||||
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
|
||||
|
||||
assert "keywords" in completions
|
||||
assert "operators" in completions
|
||||
assert "functions" in completions
|
||||
assert "types" in completions
|
||||
assert "literals" in completions
|
||||
|
||||
def test_i_can_extract_completions_returns_sorted_lists(self):
|
||||
"""Test that completion lists are sorted alphabetically."""
|
||||
completions = extract_completions_from_grammar(SIMPLE_GRAMMAR)
|
||||
|
||||
for category in completions.values():
|
||||
assert category == sorted(category)
|
||||
|
||||
|
||||
class TestLarkToLezerConversion:
|
||||
"""Tests for lark_to_lezer_grammar function."""
|
||||
|
||||
def test_i_can_convert_simple_grammar_to_lezer(self):
|
||||
"""Test that a simple Lark grammar is converted to Lezer format."""
|
||||
lezer = lark_to_lezer_grammar(SIMPLE_GRAMMAR)
|
||||
|
||||
# Should have @top directive
|
||||
assert "@top Start" in lezer
|
||||
# Should have @tokens block
|
||||
assert "@tokens {" in lezer
|
||||
# Should have @skip directive
|
||||
assert "@skip {" in lezer
|
||||
|
||||
def test_i_can_convert_rule_names_to_pascal_case(self):
|
||||
"""Test that snake_case rule names become PascalCase."""
|
||||
grammar = r'''
|
||||
my_rule: other_rule
|
||||
other_rule: "test"
|
||||
'''
|
||||
lezer = lark_to_lezer_grammar(grammar)
|
||||
|
||||
assert "MyRule" in lezer
|
||||
assert "OtherRule" in lezer
|
||||
|
||||
def test_i_cannot_include_internal_rules_in_lezer(self):
|
||||
"""Test that rules starting with _ are not included."""
|
||||
grammar = r'''
|
||||
start: rule _NL
|
||||
rule: "test"
|
||||
_NL: /\n/
|
||||
'''
|
||||
lezer = lark_to_lezer_grammar(grammar)
|
||||
|
||||
# Internal rules should not appear as Lezer rules
|
||||
assert "Nl {" not in lezer
|
||||
|
||||
def test_i_can_convert_terminal_regex_to_lezer(self):
|
||||
"""Test that terminal regex patterns are converted."""
|
||||
grammar = r'''
|
||||
NAME: /[a-zA-Z_][a-zA-Z0-9_]*/
|
||||
'''
|
||||
lezer = lark_to_lezer_grammar(grammar)
|
||||
|
||||
assert "NAME" in lezer
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"terminal,pattern",
|
||||
[
|
||||
('BOOLEAN: "True" | "False"', "BOOLEAN"),
|
||||
('KEYWORD: "if"', "KEYWORD"),
|
||||
],
|
||||
)
|
||||
def test_i_can_convert_terminal_strings_to_lezer(self, terminal, pattern):
|
||||
"""Test that terminal string literals are converted."""
|
||||
grammar = f"start: test\n{terminal}"
|
||||
lezer = lark_to_lezer_grammar(grammar)
|
||||
|
||||
assert pattern in lezer
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user