14 Commits

113 changed files with 14838 additions and 957 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

540
docs/DataGrid Formatting.md Normal file
View 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
View 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
View 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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ dependencies = [
"uvloop",
"watchfiles",
"websockets",
"lark",
]
[project.urls]

View File

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

View File

@@ -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):
"""

View 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
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
src/myfasthtml/assets/lint.min.css vendored Normal file
View 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
View 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)})});

View File

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

View 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)})});

View 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}

File diff suppressed because one or more lines are too long

View File

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

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

View File

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

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

View File

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

View File

@@ -0,0 +1,7 @@
from myfasthtml.controls.DslEditor import DslEditor
class DataGridFormattingEditor(DslEditor):
def on_dsl_change(self, dsl):
pass

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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}"
)

View File

@@ -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}",
)

View File

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

View File

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

View 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()}

View File

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

View File

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

View 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)

View File

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

View File

View 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)

View 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__

View 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"])
"""
...

View 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),
}

View 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 = ""

View 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

View 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 = {}

View File

@@ -0,0 +1 @@
# Formatting module for DataGrid

View 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

View 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

View 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",
]

View 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

View 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)

View 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"),
]

View 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
"""
...

View 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 []

View 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

View 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 ""))

View 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
"""

View 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

View 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

View 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

View 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]

View 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

View 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",
},
}

View 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()) + ";"

View File

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

View File

@@ -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=}")

View File

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

View File

@@ -48,4 +48,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -69,4 +69,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -30,4 +30,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -44,4 +44,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -37,4 +37,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -43,4 +43,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -44,4 +44,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -30,4 +30,4 @@ def get():
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)
serve(port=5010)

View File

@@ -26,4 +26,4 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View File

@@ -25,4 +25,4 @@ def index():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View 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)

View File

@@ -12,4 +12,4 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)

View File

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

View File

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

View 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"

View File

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

View File

View 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

View 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