Compare commits
61 Commits
master
...
b09763b1eb
| Author | SHA1 | Date | |
|---|---|---|---|
| b09763b1eb | |||
| 5724c96917 | |||
| 70915b2691 | |||
| f3e19743c8 | |||
| 27f12b2c32 | |||
| 789c06b842 | |||
| e8443f07f9 | |||
| 0df78c0513 | |||
| fe322300c1 | |||
| 520a8914fc | |||
| 79c37493af | |||
| b0d565589a | |||
| 0119f54f11 | |||
| d44e0a0c01 | |||
| 3ec994d6df | |||
| 85f5d872c8 | |||
| 86b80b04f7 | |||
| 8e059df68a | |||
| fc38196ad9 | |||
| 6160e91665 | |||
| 08c8c00e28 | |||
| 3fc4384251 | |||
| ab4f251f0c | |||
| 1c1ced2a9f | |||
| db1e94f930 | |||
| 0620cb678b | |||
| d7ec99c3d9 | |||
| 778e5ac69d | |||
| 9abb9dddfe | |||
| 3083f3b1fd | |||
| 05d4e5cd89 | |||
| e31d9026ce | |||
| 3abfab8e97 | |||
| 7f3e6270a2 | |||
| 0bd56c7f09 | |||
| 3c2c07ebfc | |||
| 06e81fe72a | |||
| ba2b6e672a | |||
| 191ead1c89 | |||
| 872d110f07 | |||
| ca40333742 | |||
| 346b9632c6 | |||
| 509a7b7778 | |||
| 500340fbd3 | |||
| d909f2125d | |||
| 5d6c02001e | |||
| a9eb23ad76 | |||
| 47848bb2fd | |||
| 5201858b79 | |||
| 797883dac8 | |||
| 70abf21c14 | |||
| 2f808ed226 | |||
| 9f69a6bc5b | |||
| 81a80a47b6 | |||
| 1347f12618 | |||
| b26abc4257 | |||
| 045f01b48a | |||
| 3aa36a91aa | |||
| dc5ec450f0 | |||
| fde2e85c92 | |||
| 05067515d6 |
442
.claude/commands/developer-control.md
Normal file
442
.claude/commands/developer-control.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Developer Control Mode
|
||||
|
||||
You are now in **Developer Control Mode** - specialized mode for developing UI controls in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Create robust, consistent UI controls by following the established patterns and rules of the project.
|
||||
|
||||
## Control Development Rules (DEV-CONTROL)
|
||||
|
||||
### DEV-CONTROL-01: Class Inheritance
|
||||
|
||||
A control must inherit from one of the three base classes based on its usage:
|
||||
|
||||
| Class | Usage | Example |
|
||||
|-------|-------|---------|
|
||||
| `MultipleInstance` | Multiple instances possible per session | `DataGrid`, `Panel`, `Search` |
|
||||
| `SingleInstance` | One instance per session | `Layout`, `UserProfile`, `CommandsDebugger` |
|
||||
| `UniqueInstance` | One instance, but `__init__` called each time | (special case) |
|
||||
|
||||
```python
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-02: Nested Commands Class
|
||||
|
||||
Each interactive control must define a `Commands` class inheriting from `BaseCommands`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def my_action(self):
|
||||
return Command("MyAction",
|
||||
"Description of the action",
|
||||
self._owner,
|
||||
self._owner.my_action_handler
|
||||
).htmx(target=f"#{self._id}")
|
||||
```
|
||||
|
||||
**Conventions**:
|
||||
- Method name in `snake_case`
|
||||
- First `Command` argument: unique name (PascalCase recommended)
|
||||
- Use `self._owner` to reference the parent control
|
||||
- Use `self._id` for HTMX targets
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-03: State Management with DbObject
|
||||
|
||||
Persistent state must be encapsulated in a class inheriting from `DbObject`:
|
||||
|
||||
```python
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
|
||||
class MyControlState(DbObject):
|
||||
def __init__(self, owner, save_state=True):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
# Persisted attributes
|
||||
self.visible: bool = True
|
||||
self.width: int = 250
|
||||
|
||||
# NOT persisted (ns_ prefix)
|
||||
self.ns_temporary_data = None
|
||||
|
||||
# NOT saved but evaluated (ne_ prefix)
|
||||
self.ne_computed_value = None
|
||||
```
|
||||
|
||||
**Special prefixes**:
|
||||
- `ns_` (no-save): not persisted to database
|
||||
- `ne_` (no-equality): not compared for change detection
|
||||
- `_`: internal variables, ignored
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-04: render() and __ft__() Methods
|
||||
|
||||
Each control must implement:
|
||||
|
||||
```python
|
||||
def render(self):
|
||||
return Div(
|
||||
# Control content
|
||||
id=self._id,
|
||||
cls="mf-my-control"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- `render()` contains the rendering logic
|
||||
- `__ft__()` simply delegates to `render()`
|
||||
- Root element must have `id=self._id`
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-05: Control Initialization
|
||||
|
||||
Standard initialization structure:
|
||||
|
||||
```python
|
||||
def __init__(self, parent, _id=None, **kwargs):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
# 1. State
|
||||
self._state = MyControlState(self)
|
||||
|
||||
# 2. Commands
|
||||
self.commands = Commands(self)
|
||||
|
||||
# 3. Sub-components
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._search = Search(self, _id="-search")
|
||||
|
||||
# 4. Command bindings
|
||||
self._search.bind_command("Search", self.commands.on_search())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-06: Relative IDs for Sub-components
|
||||
|
||||
Use the `-` prefix to create IDs relative to the parent:
|
||||
|
||||
```python
|
||||
# Results in: "{parent_id}-panel"
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
|
||||
# Results in: "{parent_id}-search"
|
||||
self._search = Search(self, _id="-search")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-07: Using the mk Helper Class
|
||||
|
||||
Use `mk` helpers to create interactive elements:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Button with command
|
||||
mk.button("Click me", command=self.commands.my_action())
|
||||
|
||||
# Icon with command and tooltip
|
||||
mk.icon(my_icon, command=self.commands.toggle(), tooltip="Toggle")
|
||||
|
||||
# Label with icon
|
||||
mk.label("Title", icon=my_icon, size="sm")
|
||||
|
||||
# Generic wrapper
|
||||
mk.mk(Input(...), command=self.commands.on_input())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-08: Logging
|
||||
|
||||
Each control must declare a logger with its name:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("MyControl")
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def my_action(self):
|
||||
logger.debug(f"my_action called with {param=}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-09: Command Binding Between Components
|
||||
|
||||
To link a sub-component's actions to the parent control:
|
||||
|
||||
```python
|
||||
# In the parent control
|
||||
self._child = ChildControl(self, _id="-child")
|
||||
self._child.bind_command("ChildAction", self.commands.on_child_action())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-10: Keyboard and Mouse Composition
|
||||
|
||||
For interactive controls, compose `Keyboard` and `Mouse`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-11: Partial Rendering
|
||||
|
||||
For HTMX updates, implement partial rendering methods:
|
||||
|
||||
```python
|
||||
def render_partial(self, fragment="default"):
|
||||
if fragment == "body":
|
||||
return self._mk_body()
|
||||
elif fragment == "header":
|
||||
return self._mk_header()
|
||||
return self._mk_default()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-12: Simple State (Non-Persisted)
|
||||
|
||||
For simple state without DB persistence, use a basic Python class:
|
||||
|
||||
```python
|
||||
class MyControlState:
|
||||
def __init__(self):
|
||||
self.opened = False
|
||||
self.selected = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-13: Dataclasses for Configurations
|
||||
|
||||
Use dataclasses for configurations:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class MyControlConf:
|
||||
title: str = "Default"
|
||||
show_header: bool = True
|
||||
width: Optional[int] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-14: Generated ID Prefixes
|
||||
|
||||
Use short, meaningful prefixes for sub-elements:
|
||||
|
||||
```python
|
||||
f"tb_{self._id}" # table body
|
||||
f"th_{self._id}" # table header
|
||||
f"sn_{self._id}" # sheet name
|
||||
f"fi_{self._id}" # file input
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-15: State Getters
|
||||
|
||||
Expose state via getter methods:
|
||||
|
||||
```python
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def get_selected(self):
|
||||
return self._state.selected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-16: Computed Properties
|
||||
|
||||
Use `@property` for frequent access:
|
||||
|
||||
```python
|
||||
@property
|
||||
def width(self):
|
||||
return self._state.width
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-17: JavaScript Initialization Scripts
|
||||
|
||||
If the control requires JavaScript, include it in the render:
|
||||
|
||||
```python
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Script(f"initMyControl('{self._id}');"),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-18: CSS Classes with Prefix
|
||||
|
||||
Use the `mf-` prefix for custom CSS classes:
|
||||
|
||||
```python
|
||||
cls="mf-my-control"
|
||||
cls="mf-my-control-header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-19: Sub-element Creation Methods
|
||||
|
||||
Prefix creation methods with `_mk_` or `mk_`:
|
||||
|
||||
```python
|
||||
def _mk_header(self):
|
||||
"""Private creation method"""
|
||||
return Div(...)
|
||||
|
||||
def mk_content(self):
|
||||
"""Public creation method (reusable)"""
|
||||
return Div(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Control Template
|
||||
|
||||
```python
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("MyControl")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyControlConf:
|
||||
title: str = "Default"
|
||||
show_header: bool = True
|
||||
|
||||
|
||||
class MyControlState(DbObject):
|
||||
def __init__(self, owner, save_state=True):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
self.visible: bool = True
|
||||
self.ns_temp_data = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle(self):
|
||||
return Command("Toggle",
|
||||
"Toggle visibility",
|
||||
self._owner,
|
||||
self._owner.toggle
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class MyControl(MultipleInstance):
|
||||
def __init__(self, parent, conf: Optional[MyControlConf] = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or MyControlConf()
|
||||
self._state = MyControlState(self)
|
||||
self.commands = Commands(self)
|
||||
|
||||
logger.debug(f"MyControl created with id={self._id}")
|
||||
|
||||
def toggle(self):
|
||||
self._state.visible = not self._state.visible
|
||||
return self
|
||||
|
||||
def _mk_header(self):
|
||||
return Div(
|
||||
mk.label(self.conf.title),
|
||||
mk.icon(toggle_icon, command=self.commands.toggle()),
|
||||
cls="mf-my-control-header"
|
||||
)
|
||||
|
||||
def _mk_content(self):
|
||||
if not self._state.visible:
|
||||
return None
|
||||
return Div("Content here", cls="mf-my-control-content")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_header() if self.conf.show_header else None,
|
||||
self._mk_content(),
|
||||
Script(f"initMyControl('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-my-control"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
- "Disable DEV-CONTROL-08" (do not apply the logging rule)
|
||||
- "Enable DEV-CONTROL-08" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer` to switch to general development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
@@ -237,6 +237,7 @@ For detailed architecture and patterns, refer to CLAUDE.md in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer-control` to switch to control development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch unit testing mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -10,4 +10,6 @@ Refer to CLAUDE.md for project-specific architecture and patterns.
|
||||
|
||||
You can switch to specialized modes:
|
||||
- `/developer` - Full development mode with validation workflow
|
||||
- `/developer-control` - Control development mode with DEV-CONTROL rules
|
||||
- `/technical-writer` - User documentation writing mode
|
||||
- `/unit-tester` - Unit testing mode
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# Technical Writer Persona
|
||||
# Technical Writer Mode
|
||||
|
||||
You are now acting as a **Technical Writer** specialized in user-facing documentation.
|
||||
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
|
||||
|
||||
## Your Role
|
||||
## Primary Objective
|
||||
|
||||
Create comprehensive user documentation by:
|
||||
|
||||
1. Reading the source code to understand the component
|
||||
2. Proposing structure for validation
|
||||
3. Writing documentation following established patterns
|
||||
4. Requesting feedback after completion
|
||||
|
||||
## What You Handle
|
||||
|
||||
Focus on creating and improving **user documentation** for the MyFastHtml library:
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
@@ -18,47 +26,317 @@ Focus on creating and improving **user documentation** for the MyFastHtml librar
|
||||
- Code comments
|
||||
- CLAUDE.md (handled by developers)
|
||||
|
||||
## Documentation Principles
|
||||
## Technical Writer Rules (TW)
|
||||
|
||||
**Clarity First:**
|
||||
- Write for developers who are new to MyFastHtml
|
||||
- Explain the "why" not just the "what"
|
||||
- Use concrete, runnable examples
|
||||
- Progressive complexity (simple → advanced)
|
||||
### TW-1: Standard Documentation Structure
|
||||
|
||||
**Structure:**
|
||||
- Start with the problem being solved
|
||||
- Show minimal working example
|
||||
- Explain key concepts
|
||||
- Provide variations and advanced usage
|
||||
- Link to related documentation
|
||||
Every component documentation MUST follow this structure in order:
|
||||
|
||||
**Examples Must:**
|
||||
- Be complete and runnable
|
||||
- Include necessary imports
|
||||
- Show expected output when relevant
|
||||
- Use realistic variable names
|
||||
- Follow the project's code standards (PEP 8, snake_case, English)
|
||||
| Section | Purpose | Required |
|
||||
|---------|---------|----------|
|
||||
| **Introduction** | What it is, key features, common use cases | Yes |
|
||||
| **Quick Start** | Minimal working example | Yes |
|
||||
| **Basic Usage** | Visual structure, creation, configuration | Yes |
|
||||
| **Advanced Features** | Complex use cases, customization | If applicable |
|
||||
| **Examples** | 3-4 complete, practical examples | Yes |
|
||||
| **Developer Reference** | Technical details for component developers | Yes |
|
||||
|
||||
## Communication Style
|
||||
**Introduction template:**
|
||||
```markdown
|
||||
## Introduction
|
||||
|
||||
**Conversations:** French or English (match user's language)
|
||||
**Written documentation:** English only
|
||||
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
|
||||
|
||||
## Workflow
|
||||
**Key features:**
|
||||
|
||||
1. **Ask questions** to understand what needs documentation
|
||||
2. **Propose structure** before writing content
|
||||
3. **Wait for validation** before proceeding
|
||||
4. **Write incrementally** - one section at a time
|
||||
5. **Request feedback** after each section
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
## Style Evolution
|
||||
**Common use cases:**
|
||||
|
||||
The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time.
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
- Use case 3
|
||||
```
|
||||
|
||||
## Exiting This Persona
|
||||
**Quick Start template:**
|
||||
```markdown
|
||||
## Quick Start
|
||||
|
||||
To return to normal mode:
|
||||
- Use `/developer` to switch to developer mode
|
||||
Here's a minimal example showing [what it does]:
|
||||
|
||||
\`\`\`python
|
||||
[Complete, runnable code]
|
||||
\`\`\`
|
||||
|
||||
This creates a complete [component] with:
|
||||
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
|
||||
**Note:** [Important default behavior or tip]
|
||||
```
|
||||
|
||||
### TW-2: Visual Structure Diagrams
|
||||
|
||||
**Principle:** Include ASCII diagrams to illustrate component structure.
|
||||
|
||||
**Use box-drawing characters:** `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼`
|
||||
|
||||
**Example for a dropdown:**
|
||||
```
|
||||
Closed state:
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
└──────────────┘
|
||||
|
||||
Open state (position="below", align="left"):
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
├──────────────┴─────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Label all important elements
|
||||
- Show different states when relevant (open/closed, visible/hidden)
|
||||
- Keep diagrams simple and focused
|
||||
- Use comments in diagrams when needed
|
||||
|
||||
### TW-3: Component Details Tables
|
||||
|
||||
**Principle:** Use markdown tables to summarize information.
|
||||
|
||||
**Component elements table:**
|
||||
```markdown
|
||||
| Element | Description |
|
||||
|---------------|-----------------------------------------------|
|
||||
| Left panel | Optional collapsible panel (default: visible) |
|
||||
| Main content | Always-visible central content area |
|
||||
```
|
||||
|
||||
**Constructor parameters table:**
|
||||
```markdown
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
| `position` | str | Vertical position: "below"/"above" | `"below"` |
|
||||
```
|
||||
|
||||
**State properties table:**
|
||||
```markdown
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
```
|
||||
|
||||
**CSS classes table:**
|
||||
```markdown
|
||||
| Class | Element |
|
||||
|-----------------------|---------------------------------------|
|
||||
| `mf-dropdown-wrapper` | Container with relative positioning |
|
||||
| `mf-dropdown` | Dropdown content panel |
|
||||
```
|
||||
|
||||
**Commands table:**
|
||||
```markdown
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
| `click()` | Handles click events (toggle or close behavior) |
|
||||
```
|
||||
|
||||
### TW-4: Code Examples Standards
|
||||
|
||||
**All code examples must:**
|
||||
|
||||
1. **Be complete and runnable** - Include all necessary imports
|
||||
2. **Use realistic variable names** - Not `foo`, `bar`, `x`
|
||||
3. **Follow PEP 8** - snake_case, proper indentation
|
||||
4. **Include comments** - Only when clarifying non-obvious logic
|
||||
|
||||
**Standard imports block:**
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.ComponentName import ComponentName
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
```
|
||||
|
||||
**Example with commands:**
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Define action
|
||||
def do_something():
|
||||
return "Result"
|
||||
|
||||
# Create command
|
||||
cmd = Command("action", "Description", do_something)
|
||||
|
||||
# Create component with command
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu", cls="btn"),
|
||||
content=Div(
|
||||
mk.button("Action", command=cmd, cls="btn btn-ghost")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
- Incomplete snippets without imports
|
||||
- Abstract examples without context
|
||||
- `...` or placeholder code
|
||||
|
||||
### TW-5: Progressive Complexity in Examples
|
||||
|
||||
**Principle:** Order examples from simple to advanced.
|
||||
|
||||
**Example naming pattern:**
|
||||
```markdown
|
||||
### Example 1: [Simple Use Case]
|
||||
[Most basic, common usage]
|
||||
|
||||
### Example 2: [Intermediate Use Case]
|
||||
[Common variation or configuration]
|
||||
|
||||
### Example 3: [Advanced Use Case]
|
||||
[Complex scenario or customization]
|
||||
|
||||
### Example 4: [Integration Example]
|
||||
[Combined with other components or commands]
|
||||
```
|
||||
|
||||
**Each example must include:**
|
||||
- Descriptive title
|
||||
- Brief explanation of what it demonstrates
|
||||
- Complete, runnable code
|
||||
- Comments for non-obvious parts
|
||||
|
||||
### TW-6: Developer Reference Section
|
||||
|
||||
**Principle:** Include technical details for developers working on the component.
|
||||
|
||||
**Required subsections:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the [Component] component itself.
|
||||
|
||||
### State
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
|
||||
### Commands
|
||||
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|------------|----------------------------|----------------------|
|
||||
| `toggle()` | Toggles open/closed state | Content tuple |
|
||||
| `render()` | Renders complete component | `Div` |
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
\`\`\`
|
||||
Div(id="{id}")
|
||||
├── Div(cls="wrapper")
|
||||
│ ├── Div(cls="button")
|
||||
│ │ └── [Button content]
|
||||
│ └── Div(id="{id}-content")
|
||||
│ └── [Content]
|
||||
└── Script
|
||||
\`\`\`
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|--------------------------------|
|
||||
| `{id}` | Root container |
|
||||
| `{id}-content` | Content panel |
|
||||
|
||||
**Note:** `{id}` is the instance ID (auto-generated or custom `_id`).
|
||||
|
||||
### Internal Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------|------------------------------------------|
|
||||
| `_mk_content()` | Renders the content panel |
|
||||
```
|
||||
|
||||
### TW-7: Communication Language
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Written documentation**: English only
|
||||
|
||||
**No emojis** in documentation unless explicitly requested.
|
||||
|
||||
### TW-8: Question-Driven Collaboration
|
||||
|
||||
**Ask questions to clarify understanding:**
|
||||
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/3" if multiple questions are needed
|
||||
- Never assume - always clarify ambiguities
|
||||
|
||||
### TW-9: Documentation Workflow
|
||||
|
||||
1. **Receive request** - User specifies component/feature to document
|
||||
2. **Read source code** - Understand implementation thoroughly
|
||||
3. **Propose structure** - Present outline with sections
|
||||
4. **Wait for validation** - Get approval before writing
|
||||
5. **Write documentation** - Follow all TW rules
|
||||
6. **Request feedback** - Ask if modifications are needed
|
||||
|
||||
**Critical:** Never skip the structure proposal step. Always get validation before writing.
|
||||
|
||||
### TW-10: File Location
|
||||
|
||||
Documentation files are created in the `docs/` folder:
|
||||
- Component docs: `docs/ComponentName.md`
|
||||
- Feature docs: `docs/Feature Name.md`
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable TW-2" (do not include ASCII diagrams)
|
||||
- "Enable TW-2" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and component patterns, refer to `CLAUDE.md` in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer` to switch to development mode
|
||||
- Use `/developer-control` to switch to control development mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
|
||||
@@ -201,190 +201,351 @@ class TestControlRender:
|
||||
|
||||
### UTR-11: Required Reading for Control Render Tests
|
||||
|
||||
**Before writing ANY render tests for Controls, you MUST:**
|
||||
---
|
||||
|
||||
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
|
||||
2. **Understand the key concepts**:
|
||||
- How `matches()` and `find()` work
|
||||
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
|
||||
- How to test only what matters (not every detail)
|
||||
- How to read error messages with `^^^` markers
|
||||
3. **Apply the best practices** detailed below
|
||||
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
|
||||
|
||||
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
|
||||
|
||||
**Mandatory reading:** `docs/testing_rendered_components.md`
|
||||
|
||||
**What you must master:**
|
||||
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
|
||||
- **`find(ft, expected)`** - How to search for elements within an HTML tree
|
||||
- **Predicates** - How to test patterns instead of exact values:
|
||||
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
|
||||
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
|
||||
- **Error messages** - How to read `^^^` markers to understand differences
|
||||
- **Key principle** - Test only what matters, ignore the rest
|
||||
|
||||
**Without this reading, you cannot write correct render tests.**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)**
|
||||
### **TEST FILE STRUCTURE**
|
||||
|
||||
**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern.
|
||||
---
|
||||
|
||||
**Les trois étapes :**
|
||||
1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global
|
||||
2. **Définir la structure attendue** avec `expected = ...`
|
||||
3. **Comparer** avec `assert matches(element, expected)`
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
|
||||
**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure.
|
||||
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
|
||||
|
||||
**Exemple :**
|
||||
**Why:**
|
||||
- Gives immediate overview of the structure
|
||||
- Facilitates understanding for new contributors
|
||||
- Quickly detects major structural changes
|
||||
- Serves as living documentation of HTML architecture
|
||||
|
||||
**Test format:**
|
||||
```python
|
||||
def test_i_can_render_component_with_no_data(self, component):
|
||||
"""Test that Component renders with correct global structure."""
|
||||
html = component.render()
|
||||
expected = Div(
|
||||
Div(id=f"{component.get_id()}-controller"), # controller
|
||||
Div(id=f"{component.get_id()}-header"), # header
|
||||
Div(id=f"{component.get_id()}-content"), # content
|
||||
id=component.get_id(),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simple test with only IDs of main sections
|
||||
- Inline comments to identify each section
|
||||
- No detailed verification of attributes (classes, content, etc.)
|
||||
- This test must be the first in the `TestComponentRender` class
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.2: Break down complex tests into explicit steps**
|
||||
|
||||
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
|
||||
|
||||
**Why:**
|
||||
- Facilitates debugging (you know exactly which step fails)
|
||||
- Improves test readability
|
||||
- Allows validating structure level by level
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
||||
"""Test that content wrapper shows active tab content."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# Step 1: Validate wrapper global structure
|
||||
expected = Div(
|
||||
Div(), # tab content, tested in step 2
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper",
|
||||
cls=Contains("mf-tab-content-wrapper"),
|
||||
)
|
||||
assert matches(wrapper, expected)
|
||||
|
||||
# Step 2: Extract and validate specific content
|
||||
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
expected = Div(
|
||||
Div("My Content"), # <= actual content
|
||||
cls=Contains("mf-tab-content"),
|
||||
)
|
||||
assert matches(tab_content, expected)
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Step 1: Global structure with empty `Div()` + comment for children tested after
|
||||
- Step 2+: Extraction with `find_one()` + detailed validation
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.3: Three-step pattern for simple tests**
|
||||
|
||||
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
|
||||
|
||||
**The three steps:**
|
||||
1. **Extract the element to test** with `find_one()` or `find()` from the global render
|
||||
2. **Define the expected structure** with `expected = ...`
|
||||
3. **Compare** with `assert matches(element, expected)`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ BON - Pattern en trois étapes
|
||||
def test_header_has_two_sides(self, layout):
|
||||
"""Test that there is a left and right header section."""
|
||||
# Étape 1 : Extraire l'élément à tester
|
||||
# Step 1: Extract the element to test
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Étape 2 : Définir la structure attendue
|
||||
# Step 2: Define the expected structure
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Étape 3 : Comparer
|
||||
# Step 3: Compare
|
||||
assert matches(header, expected)
|
||||
|
||||
# ❌ À ÉVITER - Tout imbriqué en une ligne
|
||||
def test_header_has_two_sides(self, layout):
|
||||
assert matches(
|
||||
find_one(layout.render(), Header(cls=Contains("mf-layout-header"))),
|
||||
Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"))
|
||||
)
|
||||
```
|
||||
|
||||
**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental.
|
||||
---
|
||||
|
||||
### **HOW TO SEARCH FOR ELEMENTS**
|
||||
|
||||
---
|
||||
|
||||
#### **COMMENT CHERCHER LES ÉLÉMENTS**
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
|
||||
---
|
||||
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
|
||||
|
||||
#### **UTR-11.2 : Privilégier la recherche par ID**
|
||||
**Why:** More robust, faster, and targeted (an ID is unique).
|
||||
|
||||
**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut.
|
||||
|
||||
**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique).
|
||||
|
||||
**Exemple :**
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ BON - recherche par ID
|
||||
# ✅ GOOD - search by ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ À ÉVITER - recherche par classe quand un ID existe
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte**
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
|
||||
**Principe :**
|
||||
- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète
|
||||
- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence
|
||||
**Principle:**
|
||||
- `find_one()`: When you search for a unique element and want to test its complete structure
|
||||
- `find()`: When you search for multiple elements or want to count/verify their presence
|
||||
|
||||
**Exemples :**
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ BON - find_one pour structure unique
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ BON - find pour compter
|
||||
# ✅ GOOD - find for counting
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE**
|
||||
### **HOW TO SPECIFY EXPECTED STRUCTURE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`**
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
|
||||
**Principe :**
|
||||
- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`.
|
||||
- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`.
|
||||
**Principle:**
|
||||
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
|
||||
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
|
||||
|
||||
**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces.
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
|
||||
**Exemples :**
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ BON - Contains pour cls (une ou plusieurs classes)
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ BON - Contains pour style
|
||||
# ✅ GOOD - Contains for style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ À ÉVITER - test exact des classes
|
||||
# ❌ AVOID - exact class test
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ À ÉVITER - test exact du style complet
|
||||
# ❌ AVOID - exact complete style test
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône**
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
|
||||
**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu.
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
|
||||
|
||||
**Le paramètre `name` :**
|
||||
- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique
|
||||
- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle.
|
||||
- **JAMAIS `name="svg"`** : Cela causera des échecs de test
|
||||
**Difference between the two:**
|
||||
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
|
||||
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
|
||||
3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
|
||||
4. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
|
||||
**The `wrapper` parameter:**
|
||||
|
||||
Different `mk` helpers use different wrappers for icons:
|
||||
|
||||
| Helper method | Wrapper element | TestIcon usage |
|
||||
|---------------|-----------------|----------------|
|
||||
| `mk.icon(my_icon)` | `<div>` | `TestIcon("name")` |
|
||||
| `mk.label("Text", icon=my_icon)` | `<span>` | `TestIcon("name", wrapper="span")` |
|
||||
| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
|
||||
|
||||
**The `name` parameter:**
|
||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
||||
- **`name=""`** (empty string): Validates **any icon**
|
||||
|
||||
**Examples:**
|
||||
|
||||
**Exemples :**
|
||||
```python
|
||||
from myfasthtml.icons.fluent import panel_right_expand20_regular
|
||||
|
||||
# ✅ BON - Tester une icône spécifique
|
||||
# Example 1: Icon via mk.icon() - wrapper is Div (default)
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><svg .../></div>
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"),
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# ✅ BON - Tester la présence de n'importe quelle icône
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepte n'importe quelle icône
|
||||
cls=Contains("icon-wrapper")
|
||||
# Example 2: Icon via mk.label() - wrapper is Span
|
||||
# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
|
||||
# Rendered: <label><span><svg .../></span><span>Back</span></label>
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
|
||||
|
||||
# Example 3: Direct icon (used without helper)
|
||||
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||
# Rendered: <span><svg .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# ❌ À ÉVITER - name="svg"
|
||||
expected = Div(TestIcon("svg")) # ERREUR : causera un échec
|
||||
# Example 4: Verify any wrapped icon
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
cls=Contains("icon-wrapper")
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`:
|
||||
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
|
||||
2. Check if there's no wrapper at all → try `TestIconNotStr()`
|
||||
3. The error message will show you the actual structure
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript**
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
|
||||
**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet.
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
|
||||
|
||||
**Exemple :**
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ BON - TestScript avec fragment important
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ À ÉVITER - tester tout le contenu du script
|
||||
# ❌ AVOID - testing all script content
|
||||
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **COMMENT DOCUMENTER LES TESTS**
|
||||
#### **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".
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7 : Justifier le choix des éléments testés**
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
|
||||
**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ?
|
||||
---
|
||||
|
||||
**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**.
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
|
||||
**Exemples :**
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
@@ -418,33 +579,33 @@ def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
assert matches(drawer, expected)
|
||||
```
|
||||
|
||||
**Points clés :**
|
||||
- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.)
|
||||
- Pas besoin de suivre une formulation rigide
|
||||
- L'important est la **justification du choix**, pas le format
|
||||
**Key points:**
|
||||
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
|
||||
- No need to follow rigid wording
|
||||
- What matters is the **justification of the choice**, not the format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8 : Tests de comptage avec messages explicites**
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu.
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
|
||||
**Exemple :**
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ BON - message explicatif
|
||||
# ✅ GOOD - explanatory message
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
# ❌ À ÉVITER - pas de message
|
||||
# ❌ AVOID - no message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **AUTRES RÈGLES IMPORTANTES**
|
||||
### **OTHER IMPORTANT RULES**
|
||||
|
||||
---
|
||||
|
||||
@@ -455,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.7)
|
||||
- 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`)
|
||||
@@ -479,23 +640,29 @@ assert len(resizers) == 1
|
||||
|
||||
---
|
||||
|
||||
#### **Résumé : Les 8 règles UTR-11**
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
|
||||
**Pattern fondamental**
|
||||
- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
|
||||
**Comment chercher**
|
||||
- **UTR-11.2** : Privilégier recherche par ID
|
||||
- **UTR-11.3** : `find_one()` vs `find()` selon contexte
|
||||
**Test file structure**
|
||||
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
|
||||
- **UTR-11.2**: Break down complex tests into numbered steps
|
||||
- **UTR-11.3**: Three-step pattern for simple tests
|
||||
|
||||
**Comment spécifier**
|
||||
- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style`
|
||||
- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes
|
||||
- **UTR-11.6** : `TestScript()` pour JavaScript
|
||||
**How to search**
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
|
||||
**Comment documenter**
|
||||
- **UTR-11.7** : Justifier le choix des éléments testés
|
||||
- **UTR-11.8** : Messages explicites pour `assert len()`
|
||||
**How to specify**
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||
|
||||
**How to document**
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
---
|
||||
|
||||
@@ -503,19 +670,139 @@ 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.7)
|
||||
- Always include justification documentation (see UTR-11.10)
|
||||
|
||||
### UTR-12: Test Workflow
|
||||
---
|
||||
|
||||
### UTR-12: Analyze Execution Flow Before Writing Tests
|
||||
|
||||
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
|
||||
|
||||
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Test: "content_is_cached_after_first_retrieval"
|
||||
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
|
||||
Conclusion: Cache is already filled after create_tab, test would be redundant
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Identify the method being tested
|
||||
2. Trace all method calls it makes
|
||||
3. Identify state changes at each step
|
||||
4. Verify your assumptions about what the test should validate
|
||||
5. Only then write the test
|
||||
|
||||
---
|
||||
|
||||
### UTR-13: Prefer matches() for Content Verification
|
||||
|
||||
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
|
||||
|
||||
**Why:** More robust, clearer error messages, consistent with render test patterns.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ FRAGILE - string matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert "Tab not found" in str(result)
|
||||
|
||||
# ✅ ROBUST - structural matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert matches(result, Div('Tab not found.'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-14: Know FastHTML Attribute Names
|
||||
|
||||
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
|
||||
|
||||
**Key differences:**
|
||||
- Use `attrs.get('class')` not `attrs.get('cls')`
|
||||
- Use `attrs.get('id')` for the ID
|
||||
- Prefer `matches()` with predicates to avoid direct attribute access
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ WRONG - Python parameter name
|
||||
classes = element.attrs.get('cls', '') # Returns None or ''
|
||||
|
||||
# ✅ CORRECT - HTML attribute name
|
||||
classes = element.attrs.get('class', '') # Returns actual classes
|
||||
|
||||
# ✅ BETTER - Use predicates with matches()
|
||||
expected = Div(cls=Contains("active"))
|
||||
assert matches(element, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-15: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
3. **Analyze code** - Read and understand implementation
|
||||
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
5. **Propose test plan** - List new/missing tests with brief explanations
|
||||
6. **Wait for approval** - User validates the test plan
|
||||
7. **Implement tests** - Write all approved tests
|
||||
8. **Verify** - Ensure tests follow naming conventions and structure
|
||||
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
4. **Trace execution flow** - Apply UTR-12 to understand side effects
|
||||
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
6. **Propose test plan** - List new/missing tests with brief explanations
|
||||
7. **Wait for approval** - User validates the test plan
|
||||
8. **Implement tests** - Write all approved tests
|
||||
9. **Verify** - Ensure tests follow naming conventions and structure
|
||||
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
---
|
||||
|
||||
### UTR-16: Propose Parameterized Tests
|
||||
|
||||
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
|
||||
|
||||
**When to parameterize:**
|
||||
- Tests that follow the same pattern with different input values
|
||||
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
|
||||
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
|
||||
- Tests that validate the same method with different valid inputs
|
||||
|
||||
**How to identify candidates:**
|
||||
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
|
||||
2. Look for tests that have identical structure but different parameters
|
||||
3. Look for combinatorial scenarios (side × state combinations)
|
||||
|
||||
**How to propose:**
|
||||
In your test plan, explicitly show:
|
||||
1. The individual tests that would be written without parameterization
|
||||
2. The parameterized version with all test cases
|
||||
3. The reduction in test count
|
||||
|
||||
**Example proposal:**
|
||||
|
||||
```
|
||||
**Without parameterization (4 tests):**
|
||||
- test_i_can_toggle_left_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_left_panel_from_hidden_to_visible
|
||||
- test_i_can_toggle_right_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_right_panel_from_hidden_to_visible
|
||||
|
||||
**With parameterization (1 test, 4 cases):**
|
||||
@pytest.mark.parametrize("side, initial, expected", [
|
||||
("left", True, False),
|
||||
("left", False, True),
|
||||
("right", True, False),
|
||||
("right", False, True),
|
||||
])
|
||||
def test_i_can_toggle_panel_visibility(...)
|
||||
|
||||
**Result:** 1 test instead of 4, same coverage
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Reduces code duplication
|
||||
- Makes it easier to add new test cases
|
||||
- Improves maintainability
|
||||
- Makes the test matrix explicit
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
@@ -532,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
|
||||
|
||||
2611
.claude/fasthtml-llms-ctx.txt
Normal file
2611
.claude/fasthtml-llms-ctx.txt
Normal file
File diff suppressed because it is too large
Load Diff
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,6 +25,7 @@ tools.db
|
||||
.idea_bak
|
||||
**/*.prof
|
||||
**/*.db
|
||||
screenshot*
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Python template
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -108,6 +108,17 @@ Activates the full development workflow with:
|
||||
- Strict PEP 8 compliance
|
||||
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
|
||||
|
||||
### `/developer-control` - Control Development Mode
|
||||
**Use for:** Developing UI controls in the controls directory
|
||||
|
||||
Specialized mode with rules for:
|
||||
- Control class inheritance (`MultipleInstance`, `SingleInstance`, `UniqueInstance`)
|
||||
- Commands class pattern with `BaseCommands`
|
||||
- State management with `DbObject`
|
||||
- Rendering with `render()` and `__ft__()`
|
||||
- Helper usage (`mk.button`, `mk.icon`, `mk.label`)
|
||||
- Sub-component composition
|
||||
|
||||
### `/technical-writer` - Documentation Mode
|
||||
**Use for:** Writing user-facing documentation
|
||||
|
||||
@@ -173,7 +184,7 @@ pip install -e .
|
||||
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||
|
||||
**Key classes:**
|
||||
- `BaseCommand`: Base class for all commands with HTMX integration
|
||||
- `Command`: Base class for all commands with HTMX integration
|
||||
- `Command`: Standard command that executes a Python callable
|
||||
- `LambdaCommand`: Inline command for simple operations
|
||||
- `CommandsManager`: Global registry for command execution
|
||||
|
||||
7
Makefile
7
Makefile
@@ -20,10 +20,13 @@ clean-tests:
|
||||
rm -rf tests/*.db
|
||||
rm -rf tests/.myFastHtmlDb
|
||||
|
||||
clean-app:
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
# Alias to clean everything
|
||||
clean: clean-build clean-tests
|
||||
clean: clean-build clean-tests clean-app
|
||||
|
||||
clean-all : clean
|
||||
rm -rf src/.sesskey
|
||||
rm -rf src/Users.db
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
|
||||
|
||||
```
|
||||
@@ -86,7 +86,7 @@ def get_homepage():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
serve(port=5010)
|
||||
```
|
||||
|
||||
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
|
||||
@@ -957,3 +957,4 @@ user.find_element("textarea[name='message']")
|
||||
* 0.1.0 : First release
|
||||
* 0.2.0 : Updated to myauth 0.2.0
|
||||
* 0.3.0 : Added Bindings support
|
||||
* 0.4.0 : First version with Datagrid + new static file server
|
||||
|
||||
141
benchmarks/profile_datagrid.py
Executable file
141
benchmarks/profile_datagrid.py
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DataGrid Performance Profiling Script
|
||||
|
||||
Generates a 1000-row DataFrame and profiles the DataGrid.render() method
|
||||
to identify performance bottlenecks.
|
||||
|
||||
Usage:
|
||||
python benchmarks/profile_datagrid.py
|
||||
"""
|
||||
|
||||
import cProfile
|
||||
import pstats
|
||||
from io import StringIO
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
|
||||
|
||||
def generate_test_dataframe(rows=1000, cols=10):
|
||||
"""Generate a test DataFrame with mixed column types."""
|
||||
np.random.seed(42)
|
||||
|
||||
data = {
|
||||
'ID': range(rows),
|
||||
'Name': [f'Person_{i}' for i in range(rows)],
|
||||
'Email': [f'user{i}@example.com' for i in range(rows)],
|
||||
'Age': np.random.randint(18, 80, rows),
|
||||
'Salary': np.random.uniform(30000, 150000, rows),
|
||||
'Active': np.random.choice([True, False], rows),
|
||||
'Score': np.random.uniform(0, 100, rows),
|
||||
'Department': np.random.choice(['Sales', 'Engineering', 'Marketing', 'HR'], rows),
|
||||
'Country': np.random.choice(['France', 'USA', 'Germany', 'UK', 'Spain'], rows),
|
||||
'Rating': np.random.uniform(1.0, 5.0, rows),
|
||||
}
|
||||
|
||||
# Add extra columns if needed
|
||||
for i in range(cols - len(data)):
|
||||
data[f'Extra_Col_{i}'] = np.random.random(rows)
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
def profile_datagrid_render(df):
|
||||
"""Profile the DataGrid render method."""
|
||||
|
||||
# Clear instances to start fresh
|
||||
InstancesManager.instances.clear()
|
||||
|
||||
# Create a minimal session
|
||||
session = {
|
||||
"user_info": {
|
||||
"id": "test_tenant_id",
|
||||
"email": "test@email.com",
|
||||
"username": "test user",
|
||||
"role": [],
|
||||
}
|
||||
}
|
||||
|
||||
# Create root instance as parent
|
||||
root = SingleInstance(parent=None, session=session, _id="profile-root")
|
||||
|
||||
# Create DataGrid (parent, settings, save_state, _id)
|
||||
datagrid = DataGrid(root)
|
||||
datagrid.init_from_dataframe(df)
|
||||
|
||||
# Profile the render call
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
|
||||
# Execute render
|
||||
html_output = datagrid.render()
|
||||
|
||||
profiler.disable()
|
||||
|
||||
return profiler, html_output
|
||||
|
||||
|
||||
def print_profile_stats(profiler, top_n=30):
|
||||
"""Print formatted profiling statistics."""
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("PROFILING RESULTS - Top {} functions by cumulative time".format(top_n))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
stats.sort_stats('cumulative')
|
||||
stats.print_stats(top_n)
|
||||
|
||||
output = s.getvalue()
|
||||
print(output)
|
||||
|
||||
# Extract total time
|
||||
for line in output.split('\n'):
|
||||
if 'function calls' in line:
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(line)
|
||||
break
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Top 10 by total time spent (time * ncalls)")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
stats.sort_stats('tottime')
|
||||
stats.print_stats(10)
|
||||
print(s.getvalue())
|
||||
|
||||
|
||||
def main():
|
||||
print("Generating test DataFrame (1000 rows × 10 columns)...")
|
||||
df = generate_test_dataframe(rows=1000, cols=10)
|
||||
print(f"DataFrame shape: {df.shape}")
|
||||
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024:.2f} KB\n")
|
||||
|
||||
print("Profiling DataGrid.render()...")
|
||||
profiler, html_output = profile_datagrid_render(df)
|
||||
|
||||
print(f"\nHTML output length: {len(str(html_output))} characters")
|
||||
|
||||
print_profile_stats(profiler, top_n=30)
|
||||
|
||||
# Clean up instances
|
||||
InstancesManager.reset()
|
||||
|
||||
print("\n✅ Profiling complete!")
|
||||
print("\nNext steps:")
|
||||
print("1. Identify the slowest functions in the 'cumulative time' section")
|
||||
print("2. Look for functions called many times (high ncalls)")
|
||||
print("3. Focus optimization on high cumtime + high ncalls functions")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1259
docs/DataGrid Formatting - User Guide.md
Normal file
1259
docs/DataGrid Formatting - User Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
1542
docs/DataGrid Formatting System.md
Normal file
1542
docs/DataGrid Formatting System.md
Normal file
File diff suppressed because it is too large
Load Diff
601
docs/DataGrid.md
Normal file
601
docs/DataGrid.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# DataGrid Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The DataGrid component provides a high-performance tabular data display for your FastHTML application. It renders pandas
|
||||
DataFrames with interactive features like column resizing, reordering, and filtering, all powered by HTMX for seamless
|
||||
updates without page reloads.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Display tabular data from pandas DataFrames
|
||||
- Resizable columns with drag handles
|
||||
- Draggable columns for reordering
|
||||
- Real-time filtering with search bar
|
||||
- Virtual scrolling for large datasets (pagination with lazy loading)
|
||||
- Custom scrollbars for consistent cross-browser appearance
|
||||
- Optional state persistence per session
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Data exploration and analysis dashboards
|
||||
- Admin interfaces with tabular data
|
||||
- Report viewers
|
||||
- Database table browsers
|
||||
- CSV/Excel file viewers
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a data table with a pandas DataFrame:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create sample data
|
||||
df = pd.DataFrame({
|
||||
"Name": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"Age": [25, 30, 35, 28],
|
||||
"City": ["Paris", "London", "Berlin", "Madrid"]
|
||||
})
|
||||
|
||||
# Create root instance and data grid
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root)
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
# Render the grid
|
||||
return grid
|
||||
```
|
||||
|
||||
This creates a complete data grid with:
|
||||
|
||||
- A header row with column names ("Name", "Age", "City")
|
||||
- Data rows displaying the DataFrame content
|
||||
- A search bar for filtering data
|
||||
- Resizable column borders (drag to resize)
|
||||
- Draggable columns (drag headers to reorder)
|
||||
- Custom scrollbars for horizontal and vertical scrolling
|
||||
|
||||
**Note:** The DataGrid automatically detects column types (Text, Number, Bool, Datetime) from the DataFrame dtypes and
|
||||
applies appropriate formatting.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The DataGrid component consists of a filter bar, a table with header/body/footer, and custom scrollbars:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Filter Bar │
|
||||
│ ┌─────────────────────────────────────────────┐ ┌────┐ │
|
||||
│ │ 🔍 Search... │ │ ✕ │ │
|
||||
│ └─────────────────────────────────────────────┘ └────┘ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ Header Row ▲ │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────┐ │ │
|
||||
│ │ Column 1 │ Column 2 │ Column 3 │ Column 4 │ █ │
|
||||
│ └──────────┴──────────┴──────────┴──────────┘ █ │
|
||||
├────────────────────────────────────────────────────────█───┤
|
||||
│ Body (scrollable) █ │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────┐ █ │
|
||||
│ │ Value │ Value │ Value │ Value │ █ │
|
||||
│ ├──────────┼──────────┼──────────┼──────────┤ │ │
|
||||
│ │ Value │ Value │ Value │ Value │ │ │
|
||||
│ ├──────────┼──────────┼──────────┼──────────┤ ▼ │
|
||||
│ │ Value │ Value │ Value │ Value │ │
|
||||
│ └──────────┴──────────┴──────────┴──────────┘ │
|
||||
│ ◄═══════════════════════════════════════════════════════► │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|------------|-------------------------------------------------------|
|
||||
| Filter bar | Search input with filter mode toggle and clear button |
|
||||
| Header row | Column names with resize handles and drag support |
|
||||
| Body | Scrollable data rows with virtual pagination |
|
||||
| Scrollbars | Custom vertical and horizontal scrollbars |
|
||||
|
||||
### Creating a DataGrid
|
||||
|
||||
The DataGrid is a `MultipleInstance`, meaning you can create multiple independent grids in your application. Create it
|
||||
by providing a parent instance:
|
||||
|
||||
```python
|
||||
grid = DataGrid(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
grid = DataGrid(parent=root_instance, _id="my-grid")
|
||||
|
||||
# Or with state persistence enabled
|
||||
grid = DataGrid(parent=root_instance, save_state=True)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `parent`: Parent instance (required)
|
||||
- `_id` (str, optional): Custom identifier for the grid
|
||||
- `save_state` (bool, optional): Enable state persistence (column widths, order, filters)
|
||||
|
||||
### Loading Data
|
||||
|
||||
Use the `init_from_dataframe()` method to load data into the grid:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
# Create a DataFrame
|
||||
df = pd.DataFrame({
|
||||
"Product": ["Laptop", "Phone", "Tablet"],
|
||||
"Price": [999.99, 699.99, 449.99],
|
||||
"In Stock": [True, False, True]
|
||||
})
|
||||
|
||||
# Load into grid
|
||||
grid.init_from_dataframe(df)
|
||||
```
|
||||
|
||||
**Column type detection:**
|
||||
|
||||
The DataGrid automatically detects column types from pandas dtypes:
|
||||
|
||||
| pandas dtype | DataGrid type | Display |
|
||||
|--------------------|---------------|-------------------------|
|
||||
| `int64`, `float64` | Number | Right-aligned |
|
||||
| `bool` | Bool | Checkbox icon |
|
||||
| `datetime64` | Datetime | Formatted date |
|
||||
| `object`, others | Text | Left-aligned, truncated |
|
||||
|
||||
### Row Index Column
|
||||
|
||||
By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows:
|
||||
|
||||
```python
|
||||
# Row index is enabled by default
|
||||
grid._state.row_index = True
|
||||
|
||||
# To disable the row index column
|
||||
grid._state.row_index = False
|
||||
grid.init_from_dataframe(df)
|
||||
```
|
||||
|
||||
## Column Features
|
||||
|
||||
### Resizing Columns
|
||||
|
||||
Users can resize columns by dragging the border between column headers:
|
||||
|
||||
- **Drag handle location**: Right edge of each column header
|
||||
- **Minimum width**: 30 pixels
|
||||
- **Persistence**: Resized widths are automatically saved when `save_state=True`
|
||||
|
||||
The resize interaction:
|
||||
|
||||
1. Hover over the right edge of a column header (cursor changes)
|
||||
2. Click and drag to resize
|
||||
3. Release to confirm the new width
|
||||
4. Double-click to reset to default width
|
||||
|
||||
**Programmatic width control:**
|
||||
|
||||
```python
|
||||
# Set a specific column width
|
||||
for col in grid._state.columns:
|
||||
if col.col_id == "my_column":
|
||||
col.width = 200 # pixels
|
||||
break
|
||||
```
|
||||
|
||||
### Moving Columns
|
||||
|
||||
Users can reorder columns by dragging column headers:
|
||||
|
||||
1. Click and hold a column header
|
||||
2. Drag to the desired position
|
||||
3. Release to drop the column
|
||||
|
||||
The columns animate smoothly during the move, and other columns shift to accommodate the new position.
|
||||
|
||||
**Note:** Column order is persisted when `save_state=True`.
|
||||
|
||||
### Column Visibility
|
||||
|
||||
Columns can be hidden programmatically:
|
||||
|
||||
```python
|
||||
# Hide a specific column
|
||||
for col in grid._state.columns:
|
||||
if col.col_id == "internal_id":
|
||||
col.visible = False
|
||||
break
|
||||
```
|
||||
|
||||
Hidden columns are not rendered but remain in the state, allowing them to be shown again later.
|
||||
|
||||
## Filtering
|
||||
|
||||
### Using the Search Bar
|
||||
|
||||
The DataGrid includes a built-in search bar that filters rows in real-time:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐ ┌────┐
|
||||
│ 🔍 Search... │ │ ✕ │
|
||||
└─────────────────────────────────────────────┘ └────┘
|
||||
│ │
|
||||
│ └── Clear button
|
||||
└── Filter mode icon (click to cycle)
|
||||
```
|
||||
|
||||
**How filtering works:**
|
||||
|
||||
1. Type in the search box
|
||||
2. The grid filters rows where ANY visible column contains the search text
|
||||
3. Matching text is highlighted in the results
|
||||
4. Click the ✕ button to clear the filter
|
||||
|
||||
### Filter Modes
|
||||
|
||||
Click the filter icon to cycle through three modes:
|
||||
|
||||
| Mode | Icon | Description |
|
||||
|------------|------|------------------------------------|
|
||||
| **Filter** | 🔍 | Hides non-matching rows |
|
||||
| **Search** | 🔎 | Highlights matches, shows all rows |
|
||||
| **AI** | 🧠 | AI-powered search (future feature) |
|
||||
|
||||
The current mode affects how results are displayed:
|
||||
|
||||
- **Filter mode**: Only matching rows are shown
|
||||
- **Search mode**: All rows shown, matches highlighted
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### State Persistence
|
||||
|
||||
Enable state persistence to save user preferences across sessions:
|
||||
|
||||
```python
|
||||
# Enable state persistence
|
||||
grid = DataGrid(parent=root, save_state=True)
|
||||
```
|
||||
|
||||
**What gets persisted:**
|
||||
|
||||
| State | Description |
|
||||
|-------------------|---------------------------------|
|
||||
| Column widths | User-resized column sizes |
|
||||
| Column order | User-defined column arrangement |
|
||||
| Column visibility | Which columns are shown/hidden |
|
||||
| Sort order | Current sort configuration |
|
||||
| Filter state | Active filters |
|
||||
|
||||
### Virtual Scrolling
|
||||
|
||||
For large datasets, the DataGrid uses virtual scrolling with lazy loading:
|
||||
|
||||
- Only a subset of rows (page) is rendered initially
|
||||
- As the user scrolls down, more rows are loaded automatically
|
||||
- Uses Intersection Observer API for efficient scroll detection
|
||||
- Default page size: configurable via `DATAGRID_PAGE_SIZE`
|
||||
|
||||
This allows smooth performance even with thousands of rows.
|
||||
|
||||
### Text Size
|
||||
|
||||
Customize the text size for the grid body:
|
||||
|
||||
```python
|
||||
# Available sizes: "xs", "sm", "md", "lg"
|
||||
grid._settings.text_size = "sm" # default
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The DataGrid uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|-----------------------------|-------------------------|
|
||||
| `dt2-table-wrapper` | Root table container |
|
||||
| `dt2-table` | Table element |
|
||||
| `dt2-header-container` | Header wrapper |
|
||||
| `dt2-body-container` | Scrollable body wrapper |
|
||||
| `dt2-footer-container` | Footer wrapper |
|
||||
| `dt2-row` | Table row |
|
||||
| `dt2-cell` | Table cell |
|
||||
| `dt2-resize-handle` | Column resize handle |
|
||||
| `dt2-scrollbars-vertical` | Vertical scrollbar |
|
||||
| `dt2-scrollbars-horizontal` | Horizontal scrollbar |
|
||||
| `dt2-highlight-1` | Search match highlight |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change highlight color */
|
||||
.dt2-highlight-1 {
|
||||
background-color: #fef08a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Customize row hover */
|
||||
.dt2-row:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Style the scrollbars */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Data Table
|
||||
|
||||
A basic data table displaying product information:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Sample product data
|
||||
df = pd.DataFrame({
|
||||
"Product": ["Laptop Pro", "Wireless Mouse", "USB-C Hub", "Monitor 27\"", "Keyboard"],
|
||||
"Category": ["Computers", "Accessories", "Accessories", "Displays", "Accessories"],
|
||||
"Price": [1299.99, 49.99, 79.99, 399.99, 129.99],
|
||||
"In Stock": [True, True, False, True, True],
|
||||
"Rating": [4.5, 4.2, 4.8, 4.6, 4.3]
|
||||
})
|
||||
|
||||
# Create and configure grid
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root, _id="products-grid")
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
# Render
|
||||
return Div(
|
||||
H1("Product Catalog"),
|
||||
grid,
|
||||
cls="p-4"
|
||||
)
|
||||
```
|
||||
|
||||
### Example 2: Large Dataset with Filtering
|
||||
|
||||
Handling a large dataset with virtual scrolling and filtering:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Generate large dataset (10,000 rows)
|
||||
np.random.seed(42)
|
||||
n_rows = 10000
|
||||
|
||||
df = pd.DataFrame({
|
||||
"ID": range(1, n_rows + 1),
|
||||
"Name": [f"Item_{i}" for i in range(n_rows)],
|
||||
"Value": np.random.uniform(10, 1000, n_rows).round(2),
|
||||
"Category": np.random.choice(["A", "B", "C", "D"], n_rows),
|
||||
"Active": np.random.choice([True, False], n_rows),
|
||||
"Created": pd.date_range("2024-01-01", periods=n_rows, freq="h")
|
||||
})
|
||||
|
||||
# Create grid with state persistence
|
||||
root = RootInstance(session)
|
||||
grid = DataGrid(parent=root, _id="large-dataset", save_state=True)
|
||||
grid.init_from_dataframe(df)
|
||||
|
||||
return Div(
|
||||
H1("Large Dataset Explorer"),
|
||||
P(f"Displaying {n_rows:,} rows with virtual scrolling"),
|
||||
grid,
|
||||
cls="p-4",
|
||||
style="height: 100vh;"
|
||||
)
|
||||
```
|
||||
|
||||
**Note:** Virtual scrolling loads rows on demand as you scroll, ensuring smooth performance even with 10,000+ rows.
|
||||
|
||||
### Example 3: Dashboard with Multiple Grids
|
||||
|
||||
An application with multiple data grids in different tabs:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create data for different views
|
||||
sales_df = pd.DataFrame({
|
||||
"Date": pd.date_range("2024-01-01", periods=30, freq="D"),
|
||||
"Revenue": [1000 + i * 50 for i in range(30)],
|
||||
"Orders": [10 + i for i in range(30)]
|
||||
})
|
||||
|
||||
customers_df = pd.DataFrame({
|
||||
"Customer": ["Acme Corp", "Tech Inc", "Global Ltd"],
|
||||
"Country": ["USA", "UK", "Germany"],
|
||||
"Total Spent": [15000, 12000, 8500]
|
||||
})
|
||||
|
||||
# Create instances
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="dashboard-tabs")
|
||||
|
||||
# Create grids
|
||||
sales_grid = DataGrid(parent=root, _id="sales-grid")
|
||||
sales_grid.init_from_dataframe(sales_df)
|
||||
|
||||
customers_grid = DataGrid(parent=root, _id="customers-grid")
|
||||
customers_grid.init_from_dataframe(customers_df)
|
||||
|
||||
# Add to tabs
|
||||
tabs.create_tab("Sales", sales_grid)
|
||||
tabs.create_tab("Customers", customers_grid)
|
||||
|
||||
return Div(
|
||||
H1("Sales Dashboard"),
|
||||
tabs,
|
||||
cls="p-4"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the DataGrid component itself.
|
||||
|
||||
### State
|
||||
|
||||
The DataGrid uses two state objects:
|
||||
|
||||
**DatagridState** - Main state for grid data and configuration:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-------------------|---------------------------|----------------------------|---------|
|
||||
| `sidebar_visible` | bool | Whether sidebar is visible | `False` |
|
||||
| `row_index` | bool | Show row index column | `True` |
|
||||
| `columns` | list[DataGridColumnState] | Column definitions | `[]` |
|
||||
| `rows` | list[DataGridRowState] | Row-specific states | `[]` |
|
||||
| `sorted` | list | Sort configuration | `[]` |
|
||||
| `filtered` | dict | Active filters | `{}` |
|
||||
| `selection` | DatagridSelectionState | Selection state | - |
|
||||
| `ne_df` | DataFrame | The data (non-persisted) | `None` |
|
||||
|
||||
**DatagridSettings** - User preferences:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------------------|------|--------------------|---------|
|
||||
| `save_state` | bool | Enable persistence | `False` |
|
||||
| `header_visible` | bool | Show header row | `True` |
|
||||
| `filter_all_visible` | bool | Show filter bar | `True` |
|
||||
| `text_size` | str | Body text size | `"sm"` |
|
||||
|
||||
### Column State
|
||||
|
||||
Each column is represented by `DataGridColumnState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-------------|------------|--------------------|---------|
|
||||
| `col_id` | str | Column identifier | - |
|
||||
| `col_index` | int | Index in DataFrame | - |
|
||||
| `title` | str | Display title | `None` |
|
||||
| `type` | ColumnType | Data type | `Text` |
|
||||
| `visible` | bool | Is column visible | `True` |
|
||||
| `usable` | bool | Is column usable | `True` |
|
||||
| `width` | int | Width in pixels | `150` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------|---------------------------------------------|
|
||||
| `get_page(page_index)` | Load a specific page of data (lazy loading) |
|
||||
| `set_column_width()` | Update column width after resize |
|
||||
| `move_column()` | Move column to new position |
|
||||
| `filter()` | Apply current filter to grid |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------------------|----------------------------------------|
|
||||
| `init_from_dataframe(df, init_state=True)` | Load data from pandas DataFrame |
|
||||
| `set_column_width(col_id, width)` | Set column width programmatically |
|
||||
| `move_column(source_col_id, target_col_id)` | Move column to new position |
|
||||
| `filter()` | Apply filter and return partial render |
|
||||
| `render()` | Render the complete grid |
|
||||
| `render_partial(fragment, redraw_scrollbars)` | Render only part of the grid |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="grid")
|
||||
├── Div (filter bar)
|
||||
│ └── DataGridQuery # Filter/search component
|
||||
├── Div(id="tw_{id}", cls="dt2-table-wrapper")
|
||||
│ ├── Div(id="t_{id}", cls="dt2-table")
|
||||
│ │ ├── Div (dt2-header-container)
|
||||
│ │ │ └── Div(id="th_{id}", cls="dt2-row dt2-header")
|
||||
│ │ │ ├── Div (dt2-cell) # Column 1 header
|
||||
│ │ │ ├── Div (dt2-cell) # Column 2 header
|
||||
│ │ │ └── ...
|
||||
│ │ ├── Div(id="tb_{id}", cls="dt2-body-container")
|
||||
│ │ │ └── Div (dt2-body)
|
||||
│ │ │ ├── Div (dt2-row) # Data row 1
|
||||
│ │ │ ├── Div (dt2-row) # Data row 2
|
||||
│ │ │ └── ...
|
||||
│ │ └── Div (dt2-footer-container)
|
||||
│ │ └── Div (dt2-row dt2-header) # Footer row
|
||||
│ └── Div (dt2-scrollbars)
|
||||
│ ├── Div (dt2-scrollbars-vertical-wrapper)
|
||||
│ │ └── Div (dt2-scrollbars-vertical)
|
||||
│ └── Div (dt2-scrollbars-horizontal-wrapper)
|
||||
│ └── Div (dt2-scrollbars-horizontal)
|
||||
└── Script # Initialization script
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Pattern | Description |
|
||||
|-----------------------|-------------------------------------|
|
||||
| `{id}` | Root grid container |
|
||||
| `tw_{id}` | Table wrapper (scrollbar container) |
|
||||
| `t_{id}` | Table element |
|
||||
| `th_{id}` | Header row |
|
||||
| `tb_{id}` | Body container |
|
||||
| `tf_{id}` | Footer row |
|
||||
| `tsm_{id}` | Selection Manager |
|
||||
| `tr_{id}-{row_index}` | Individual data row |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------------------------|----------------------------------------|
|
||||
| `mk_headers()` | Renders the header row |
|
||||
| `mk_body()` | Renders the body with first page |
|
||||
| `mk_body_container()` | Renders the scrollable body container |
|
||||
| `mk_body_content_page(page_index)` | Renders a specific page of rows |
|
||||
| `mk_body_cell(col_pos, row_index, col_def)` | Renders a single cell |
|
||||
| `mk_body_cell_content(...)` | Renders cell content with highlighting |
|
||||
| `mk_footers()` | Renders the footer row |
|
||||
| `mk_table()` | Renders the complete table structure |
|
||||
| `mk_aggregation_cell(...)` | Renders footer aggregation cell |
|
||||
| `_get_filtered_df()` | Returns filtered and sorted DataFrame |
|
||||
| `_apply_sort(df)` | Applies sort configuration |
|
||||
| `_apply_filter(df)` | Applies filter configuration |
|
||||
|
||||
### DataGridQuery Component
|
||||
|
||||
The filter bar is a separate component (`DataGridQuery`) with its own state:
|
||||
|
||||
| State Property | Type | Description | Default |
|
||||
|----------------|------|-----------------------------------------|------------|
|
||||
| `filter_type` | str | Current mode ("filter", "search", "ai") | `"filter"` |
|
||||
| `query` | str | Current search text | `None` |
|
||||
|
||||
**Commands:**
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|-----------------------------|
|
||||
| `change_filter_type()` | Cycle through filter modes |
|
||||
| `on_filter_changed()` | Handle search input changes |
|
||||
| `on_cancel_query()` | Clear the search query |
|
||||
365
docs/Datagrid Formulas.md
Normal file
365
docs/Datagrid Formulas.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# DataGrid Formulas
|
||||
|
||||
## Overview
|
||||
|
||||
The DataGrid formula system adds computed columns to the DataGrid. A formula column applies a single expression to every
|
||||
row, producing derived values from existing data — within the same table or across tables.
|
||||
|
||||
The system is designed for:
|
||||
|
||||
- **Column-level formulas**: one formula per column, applied to all rows
|
||||
- **Cross-table references**: direct syntax to reference columns from other tables
|
||||
- **Reactive recalculation**: dirty flag propagation with page-aware computation
|
||||
- **Cell-level overrides** (planned): individual cells can override the column formula
|
||||
|
||||
## Formula Language
|
||||
|
||||
### Basic Syntax
|
||||
|
||||
A formula is an expression that references columns with `{ColumnName}` and produces a value for each row:
|
||||
|
||||
```
|
||||
{Price} * {Quantity}
|
||||
```
|
||||
|
||||
References use curly braces `{}` to distinguish column names from keywords and functions. Column names are matched by ID
|
||||
or title.
|
||||
|
||||
### Operators
|
||||
|
||||
#### Arithmetic
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|----------------|------------------------|
|
||||
| `+` | Addition | `{Price} + {Tax}` |
|
||||
| `-` | Subtraction | `{Total} - {Discount}` |
|
||||
| `*` | Multiplication | `{Price} * {Quantity}` |
|
||||
| `/` | Division | `{Total} / {Count}` |
|
||||
| `%` | Modulo | `{Value} % 2` |
|
||||
| `^` | Power | `{Base} ^ 2` |
|
||||
|
||||
#### Comparison
|
||||
|
||||
| Operator | Description | Example |
|
||||
|--------------|--------------------|---------------------------------|
|
||||
| `==` | Equal | `{Status} == "active"` |
|
||||
| `!=` | Not equal | `{Status} != "deleted"` |
|
||||
| `>` | Greater than | `{Price} > 100` |
|
||||
| `<` | Less than | `{Stock} < 10` |
|
||||
| `>=` | Greater or equal | `{Score} >= 80` |
|
||||
| `<=` | Less or equal | `{Age} <= 18` |
|
||||
| `contains` | String contains | `{Name} contains "Corp"` |
|
||||
| `startswith` | String starts with | `{Code} startswith "ERR"` |
|
||||
| `endswith` | String ends with | `{File} endswith ".csv"` |
|
||||
| `in` | Value in list | `{Status} in ["active", "new"]` |
|
||||
| `between` | Value in range | `{Age} between 18 and 65` |
|
||||
| `isempty` | Value is empty | `{Notes} isempty` |
|
||||
| `isnotempty` | Value is not empty | `{Email} isnotempty` |
|
||||
| `isnan` | Value is NaN | `{Score} isnan` |
|
||||
|
||||
#### Logical
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------------------------------------|
|
||||
| `and` | Logical AND | `{Age} > 18 and {Status} == "active"` |
|
||||
| `or` | Logical OR | `{Type} == "A" or {Type} == "B"` |
|
||||
| `not` | Negation | `not {Status} == "deleted"` |
|
||||
|
||||
Parentheses control precedence: `({Type} == "A" or {Type} == "B") and {Active} == True`
|
||||
|
||||
### Conditions (suffix-if)
|
||||
|
||||
Conditions use a **suffix-if** syntax: the result expression comes first, then the condition. This keeps the focus on
|
||||
the output, not the branching logic.
|
||||
|
||||
#### Simple condition (no else — result is None when false)
|
||||
|
||||
```
|
||||
{Price} * 0.8 if {Country} == "FR"
|
||||
```
|
||||
|
||||
#### With else
|
||||
|
||||
```
|
||||
{Price} * 0.8 if {Country} == "FR" else {Price}
|
||||
```
|
||||
|
||||
#### Chained conditions
|
||||
|
||||
```
|
||||
{Price} * 0.8 if {Country} == "FR" else {Price} * 0.9 if {Country} == "DE" else {Price}
|
||||
```
|
||||
|
||||
#### With logical operators
|
||||
|
||||
```
|
||||
{Price} * 0.8 if {Country} == "FR" and {Quantity} > 10 else {Price}
|
||||
```
|
||||
|
||||
#### With grouping
|
||||
|
||||
```
|
||||
{Price} * 0.8 if ({Country} == "FR" or {Country} == "DE") and {Quantity} > 10
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### Math
|
||||
|
||||
| Function | Description | Example |
|
||||
|-------------------|-----------------------|-------------------------------|
|
||||
| `round(expr, n)` | Round to n decimals | `round({Price} * 1.2, 2)` |
|
||||
| `abs(expr)` | Absolute value | `abs({Balance})` |
|
||||
| `min(expr, expr)` | Minimum of two values | `min({Price}, {MaxPrice})` |
|
||||
| `max(expr, expr)` | Maximum of two values | `max({Score}, 0)` |
|
||||
| `sum(expr, ...)` | Sum of values | `sum({Q1}, {Q2}, {Q3}, {Q4})` |
|
||||
| `avg(expr, ...)` | Average of values | `avg({Q1}, {Q2}, {Q3}, {Q4})` |
|
||||
|
||||
#### Text
|
||||
|
||||
| Function | Description | Example |
|
||||
|---------------------|---------------------|--------------------------------|
|
||||
| `upper(expr)` | Uppercase | `upper({Name})` |
|
||||
| `lower(expr)` | Lowercase | `lower({Email})` |
|
||||
| `len(expr)` | String length | `len({Description})` |
|
||||
| `concat(expr, ...)` | Concatenate strings | `concat({First}, " ", {Last})` |
|
||||
| `trim(expr)` | Remove whitespace | `trim({Input})` |
|
||||
| `left(expr, n)` | First n characters | `left({Code}, 3)` |
|
||||
| `right(expr, n)` | Last n characters | `right({Phone}, 4)` |
|
||||
|
||||
#### Date
|
||||
|
||||
| Function | Description | Example |
|
||||
|------------------------|--------------------|--------------------------------|
|
||||
| `year(expr)` | Extract year | `year({CreatedAt})` |
|
||||
| `month(expr)` | Extract month | `month({CreatedAt})` |
|
||||
| `day(expr)` | Extract day | `day({CreatedAt})` |
|
||||
| `today()` | Current date | `datediff({DueDate}, today())` |
|
||||
| `datediff(expr, expr)` | Difference in days | `datediff({End}, {Start})` |
|
||||
|
||||
#### Aggregation (for cross-table contexts)
|
||||
|
||||
| Function | Description | Example |
|
||||
|---------------|--------------|-----------------------------------------------------|
|
||||
| `sum(expr)` | Sum values | `sum({Orders.Amount WHERE Orders.ClientId = Id})` |
|
||||
| `count(expr)` | Count values | `count({Orders.Id WHERE Orders.ClientId = Id})` |
|
||||
| `avg(expr)` | Average | `avg({Reviews.Score WHERE Reviews.ProductId = Id})` |
|
||||
| `min(expr)` | Minimum | `min({Bids.Price WHERE Bids.ItemId = Id})` |
|
||||
| `max(expr)` | Maximum | `max({Bids.Price WHERE Bids.ItemId = Id})` |
|
||||
|
||||
## Cross-Table References
|
||||
|
||||
### Direct Reference
|
||||
|
||||
Reference a column from another table using `{TableName.ColumnName}`:
|
||||
|
||||
```
|
||||
{Products.Price} * {Quantity}
|
||||
```
|
||||
|
||||
### Join Resolution (implicit)
|
||||
|
||||
When referencing another table without a WHERE clause, the join is resolved automatically:
|
||||
|
||||
1. **By `id` column**: if both tables have a column named `id`, rows are matched on equal `id` values
|
||||
2. **By row index**: if no `id` column exists in both tables, rows are matched by their internal row index (stable
|
||||
across sort/filter)
|
||||
|
||||
### Explicit Join (WHERE clause)
|
||||
|
||||
For explicit control over which row of the other table to use:
|
||||
|
||||
```
|
||||
{Products.Price WHERE Products.Code = ProductCode} * {Quantity}
|
||||
```
|
||||
|
||||
Inside the WHERE clause:
|
||||
|
||||
- `Products.Code` refers to a column in the referenced table
|
||||
- `ProductCode` (no `Table.` prefix) refers to a column in the current table
|
||||
|
||||
### Aggregation with Cross-Table
|
||||
|
||||
When a cross-table reference matches multiple rows, use an aggregation function:
|
||||
|
||||
```
|
||||
sum({OrderLines.Amount WHERE OrderLines.OrderId = Id})
|
||||
```
|
||||
|
||||
Without aggregation, a multi-row match returns the first matching value.
|
||||
|
||||
## Calculation Engine
|
||||
|
||||
### Dependency Graph (DAG)
|
||||
|
||||
The formula system maintains a **Directed Acyclic Graph** of dependencies between columns:
|
||||
|
||||
- **Nodes**: each formula column is a node, identified by `table_name.column_id`
|
||||
- **Edges**: if column A's formula references column B, an edge B → A exists ("A depends on B")
|
||||
- Both directions are tracked:
|
||||
- **Precedents**: columns that a formula reads from
|
||||
- **Dependents**: columns that need recalculation when this column changes
|
||||
|
||||
Cross-table references create edges that span DataGrid instances, managed at the `DataGridsManager` level.
|
||||
|
||||
### Dirty Flag Propagation
|
||||
|
||||
When a source column's data changes:
|
||||
|
||||
1. The source column is marked **dirty**
|
||||
2. All direct dependents are marked dirty
|
||||
3. Propagation continues recursively through the DAG
|
||||
4. Each dirty column maintains a **dirty row set**: the specific row indices that need recalculation
|
||||
|
||||
This propagation is **immediate** (fast — only flag marking, no computation).
|
||||
|
||||
### Recalculation Strategy (Hybrid)
|
||||
|
||||
Actual computation is **deferred to rendering time**:
|
||||
|
||||
1. On value change → dirty flags propagate instantly through the DAG
|
||||
2. On page render (`mk_body_content_page`) → only dirty rows within the visible page (up to 1000 rows) are recalculated
|
||||
3. Off-screen pages remain dirty until scrolled into view
|
||||
4. Calculation follows **topological order** of the DAG to ensure precedents are computed before dependents
|
||||
|
||||
### Cycle Detection
|
||||
|
||||
Before adding a formula, the engine checks for cycles in the DAG using Kahn's algorithm during topological sort. If a
|
||||
cycle is detected:
|
||||
|
||||
- The formula is **rejected**
|
||||
- The editor displays an error identifying the circular dependency chain
|
||||
- The previous formula (if any) remains unchanged
|
||||
|
||||
### Caching
|
||||
|
||||
Each formula column caches its computed values:
|
||||
|
||||
- Results are stored in `ns_fast_access[col_id]` alongside raw data columns
|
||||
- The dirty row set tracks which cached values are stale
|
||||
- Non-dirty rows return their cached value without re-evaluation
|
||||
- Cache is invalidated per-row when source data changes
|
||||
|
||||
## Evaluation
|
||||
|
||||
### Row-by-Row Execution
|
||||
|
||||
Formulas are evaluated **row-by-row** within the page being rendered. For each row:
|
||||
|
||||
1. Resolve column references `{ColumnName}` to the cell value at the current row index
|
||||
2. Resolve cross-table references `{Table.Column}` via the join mechanism
|
||||
3. Evaluate the expression with resolved values
|
||||
4. Store the result in the cache (`ns_fast_access`)
|
||||
|
||||
### Parser
|
||||
|
||||
The formula language uses a **custom grammar** parsed with Lark (consistent with the formatting DSL). The parser:
|
||||
|
||||
1. Tokenizes the formula string
|
||||
2. Builds an AST (Abstract Syntax Tree)
|
||||
3. Transforms the AST into an evaluable representation
|
||||
4. Extracts column references for dependency graph registration
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Error Type | Behavior |
|
||||
|-----------------------|-------------------------------------------------------|
|
||||
| Syntax error | Editor highlights the error, formula not saved |
|
||||
| Unknown column | Editor highlights, autocompletion suggests fixes |
|
||||
| Type mismatch | Cell displays error indicator, other cells unaffected |
|
||||
| Division by zero | Cell displays `#DIV/0!` or None |
|
||||
| Circular dependency | Formula rejected, editor shows cycle chain |
|
||||
| Cross-table not found | Editor highlights unknown table name |
|
||||
| No join match | Cell displays None |
|
||||
|
||||
## User Interface
|
||||
|
||||
### Creating a Formula Column
|
||||
|
||||
Formula columns are created and edited through the **DataGridColumnsManager**:
|
||||
|
||||
1. User opens the Columns Manager panel
|
||||
2. Adds a new column or edits an existing one
|
||||
3. Selects column type **"Formula"**
|
||||
4. A **DslEditor** (CodeMirror 5) opens for formula input
|
||||
5. The editor provides:
|
||||
- **Syntax highlighting**: keywords, column references, functions, operators
|
||||
- **Autocompletion**: column names (current table and other tables), function names, table names
|
||||
- **Validation**: real-time syntax checking and dependency cycle detection
|
||||
- **Error markers**: inline error indicators with descriptions
|
||||
|
||||
### Formula Column Properties
|
||||
|
||||
A formula column extends `DataGridColumnState` with:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---------------------------------------------------------------------------|---------------|------------------------------------------------|
|
||||
| `formula` | `str` or None | The formula expression (None for data columns) |
|
||||
| `col_type` | `ColumnType` | Set to `ColumnType.Formula` |
|
||||
| Other properties (`title`, `visible`, `width`, `format`) remain unchanged |
|
||||
|
||||
Formula columns are **read-only** in the grid body — cell values are computed, not editable. Formatting rules from the
|
||||
formatting DSL apply to formula columns like any other column.
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Component | Role |
|
||||
|--------------------------|----------------------------------------------------------|
|
||||
| `DataGridColumnState` | Stores `formula` field and `ColumnType.Formula` type |
|
||||
| `DatagridStore` | `ns_fast_access` caches formula results as numpy arrays |
|
||||
| `DataGridColumnsManager` | UI for creating/editing formula columns |
|
||||
| `DataGridsManager` | Hosts the global dependency DAG across all tables |
|
||||
| `DslEditor` | CodeMirror 5 editor with highlighting and autocompletion |
|
||||
| `FormattingEngine` | Applies formatting rules AFTER formula evaluation |
|
||||
| `mk_body_content_page()` | Triggers formula computation for visible rows |
|
||||
| `mk_body_cell_content()` | Reads computed values from `ns_fast_access` |
|
||||
|
||||
## Syntax Summary
|
||||
|
||||
```
|
||||
# Basic arithmetic
|
||||
{Price} * {Quantity}
|
||||
|
||||
# Function call
|
||||
round({Price} * 1.2, 2)
|
||||
|
||||
# Simple condition (None if false)
|
||||
{Price} * 0.8 if {Country} == "FR"
|
||||
|
||||
# Condition with else
|
||||
{Price} * 0.8 if {Country} == "FR" else {Price}
|
||||
|
||||
# Chained conditions
|
||||
{Price} * 0.8 if {Country} == "FR" else {Price} * 0.9 if {Country} == "DE" else {Price}
|
||||
|
||||
# Logical operators
|
||||
{Price} * 0.8 if {Country} == "FR" and {Quantity} > 10
|
||||
|
||||
# Grouping
|
||||
{Price} * 0.8 if ({Country} == "FR" or {Country} == "DE") and {Quantity} > 10
|
||||
|
||||
# Cross-table (implicit join on id)
|
||||
{Products.Price} * {Quantity}
|
||||
|
||||
# Cross-table (explicit join)
|
||||
{Products.Price WHERE Products.Code = ProductCode} * {Quantity}
|
||||
|
||||
# Cross-table aggregation
|
||||
sum({OrderLines.Amount WHERE OrderLines.OrderId = Id})
|
||||
|
||||
# Nested functions
|
||||
round(avg({Q1}, {Q2}, {Q3}, {Q4}), 1)
|
||||
|
||||
# Text operations
|
||||
concat(upper(left({FirstName}, 1)), ". ", {LastName})
|
||||
```
|
||||
|
||||
## Future: Cell-Level Overrides
|
||||
|
||||
The architecture supports adding cell-level formula overrides with ~20-30% additional work:
|
||||
|
||||
- **Storage**: sparse dict `cell_formulas: dict[(col_id, row_index), str]` (same pattern as `cell_formats`)
|
||||
- **DAG**: new node type `table.column[row]` alongside existing `table.column` nodes
|
||||
- **Evaluation**: "does this cell have an override? If yes, use it. Otherwise, use the column formula."
|
||||
- **Node ID scheme**: designed to be extensible from the start (`table.column` for columns, `table.column[row]` for
|
||||
cells)
|
||||
557
docs/Dropdown.md
Normal file
557
docs/Dropdown.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# Dropdown Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Dropdown component provides an interactive dropdown menu that toggles open or closed when clicking a trigger button. It handles positioning, automatic closing behavior, and keyboard navigation out of the box.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Toggle open/close on button click
|
||||
- Automatic close when clicking outside
|
||||
- Keyboard support (ESC to close)
|
||||
- Configurable vertical position (above or below the button)
|
||||
- Configurable horizontal alignment (left, right, or center)
|
||||
- Session-based state management
|
||||
- HTMX-powered updates without page reload
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Navigation menus
|
||||
- User account menus
|
||||
- Action menus (edit, delete, share)
|
||||
- Filter or sort options
|
||||
- Context-sensitive toolbars
|
||||
- Settings quick access
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a dropdown menu with navigation links:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create root instance and dropdown
|
||||
root = RootInstance(session)
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu", cls="btn"),
|
||||
content=Ul(
|
||||
Li(A("Home", href="/")),
|
||||
Li(A("Settings", href="/settings")),
|
||||
Li(A("Logout", href="/logout"))
|
||||
)
|
||||
)
|
||||
|
||||
# Render the dropdown
|
||||
return dropdown
|
||||
```
|
||||
|
||||
This creates a complete dropdown with:
|
||||
|
||||
- A "Menu" button that toggles the dropdown
|
||||
- A list of navigation links displayed below the button
|
||||
- Automatic closing when clicking outside the dropdown
|
||||
- ESC key support to close the dropdown
|
||||
|
||||
**Note:** The dropdown opens below the button and aligns to the left by default. Users can click anywhere outside the dropdown to close it, or press ESC on the keyboard.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The Dropdown component consists of a trigger button and a content panel:
|
||||
|
||||
```
|
||||
Closed state:
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
└──────────────┘
|
||||
|
||||
Open state (position="below", align="left"):
|
||||
┌──────────────┐
|
||||
│ Button ▼ │
|
||||
├──────────────┴─────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
│ - Option 3 │
|
||||
└────────────────────────┘
|
||||
|
||||
Open state (position="above", align="right"):
|
||||
┌────────────────────────┐
|
||||
│ Dropdown Content │
|
||||
│ - Option 1 │
|
||||
│ - Option 2 │
|
||||
├──────────────┬─────────┘
|
||||
│ Button ▲ │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|-----------|------------------------------------------------|
|
||||
| Button | Trigger element that toggles the dropdown |
|
||||
| Content | Panel containing the dropdown menu items |
|
||||
| Wrapper | Container with relative positioning for anchor |
|
||||
|
||||
### Creating a Dropdown
|
||||
|
||||
The Dropdown is a `MultipleInstance`, meaning you can create multiple independent dropdowns in your application. Create it by providing a parent instance:
|
||||
|
||||
```python
|
||||
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content)
|
||||
|
||||
# Or with a custom ID
|
||||
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content, _id="my-dropdown")
|
||||
```
|
||||
|
||||
### Button and Content
|
||||
|
||||
The dropdown requires two main elements:
|
||||
|
||||
**Button:** The trigger element that users click to toggle the dropdown.
|
||||
|
||||
```python
|
||||
# Simple text button
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Click me", cls="btn btn-primary"),
|
||||
content=my_content
|
||||
)
|
||||
|
||||
# Button with icon
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Div(
|
||||
icon_svg,
|
||||
Span("Options"),
|
||||
cls="flex items-center gap-2"
|
||||
),
|
||||
content=my_content
|
||||
)
|
||||
|
||||
# Just an icon
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=icon_svg,
|
||||
content=my_content
|
||||
)
|
||||
```
|
||||
|
||||
**Content:** Any FastHTML element to display in the dropdown panel.
|
||||
|
||||
```python
|
||||
# Simple list
|
||||
content = Ul(
|
||||
Li("Option 1"),
|
||||
Li("Option 2"),
|
||||
Li("Option 3"),
|
||||
cls="menu"
|
||||
)
|
||||
|
||||
# Complex content with sections
|
||||
content = Div(
|
||||
Div("User Actions", cls="font-bold p-2"),
|
||||
Hr(),
|
||||
Button("Edit Profile", cls="btn btn-ghost w-full"),
|
||||
Button("Settings", cls="btn btn-ghost w-full"),
|
||||
Hr(),
|
||||
Button("Logout", cls="btn btn-error w-full")
|
||||
)
|
||||
```
|
||||
|
||||
### Positioning Options
|
||||
|
||||
The Dropdown supports two positioning parameters:
|
||||
|
||||
**`position`** - Vertical position relative to the button:
|
||||
- `"below"` (default): Dropdown appears below the button
|
||||
- `"above"`: Dropdown appears above the button
|
||||
|
||||
**`align`** - Horizontal alignment relative to the button:
|
||||
- `"left"` (default): Dropdown aligns to the left edge of the button
|
||||
- `"right"`: Dropdown aligns to the right edge of the button
|
||||
- `"center"`: Dropdown is centered relative to the button
|
||||
|
||||
```python
|
||||
# Default: below + left
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu)
|
||||
|
||||
# Above the button, aligned right
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu, position="above", align="right")
|
||||
|
||||
# Below the button, centered
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu, position="below", align="center")
|
||||
```
|
||||
|
||||
**Visual examples of all combinations:**
|
||||
|
||||
```
|
||||
position="below", align="left" position="below", align="center" position="below", align="right"
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Button │ │ Button │ │ Button │
|
||||
├────────┴────┐ ┌────┴────────┴────┐ ┌────────────┴────────┤
|
||||
│ Content │ │ Content │ │ Content │
|
||||
└─────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
|
||||
position="above", align="left" position="above", align="center" position="above", align="right"
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ Content │ │ Content │ │ Content │
|
||||
├────────┬────┘ └────┬────────┬────┘ └────────────┬────────┤
|
||||
│ Button │ │ Button │ │ Button │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Automatic Close Behavior
|
||||
|
||||
The Dropdown automatically closes in two scenarios:
|
||||
|
||||
**Click outside:** When the user clicks anywhere outside the dropdown, it closes automatically. This is handled by the Mouse component listening for global click events.
|
||||
|
||||
**ESC key:** When the user presses the ESC key, the dropdown closes. This is handled by the Keyboard component.
|
||||
|
||||
```python
|
||||
# Both behaviors are enabled by default - no configuration needed
|
||||
dropdown = Dropdown(parent=root, button=btn, content=menu)
|
||||
```
|
||||
|
||||
**How it works internally:**
|
||||
|
||||
- The `Mouse` component detects clicks and sends `is_inside` and `is_button` parameters
|
||||
- If `is_button` is true, the dropdown toggles
|
||||
- If `is_inside` is false (clicked outside), the dropdown closes
|
||||
- The `Keyboard` component listens for ESC and triggers the close command
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
You can control the dropdown programmatically using its methods and commands:
|
||||
|
||||
```python
|
||||
# Toggle the dropdown state
|
||||
dropdown.toggle()
|
||||
|
||||
# Close the dropdown
|
||||
dropdown.close()
|
||||
|
||||
# Access commands for use with other controls
|
||||
close_cmd = dropdown.commands.close()
|
||||
click_cmd = dropdown.commands.click()
|
||||
```
|
||||
|
||||
**Using commands with buttons:**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Create a button that closes the dropdown
|
||||
close_button = mk.button("Close", command=dropdown.commands.close())
|
||||
|
||||
# Add it to the dropdown content
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu"),
|
||||
content=Div(
|
||||
Ul(Li("Option 1"), Li("Option 2")),
|
||||
close_button
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Dropdown uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|-----------------------|---------------------------------------|
|
||||
| `mf-dropdown-wrapper` | Container with relative positioning |
|
||||
| `mf-dropdown-btn` | Button wrapper |
|
||||
| `mf-dropdown` | Dropdown content panel |
|
||||
| `mf-dropdown-below` | Applied when position="below" |
|
||||
| `mf-dropdown-above` | Applied when position="above" |
|
||||
| `mf-dropdown-left` | Applied when align="left" |
|
||||
| `mf-dropdown-right` | Applied when align="right" |
|
||||
| `mf-dropdown-center` | Applied when align="center" |
|
||||
| `is-visible` | Applied when dropdown is open |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change dropdown background and border */
|
||||
.mf-dropdown {
|
||||
background-color: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add animation */
|
||||
.mf-dropdown {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Style for above position */
|
||||
.mf-dropdown-above {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.mf-dropdown-above.is-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Navigation Menu
|
||||
|
||||
A simple navigation dropdown menu:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Navigation", cls="btn btn-ghost"),
|
||||
content=Ul(
|
||||
Li(A("Dashboard", href="/dashboard", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Projects", href="/projects", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Tasks", href="/tasks", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Reports", href="/reports", cls="block p-2 hover:bg-base-200")),
|
||||
cls="menu p-2"
|
||||
)
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 2: User Account Menu
|
||||
|
||||
A user menu aligned to the right, typically placed in a header:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
# User avatar button
|
||||
user_button = Div(
|
||||
Img(src="/avatar.png", cls="w-8 h-8 rounded-full"),
|
||||
Span("John Doe", cls="ml-2"),
|
||||
cls="flex items-center gap-2 cursor-pointer"
|
||||
)
|
||||
|
||||
# Account menu content
|
||||
account_menu = Div(
|
||||
Div(
|
||||
Div("John Doe", cls="font-bold"),
|
||||
Div("john@example.com", cls="text-sm opacity-60"),
|
||||
cls="p-3 border-b"
|
||||
),
|
||||
Ul(
|
||||
Li(A("Profile", href="/profile", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Settings", href="/settings", cls="block p-2 hover:bg-base-200")),
|
||||
Li(A("Billing", href="/billing", cls="block p-2 hover:bg-base-200")),
|
||||
cls="menu p-2"
|
||||
),
|
||||
Div(
|
||||
A("Sign out", href="/logout", cls="block p-2 text-error hover:bg-base-200"),
|
||||
cls="border-t"
|
||||
),
|
||||
cls="w-56"
|
||||
)
|
||||
|
||||
# Align right so it doesn't overflow the viewport
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=user_button,
|
||||
content=account_menu,
|
||||
align="right"
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 3: Action Menu Above Button
|
||||
|
||||
A dropdown that opens above the trigger, useful when the button is at the bottom of the screen:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
|
||||
# Action button with icon
|
||||
action_button = Button(
|
||||
Span("+", cls="text-xl"),
|
||||
cls="btn btn-circle btn-primary"
|
||||
)
|
||||
|
||||
# Quick actions menu
|
||||
actions_menu = Div(
|
||||
Button("New Document", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Upload File", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Create Folder", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Button("Import Data", cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
cls="flex flex-col p-2 w-40"
|
||||
)
|
||||
|
||||
# Open above and center-aligned
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=action_button,
|
||||
content=actions_menu,
|
||||
position="above",
|
||||
align="center"
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
### Example 4: Dropdown with Commands
|
||||
|
||||
A dropdown containing action buttons that execute commands:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Define actions
|
||||
def edit_item():
|
||||
return "Editing..."
|
||||
|
||||
def delete_item():
|
||||
return "Deleted!"
|
||||
|
||||
def share_item():
|
||||
return "Shared!"
|
||||
|
||||
# Create commands
|
||||
edit_cmd = Command("edit", "Edit item", edit_item)
|
||||
delete_cmd = Command("delete", "Delete item", delete_item)
|
||||
share_cmd = Command("share", "Share item", share_item)
|
||||
|
||||
# Build menu with command buttons
|
||||
actions_menu = Div(
|
||||
mk.button("Edit", command=edit_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
mk.button("Share", command=share_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
|
||||
Hr(cls="my-1"),
|
||||
mk.button("Delete", command=delete_cmd, cls="btn btn-ghost btn-sm w-full justify-start text-error"),
|
||||
cls="flex flex-col p-2"
|
||||
)
|
||||
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Actions", cls="btn btn-sm"),
|
||||
content=actions_menu
|
||||
)
|
||||
|
||||
return dropdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Dropdown component itself.
|
||||
|
||||
### State
|
||||
|
||||
The Dropdown component maintains its state via `DropdownState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------|---------|------------------------------|---------|
|
||||
| `opened` | boolean | Whether dropdown is open | `False` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|-----------|-------------------------------------------------|
|
||||
| `close()` | Closes the dropdown |
|
||||
| `click()` | Handles click events (toggle or close behavior) |
|
||||
|
||||
**Command details:**
|
||||
|
||||
- `close()`: Sets `opened` to `False` and returns updated content
|
||||
- `click()`: Receives `combination`, `is_inside`, and `is_button` parameters
|
||||
- If `is_button` is `True`: toggles the dropdown
|
||||
- If `is_inside` is `False`: closes the dropdown
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|------------|----------------------------|----------------------|
|
||||
| `toggle()` | Toggles open/closed state | Content tuple |
|
||||
| `close()` | Closes the dropdown | Content tuple |
|
||||
| `render()` | Renders complete component | `Div` |
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-------------|------------------------------------|-----------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
| `content` | Any | Content to display in dropdown | `None` |
|
||||
| `button` | Any | Trigger element | `None` |
|
||||
| `_id` | str | Custom ID for the instance | `None` |
|
||||
| `position` | str | Vertical position: "below"/"above" | `"below"` |
|
||||
| `align` | str | Horizontal align: "left"/"right"/"center" | `"left"` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}")
|
||||
├── Div(cls="mf-dropdown-wrapper")
|
||||
│ ├── Div(cls="mf-dropdown-btn")
|
||||
│ │ └── [Button content]
|
||||
│ └── Div(id="{id}-content", cls="mf-dropdown mf-dropdown-{position} mf-dropdown-{align} [is-visible]")
|
||||
│ └── [Dropdown content]
|
||||
├── Keyboard(id="{id}-keyboard")
|
||||
│ └── ESC → close command
|
||||
└── Mouse(id="{id}-mouse")
|
||||
└── click → click command
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|--------------------------------|
|
||||
| `{id}` | Root dropdown container |
|
||||
| `{id}-content` | Dropdown content panel |
|
||||
| `{id}-keyboard` | Keyboard handler component |
|
||||
| `{id}-mouse` | Mouse handler component |
|
||||
|
||||
**Note:** `{id}` is the Dropdown instance ID (auto-generated or custom `_id`).
|
||||
|
||||
### Internal Methods
|
||||
|
||||
| Method | Description |
|
||||
|-----------------|------------------------------------------|
|
||||
| `_mk_content()` | Renders the dropdown content panel |
|
||||
| `on_click()` | Handles click events from Mouse component |
|
||||
|
||||
**Method details:**
|
||||
|
||||
- `_mk_content()`:
|
||||
- Builds CSS classes based on `position` and `align`
|
||||
- Adds `is-visible` class when `opened` is `True`
|
||||
- Returns a tuple containing the content `Div`
|
||||
|
||||
- `on_click(combination, is_inside, is_button)`:
|
||||
- Called by Mouse component on click events
|
||||
- `is_button`: `True` if click was on the button
|
||||
- `is_inside`: `True` if click was inside the dropdown
|
||||
- Returns updated content for HTMX swap
|
||||
@@ -176,12 +176,55 @@ You can use any HTMX attribute in the configuration object:
|
||||
- `hx-target` - Target element selector
|
||||
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
|
||||
- `hx-vals` - Additional values to send (object)
|
||||
- `hx-vals-extra` - Extra values to merge (see below)
|
||||
- `hx-headers` - Custom headers (object)
|
||||
- `hx-select` - Select specific content from response
|
||||
- `hx-confirm` - Confirmation message
|
||||
|
||||
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
|
||||
|
||||
### Dynamic Values with hx-vals-extra
|
||||
|
||||
The `hx-vals-extra` attribute allows adding dynamic values computed at event time, without overwriting the static `hx-vals`.
|
||||
|
||||
**Format:**
|
||||
```javascript
|
||||
{
|
||||
"hx-vals": {"c_id": "command_id"}, // Static values (preserved)
|
||||
"hx-vals-extra": {
|
||||
"dict": {"key": "value"}, // Additional static values (merged)
|
||||
"js": "functionName" // JS function to call (merged)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How values are merged:**
|
||||
1. `hx-vals` - static values (e.g., `c_id` from Command)
|
||||
2. `hx-vals-extra.dict` - additional static values
|
||||
3. `hx-vals-extra.js` - function called with `(event, element, combinationStr)`, result merged
|
||||
|
||||
**JavaScript function example:**
|
||||
```javascript
|
||||
function getKeyboardContext(event, element, combination) {
|
||||
return {
|
||||
key: event.key,
|
||||
shift: event.shiftKey,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration example:**
|
||||
```javascript
|
||||
const combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save",
|
||||
"hx-vals": {"c_id": "save_cmd"},
|
||||
"hx-vals-extra": {"js": "getKeyboardContext"}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every request:
|
||||
|
||||
@@ -19,6 +19,13 @@ The mouse support library provides keyboard-like binding capabilities for mouse
|
||||
- `ctrl+shift+click` - Multiple modifiers
|
||||
- Any combination of modifiers
|
||||
|
||||
**Drag Actions**:
|
||||
- `mousedown>mouseup` - Left button drag (press, drag at least 5px, release)
|
||||
- `rmousedown>mouseup` - Right button drag
|
||||
- `ctrl+mousedown>mouseup` - Ctrl + left drag
|
||||
- `shift+mousedown>mouseup` - Shift + left drag
|
||||
- Any combination of modifiers
|
||||
|
||||
**Sequences**:
|
||||
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||
- `click click` - Double click sequence
|
||||
@@ -64,6 +71,191 @@ 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 {};
|
||||
}
|
||||
```
|
||||
|
||||
## Drag Actions (mousedown>mouseup)
|
||||
|
||||
### How It Works
|
||||
|
||||
Drag detection uses a **5-pixel threshold**: the action only activates when the mouse has moved at least 5px after mousedown. This prevents accidental drags from normal clicks.
|
||||
|
||||
**Lifecycle**:
|
||||
1. `mousedown` → library waits, stores start position
|
||||
2. Mouse moves > 5px → drag mode activated, `hx-vals-extra` function called with mousedown event → result stored
|
||||
3. Mouse moves (during drag) → `on_move` function called on each animation frame *(if configured)*
|
||||
4. `mouseup` → `hx-vals-extra` function called again with mouseup event → HTMX request fired with both values
|
||||
5. The subsequent `click` event is suppressed (left button only)
|
||||
|
||||
### Two-Phase Values
|
||||
|
||||
For `mousedown>mouseup`, the `hx-vals-extra` function is called **twice** — once at each phase — and values are suffixed automatically:
|
||||
|
||||
```javascript
|
||||
// hx-vals-extra function (same function, called twice)
|
||||
function getCellId(event) {
|
||||
const cell = event.target.closest('.dt2-cell');
|
||||
return { cell_id: cell.id };
|
||||
}
|
||||
```
|
||||
|
||||
**Values sent to server**:
|
||||
```json
|
||||
{
|
||||
"c_id": "command_id",
|
||||
"cell_id_mousedown": "tcell_grid-0-2",
|
||||
"cell_id_mouseup": "tcell_grid-3-5",
|
||||
"combination": "mousedown>mouseup",
|
||||
"is_inside": true,
|
||||
"has_focus": false
|
||||
}
|
||||
```
|
||||
|
||||
**Python handler**:
|
||||
```python
|
||||
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
|
||||
# cell_id_mousedown: where the drag started
|
||||
# cell_id_mouseup: where the drag ended
|
||||
...
|
||||
```
|
||||
|
||||
### Real-Time Visual Feedback with `on_move`
|
||||
|
||||
The `on_move` attribute specifies a JavaScript function to call on each animation frame **during the drag**, enabling real-time visual feedback without any server calls.
|
||||
|
||||
**Configuration**:
|
||||
```javascript
|
||||
{
|
||||
"mousedown>mouseup": {
|
||||
"hx-post": "/myfasthtml/commands",
|
||||
"hx-vals": {"c_id": "command_id"},
|
||||
"hx-vals-extra": {"js": "getCellId"},
|
||||
"on-move": "onDragMove" // called during drag
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`on_move` function signature**:
|
||||
```javascript
|
||||
function onDragMove(event, combination, mousedown_result) {
|
||||
// event : current mousemove event
|
||||
// combination : e.g. "mousedown>mouseup" or "ctrl+mousedown>mouseup"
|
||||
// mousedown_result : raw result of hx-vals-extra at mousedown (unsuffixed), or null
|
||||
}
|
||||
```
|
||||
|
||||
**Key properties**:
|
||||
- Called only after the 5px drag threshold is exceeded (never during a simple click)
|
||||
- Throttled via `requestAnimationFrame` (~60fps) — no manual throttling needed
|
||||
- Return value is ignored
|
||||
- Visual state cleanup is handled by the server response (which overwrites any client-side visual)
|
||||
|
||||
**DataGrid range selection example**:
|
||||
```javascript
|
||||
function highlightDragRange(event, combination, mousedownResult) {
|
||||
const startCell = mousedownResult ? mousedownResult.cell_id : null;
|
||||
const endCell = event.target.closest('.dt2-cell');
|
||||
if (!startCell || !endCell) return;
|
||||
|
||||
// Clear previous preview
|
||||
document.querySelectorAll('.dt2-drag-preview')
|
||||
.forEach(el => el.classList.remove('dt2-drag-preview'));
|
||||
|
||||
// Highlight range from start to current cell
|
||||
applyRangeClass(startCell, endCell.id, 'dt2-drag-preview');
|
||||
}
|
||||
```
|
||||
|
||||
**Canvas selection rectangle example**:
|
||||
```javascript
|
||||
function drawSelectionRect(event, combination, mousedownResult) {
|
||||
if (!mousedownResult) return;
|
||||
const canvas = document.getElementById('my-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = 'blue';
|
||||
ctx.strokeRect(
|
||||
mousedownResult.x - rect.left,
|
||||
mousedownResult.y - rect.top,
|
||||
event.clientX - rect.left - (mousedownResult.x - rect.left),
|
||||
event.clientY - rect.top - (mousedownResult.y - rect.top)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Python configuration**:
|
||||
```python
|
||||
mouse.add(
|
||||
"mousedown>mouseup",
|
||||
selection_command,
|
||||
hx_vals="js:getCellId()",
|
||||
on_move="js:highlightDragRange()"
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_mouse_support(elementId, combinationsJson)
|
||||
@@ -150,16 +342,168 @@ 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,
|
||||
on_move: str = None)
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "mousedown>mouseup")
|
||||
- `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
|
||||
- `on_move`: Client-side JS function called during drag — `"js:functionName()"` format. Only valid with `mousedown>mouseup` sequences.
|
||||
|
||||
**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`.
|
||||
- `on_move` is purely client-side — it never triggers a server call.
|
||||
|
||||
### 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()")
|
||||
```
|
||||
|
||||
**With drag and real-time feedback**:
|
||||
```python
|
||||
mouse.add(
|
||||
"mousedown>mouseup",
|
||||
selection_command,
|
||||
hx_vals="js:getCellId()",
|
||||
on_move="js:highlightDragRange()"
|
||||
)
|
||||
```
|
||||
|
||||
### 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 +512,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
|
||||
@@ -389,6 +699,43 @@ const combinations = {
|
||||
};
|
||||
```
|
||||
|
||||
### Range Selection with Visual Feedback
|
||||
|
||||
```python
|
||||
# Python: configure drag with live feedback
|
||||
mouse.add(
|
||||
"mousedown>mouseup",
|
||||
self.commands.on_mouse_selection(),
|
||||
hx_vals="js:getCellId()",
|
||||
on_move="js:highlightDragRange()"
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript: real-time highlight during drag
|
||||
function highlightDragRange(event, combination, mousedownResult) {
|
||||
const startCell = mousedownResult ? mousedownResult.cell_id : null;
|
||||
const endCell = event.target.closest('.dt2-cell');
|
||||
if (!startCell || !endCell) return;
|
||||
|
||||
document.querySelectorAll('.dt2-drag-preview')
|
||||
.forEach(el => el.classList.remove('dt2-drag-preview'));
|
||||
|
||||
applyRangeClass(startCell, endCell.id, 'dt2-drag-preview');
|
||||
// Server response will replace .dt2-drag-preview with final selection classes
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python: server handler receives both positions
|
||||
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
|
||||
if is_inside and cell_id_mousedown and cell_id_mouseup:
|
||||
pos_start = self._get_pos_from_element_id(cell_id_mousedown)
|
||||
pos_end = self._get_pos_from_element_id(cell_id_mouseup)
|
||||
self._state.selection.set_range(pos_start, pos_end)
|
||||
return self.render_partial()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Clicks not detected
|
||||
@@ -409,14 +756,29 @@ const combinations = {
|
||||
- Check if longer sequences exist (causes waiting)
|
||||
- Verify the combination string format (space-separated)
|
||||
|
||||
### Drag not triggering
|
||||
|
||||
- Ensure the mouse moved at least 5px before releasing
|
||||
- Verify `mousedown>mouseup` (not `mousedown_mouseup`) in the combination string
|
||||
- Check that `hx-vals-extra` function exists and is accessible via `window`
|
||||
|
||||
### `on_move` not called
|
||||
|
||||
- Verify `on_move` is only used with `mousedown>mouseup` sequences
|
||||
- Check that the function name matches exactly (case-sensitive)
|
||||
- Ensure the function is accessible via `window` (not inside a module scope)
|
||||
- Remember: `on_move` only fires after the 5px threshold — it won't fire on small movements
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||
- **Global listeners** on `document` for `click`, `contextmenu`, `mousedown`, `mouseup` events
|
||||
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||
- **Independent from keyboard support** (separate registry and timeouts)
|
||||
- **Drag detection**: temporary `mousemove` listener attached on `mousedown`, removed when 5px threshold exceeded
|
||||
- **`on_move` throttling**: `requestAnimationFrame` used internally — no manual throttling needed in user functions
|
||||
|
||||
### Performance
|
||||
|
||||
|
||||
944
docs/Panel.md
Normal file
944
docs/Panel.md
Normal file
@@ -0,0 +1,944 @@
|
||||
# Panel Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Panel component provides a flexible three-zone layout with optional collapsible side panels. It's designed to
|
||||
organize content into left panel, main area, and right panel sections, with smooth toggle animations and resizable
|
||||
panels.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Three customizable zones (left panel, main content, right panel)
|
||||
- Configurable panel titles with sticky headers
|
||||
- Toggle visibility with hide/show icons
|
||||
- Resizable panels with drag handles
|
||||
- Smooth CSS animations for show/hide transitions
|
||||
- Automatic state persistence per session
|
||||
- Configurable panel presence (enable/disable left or right)
|
||||
- Session-based width and visibility state
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Code editor with file explorer and properties panel
|
||||
- Data visualization with filters sidebar and details panel
|
||||
- Admin interface with navigation menu and tools panel
|
||||
- Documentation viewer with table of contents and metadata
|
||||
- Dashboard with configuration panel and information sidebar
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a three-panel layout for a code editor:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create the panel instance
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set content for each zone
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Files"),
|
||||
Ul(
|
||||
Li("app.py"),
|
||||
Li("config.py"),
|
||||
Li("utils.py")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_main(
|
||||
Div(
|
||||
H2("Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties"),
|
||||
Div("Language: Python"),
|
||||
Div("Lines: 120"),
|
||||
Div("Size: 3.2 KB")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the panel
|
||||
return panel
|
||||
```
|
||||
|
||||
This creates a complete panel layout with:
|
||||
|
||||
- A left panel displaying a file list with a hide icon (−) at the top right
|
||||
- A main content area with a code editor
|
||||
- A right panel showing file properties with a hide icon (−) at the top right
|
||||
- Show icons (⋯) that appear in the main area when panels are hidden
|
||||
- Drag handles between panels for manual resizing
|
||||
- Automatic state persistence (visibility and width)
|
||||
|
||||
**Note:** Users can hide panels by clicking the hide icon (−) inside each panel. When hidden, a show icon (⋯) appears in
|
||||
the main area (left side for left panel, right side for right panel). Panels can be resized by dragging the handles, and
|
||||
all state is automatically saved in the session.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The Panel component consists of three zones with optional side panels:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┐ │ ┌──────────────────────┐ │ ┌──────────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ Left │ ║ │ │ ║ │ Right │ │
|
||||
│ │ Panel │ │ │ Main Content │ │ │ Panel │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ [−] │ │ │ [⋯] [⋯] │ │ │ [−] │ │
|
||||
│ └──────────┘ │ └──────────────────────┘ │ └──────────┘ │
|
||||
│ ║ ║ │
|
||||
│ Resizer Resizer │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|---------------|-----------------------------------------------|
|
||||
| Left panel | Optional collapsible panel (default: visible) |
|
||||
| Main content | Always-visible central content area |
|
||||
| Right panel | Optional collapsible panel (default: visible) |
|
||||
| Hide icon (−) | Inside each panel header, right side |
|
||||
| Show icon (⋯) | In main area when panel is hidden |
|
||||
| Resizer (║) | Drag handle to resize panels manually |
|
||||
|
||||
**Panel with title (default):**
|
||||
|
||||
When `show_left_title` or `show_right_title` is `True` (default), panels display a sticky header with title and hide icon:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Title [−] │ ← Header (sticky, always visible)
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ Scrollable Content │ ← Content area (scrolls independently)
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Panel without title:**
|
||||
|
||||
When `show_left_title` or `show_right_title` is `False`, panels use the legacy layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [−] │ ← Hide icon at top-right (absolute)
|
||||
│ │
|
||||
│ Content │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
||||
providing a parent instance:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
panel = Panel(parent=root_instance, _id="my-panel")
|
||||
|
||||
# Or with custom configuration
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
conf = PanelConf(left=True, right=False) # Only left panel enabled
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Panel provides three content zones:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Left Panel │ Main Content │ Right Panel │
|
||||
│ (optional) │ (required) │ (optional) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use | Required |
|
||||
|---------|-------------------------------------------------------|----------|
|
||||
| `left` | Navigation, file explorer, filters, table of contents | No |
|
||||
| `main` | Primary content, editor, visualization, results | Yes |
|
||||
| `right` | Properties, tools, metadata, debug info, settings | No |
|
||||
|
||||
### Setting Content
|
||||
|
||||
Use the `set_*()` methods to add content to each zone:
|
||||
|
||||
```python
|
||||
# Main content (always visible)
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Left panel (optional)
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Home"),
|
||||
Li("Settings"),
|
||||
Li("About")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel (optional)
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Tools"),
|
||||
Button("Export"),
|
||||
Button("Refresh")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Method chaining:**
|
||||
|
||||
The `set_main()` method returns `self`, enabling method chaining:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
.set_main(Div("Main content"))
|
||||
.set_left(Div("Left content"))
|
||||
```
|
||||
|
||||
### Panel Configuration
|
||||
|
||||
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
# Only left panel enabled
|
||||
conf = PanelConf(left=True, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only right panel enabled
|
||||
conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Both panels enabled (default)
|
||||
conf = PanelConf(left=True, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# No side panels (main content only)
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Customizing panel titles:**
|
||||
|
||||
```python
|
||||
# Custom titles for panels
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
left_title="Explorer", # Custom title for left panel
|
||||
right_title="Properties" # Custom title for right panel
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Disabling panel titles:**
|
||||
|
||||
When titles are disabled, panels use the legacy layout without a sticky header:
|
||||
|
||||
```python
|
||||
# Disable titles (legacy layout)
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
show_left_title=False,
|
||||
show_right_title=False
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Disabling show icons:**
|
||||
|
||||
You can hide the show icons (⋯) that appear when panels are hidden. This means users can only show panels programmatically:
|
||||
|
||||
```python
|
||||
# Disable show icons (programmatic control only)
|
||||
conf = PanelConf(
|
||||
left=True,
|
||||
right=True,
|
||||
show_display_left=False, # No show icon for left panel
|
||||
show_display_right=False # No show icon for right panel
|
||||
)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
||||
renders but with zero width and overflow hidden.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Toggling Panel Visibility
|
||||
|
||||
Each visible panel includes a hide icon (−) in its top-right corner. When hidden, a show icon (⋯) appears in the main
|
||||
area:
|
||||
|
||||
**User interaction:**
|
||||
|
||||
- **Hide panel**: Click the − icon inside the panel
|
||||
- **Show panel**: Click the ⋯ icon in the main area
|
||||
|
||||
**Icon positions:**
|
||||
|
||||
- Hide icons (−): Always at top-right of each panel
|
||||
- Show icon for left panel (⋯): Top-left of main area
|
||||
- Show icon for right panel (⋯): Top-right of main area
|
||||
|
||||
**Visual states:**
|
||||
|
||||
```
|
||||
Panel Visible:
|
||||
┌──────────┐
|
||||
│ Content │
|
||||
│ [−] │ ← Hide icon visible
|
||||
└──────────┘
|
||||
|
||||
Panel Hidden:
|
||||
┌──────────────────┐
|
||||
│ [⋯] Main │ ← Show icon visible in main
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**Animation:**
|
||||
|
||||
When toggling visibility:
|
||||
|
||||
- **Hiding**: Panel width animates to 0px over 0.3s
|
||||
- **Showing**: Panel width animates to its saved width over 0.3s
|
||||
- Content remains in DOM (state preserved)
|
||||
- Smooth CSS transition with ease timing
|
||||
|
||||
**Note:** The animation only works when showing (panel appearing). When hiding, the transition currently doesn't apply
|
||||
due to HTMX swap timing. This is a known limitation.
|
||||
|
||||
### Resizable Panels
|
||||
|
||||
Both left and right panels can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left panel: Right edge (vertical bar)
|
||||
- Right panel: Left edge (vertical bar)
|
||||
- **Width constraints**: 150px (minimum) to 500px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in session state
|
||||
- **No transition during resize**: CSS transitions are disabled during manual dragging for smooth performance
|
||||
|
||||
**How to resize:**
|
||||
|
||||
1. Hover over the panel edge (cursor changes to resize cursor)
|
||||
2. Click and drag left/right
|
||||
3. Release to set the new width
|
||||
4. Width is saved automatically and persists in the session
|
||||
|
||||
**Initial widths:**
|
||||
|
||||
- Left panel: 250px
|
||||
- Right panel: 250px
|
||||
|
||||
These defaults can be customized via state after creation if needed.
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Panel automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|-----------------|--------------------------------|---------|
|
||||
| `left_visible` | Whether left panel is visible | `True` |
|
||||
| `right_visible` | Whether right panel is visible | `True` |
|
||||
| `left_width` | Left panel width in pixels | `250` |
|
||||
| `right_width` | Right panel width in pixels | `250` |
|
||||
|
||||
State changes (toggle visibility, resize width) are automatically saved and restored within the session.
|
||||
|
||||
**Accessing state:**
|
||||
|
||||
```python
|
||||
# Check current state
|
||||
is_left_visible = panel._state.left_visible
|
||||
left_panel_width = panel._state.left_width
|
||||
|
||||
# Programmatically update state (not recommended - use commands instead)
|
||||
panel._state.left_visible = False # Better to use toggle_side command
|
||||
```
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
You can control panels programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle panel visibility
|
||||
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
|
||||
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
|
||||
|
||||
# Update panel width
|
||||
update_left_width = panel.commands.update_side_width("left")
|
||||
update_right_width = panel.commands.update_side_width("right")
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Add buttons to toggle panels
|
||||
hide_left_btn = mk.button("Hide Left", command=panel.commands.set_side_visible("left", False))
|
||||
show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
|
||||
|
||||
# Add to your layout
|
||||
panel.set_main(
|
||||
Div(
|
||||
hide_left_btn,
|
||||
show_left_btn,
|
||||
H1("Main Content")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Command details:**
|
||||
|
||||
- `toggle_side(side, visible)`: Sets panel visibility explicitly
|
||||
- `side`: `"left"` or `"right"`
|
||||
- `visible`: `True` (show) or `False` (hide)
|
||||
- Returns: tuple of (panel_element, show_icon_element) for HTMX swap
|
||||
|
||||
- `update_side_width(side)`: Updates panel width from HTMX request
|
||||
- `side`: `"left"` or `"right"`
|
||||
- Width value comes from JavaScript resize handler
|
||||
- Returns: updated panel element for HTMX swap
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Panel uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|--------------------------------------------|
|
||||
| `mf-panel` | Root panel container |
|
||||
| `mf-panel-left` | Left panel container |
|
||||
| `mf-panel-right` | Right panel container |
|
||||
| `mf-panel-main` | Main content area |
|
||||
| `mf-panel-with-title` | Panel using title layout (no padding-top) |
|
||||
| `mf-panel-body` | Grid container for header + content |
|
||||
| `mf-panel-header` | Sticky header with title and hide icon |
|
||||
| `mf-panel-content` | Scrollable content area |
|
||||
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
||||
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
||||
| `mf-panel-show-icon-left` | Show icon for left panel |
|
||||
| `mf-panel-show-icon-right` | Show icon for right panel |
|
||||
| `mf-resizer` | Resize handle base class |
|
||||
| `mf-resizer-left` | Left panel resize handle |
|
||||
| `mf-resizer-right` | Right panel resize handle |
|
||||
| `mf-hidden` | Applied to hidden panels |
|
||||
| `no-transition` | Disables transition during manual resize |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change panel background color */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Customize hide icon appearance */
|
||||
.mf-panel-hide-icon:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Change transition timing */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
transition: width 0.5s ease-in-out; /* Slower animation */
|
||||
}
|
||||
|
||||
/* Style resizer handles */
|
||||
.mf-resizer {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Editor Layout
|
||||
|
||||
A typical code editor with file explorer, editor, and properties panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: File Explorer
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Explorer", cls="font-bold mb-2"),
|
||||
Div(
|
||||
Div("📁 src", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div(" 📄 config.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div("📁 tests", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 test_app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
cls="space-y-1"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Code Editor
|
||||
panel.set_main(
|
||||
Div(
|
||||
Div(
|
||||
Span("app.py", cls="font-bold"),
|
||||
Span("Python", cls="text-sm opacity-60 ml-2"),
|
||||
cls="border-b pb-2 mb-2"
|
||||
),
|
||||
Textarea(
|
||||
"""def main():
|
||||
print("Hello, World!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()""",
|
||||
rows=20,
|
||||
cls="w-full font-mono text-sm p-2 border rounded"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Properties and Tools
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties", cls="font-bold mb-2"),
|
||||
Div("Language: Python", cls="text-sm mb-1"),
|
||||
Div("Lines: 5", cls="text-sm mb-1"),
|
||||
Div("Size: 87 bytes", cls="text-sm mb-4"),
|
||||
|
||||
H3("Tools", cls="font-bold mb-2 mt-4"),
|
||||
Button("Run", cls="btn btn-sm btn-primary w-full mb-2"),
|
||||
Button("Debug", cls="btn btn-sm w-full mb-2"),
|
||||
Button("Format", cls="btn btn-sm w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 2: Dashboard with Filters
|
||||
|
||||
A data dashboard with filters sidebar and details panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: Filters
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Filters", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Label("Date Range", cls="label"),
|
||||
Select(
|
||||
Option("Last 7 days"),
|
||||
Option("Last 30 days"),
|
||||
Option("Last 90 days"),
|
||||
cls="select select-bordered w-full"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Div(
|
||||
Label("Category", cls="label"),
|
||||
Div(
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Sales", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Marketing", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Support", cls="label cursor-pointer"),
|
||||
cls="space-y-2"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Button("Apply Filters", cls="btn btn-primary w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Dashboard Charts
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Analytics Dashboard", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
Div(
|
||||
Div("Total Revenue", cls="stat-title"),
|
||||
Div("$45,231", cls="stat-value"),
|
||||
Div("+12% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
Div(
|
||||
Div("Active Users", cls="stat-title"),
|
||||
Div("2,345", cls="stat-value"),
|
||||
Div("+8% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
cls="stats shadow mb-4"
|
||||
),
|
||||
|
||||
Div("[Chart placeholder - Revenue over time]", cls="border rounded p-8 text-center"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Details and Insights
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Key Insights", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Div("🎯 Top Performing", cls="font-bold mb-1"),
|
||||
Div("Product A: $12,450", cls="text-sm"),
|
||||
Div("Product B: $8,920", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("📊 Trending Up", cls="font-bold mb-1"),
|
||||
Div("Category: Electronics", cls="text-sm"),
|
||||
Div("+23% this week", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("⚠️ Needs Attention", cls="font-bold mb-1"),
|
||||
Div("Low stock: Item X", cls="text-sm"),
|
||||
Div("Response time: +15%", cls="text-sm")
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 3: Simple Layout (Main Content Only)
|
||||
|
||||
A minimal panel with no side panels, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
|
||||
# Create panel with both side panels disabled
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only main content
|
||||
panel.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog", cls="text-3xl font-bold mb-4"),
|
||||
P("This is a simple layout focusing entirely on the main content."),
|
||||
P("No side panels distract from the reading experience."),
|
||||
P("The content takes up the full width of the container."),
|
||||
cls="prose max-w-none p-8"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Panel Updates
|
||||
|
||||
Controlling panels programmatically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set up content
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Dashboard"),
|
||||
Li("Reports"),
|
||||
Li("Settings")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Debug Info"),
|
||||
Div("Session ID: abc123"),
|
||||
Div("User: Admin"),
|
||||
Div("Timestamp: 2024-01-15")
|
||||
)
|
||||
)
|
||||
|
||||
# Create control buttons
|
||||
toggle_left_btn = mk.button(
|
||||
"Toggle Left Panel",
|
||||
command=panel.commands.set_side_visible("left", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
toggle_right_btn = mk.button(
|
||||
"Toggle Right Panel",
|
||||
command=panel.commands.set_side_visible("right", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
show_all_btn = mk.button(
|
||||
"Show All Panels",
|
||||
command=Command(
|
||||
"show_all",
|
||||
"Show all panels",
|
||||
lambda: (
|
||||
panel.toggle_side("left", True),
|
||||
panel.toggle_side("right", True)
|
||||
)
|
||||
),
|
||||
cls="btn btn-sm btn-primary"
|
||||
)
|
||||
|
||||
# Main content with controls
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Panel Controls Demo", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
toggle_left_btn,
|
||||
toggle_right_btn,
|
||||
show_all_btn,
|
||||
cls="space-x-2 mb-4"
|
||||
),
|
||||
|
||||
P("Use the buttons above to toggle panels programmatically."),
|
||||
P("You can also use the hide (−) and show (⋯) icons."),
|
||||
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Panel component itself.
|
||||
|
||||
### Configuration
|
||||
|
||||
The Panel component uses `PanelConf` dataclass for configuration:
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|----------------------|---------|-------------------------------------------|-----------|
|
||||
| `left` | boolean | Enable/disable left panel | `False` |
|
||||
| `right` | boolean | Enable/disable right panel | `True` |
|
||||
| `left_title` | string | Title displayed in left panel header | `"Left"` |
|
||||
| `right_title` | string | Title displayed in right panel header | `"Right"` |
|
||||
| `show_left_title` | boolean | Show title header on left panel | `True` |
|
||||
| `show_right_title` | boolean | Show title header on right panel | `True` |
|
||||
| `show_display_left` | boolean | Show the "show" icon when left is hidden | `True` |
|
||||
| `show_display_right` | boolean | Show the "show" icon when right is hidden | `True` |
|
||||
|
||||
### State
|
||||
|
||||
The Panel component maintains the following state properties via `PanelState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-----------------|---------|------------------------------------|---------|
|
||||
| `left_visible` | boolean | True if the left panel is visible | `True` |
|
||||
| `right_visible` | boolean | True if the right panel is visible | `True` |
|
||||
| `left_width` | integer | Width of the left panel in pixels | `250` |
|
||||
| `right_width` | integer | Width of the right panel in pixels | `250` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------------|-------------------------------------------------------------------|
|
||||
| `toggle_side(side, visible)` | Sets panel visibility (side: "left"/"right", visible: True/False) |
|
||||
| `update_side_width(side)` | Updates panel width from HTMX request (side: "left"/"right") |
|
||||
|
||||
**Note:** The old `toggle_side(side)` command without the `visible` parameter is deprecated but still available in the
|
||||
codebase.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|----------------------|------------------------------|---------|
|
||||
| `set_main(content)` | Sets the main content area | `self` |
|
||||
| `set_left(content)` | Sets the left panel content | `Div` |
|
||||
| `set_right(content)` | Sets the right panel content | `Div` |
|
||||
| `render()` | Renders the complete panel | `Div` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
**With title (default, `show_*_title=True`):**
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-panel")
|
||||
├── Div(id="{id}_pl", cls="mf-panel-left mf-panel-with-title [mf-hidden]")
|
||||
│ ├── Div(cls="mf-panel-body")
|
||||
│ │ ├── Div(cls="mf-panel-header")
|
||||
│ │ │ ├── Div [Title text]
|
||||
│ │ │ └── Div (hide icon)
|
||||
│ │ └── Div(id="{id}_cl", cls="mf-panel-content")
|
||||
│ │ └── [Left content - scrollable]
|
||||
│ └── Div (resizer-left)
|
||||
├── Div(cls="mf-panel-main")
|
||||
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||
│ │ └── [Main content]
|
||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||
├── Div(id="{id}_pr", cls="mf-panel-right mf-panel-with-title [mf-hidden]")
|
||||
│ ├── Div (resizer-right)
|
||||
│ └── Div(cls="mf-panel-body")
|
||||
│ ├── Div(cls="mf-panel-header")
|
||||
│ │ ├── Div [Title text]
|
||||
│ │ └── Div (hide icon)
|
||||
│ └── Div(id="{id}_cr", cls="mf-panel-content")
|
||||
│ └── [Right content - scrollable]
|
||||
└── Script # initResizer('{id}')
|
||||
```
|
||||
|
||||
**Without title (legacy, `show_*_title=False`):**
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-panel")
|
||||
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ ├── Div(id="{id}_cl")
|
||||
│ │ └── [Left content]
|
||||
│ └── Div (resizer-left)
|
||||
├── Div(cls="mf-panel-main")
|
||||
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||
│ │ └── [Main content]
|
||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
||||
│ ├── Div (resizer-right)
|
||||
│ ├── Div (hide icon - absolute positioned)
|
||||
│ └── Div(id="{id}_cr")
|
||||
│ └── [Right content]
|
||||
└── Script # initResizer('{id}')
|
||||
```
|
||||
|
||||
**Note:**
|
||||
|
||||
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
|
||||
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
|
||||
- Left panel: body/content then resizer (resizer on right edge)
|
||||
- Right panel: resizer then body/content (resizer on left edge)
|
||||
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
||||
- `mf-panel-with-title` class removes default padding-top when using title layout
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|-------------------------------------|
|
||||
| `{id}` | Root panel container |
|
||||
| `{id}_pl` | Left panel container |
|
||||
| `{id}_pr` | Right panel container |
|
||||
| `{id}_cl` | Left panel content wrapper |
|
||||
| `{id}_cr` | Right panel content wrapper |
|
||||
| `{id}_m` | Main content wrapper |
|
||||
| `{id}_show_left` | Show icon for left panel (in main) |
|
||||
| `{id}_show_right`| Show icon for right panel (in main) |
|
||||
|
||||
**Note:** `{id}` is the Panel instance ID (auto-generated UUID or custom `_id`).
|
||||
|
||||
**ID Management:**
|
||||
|
||||
The Panel component uses the `PanelIds` helper class to manage element IDs consistently. Access IDs programmatically:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Access IDs via get_ids()
|
||||
panel.get_ids().panel("left") # Returns "{id}_pl"
|
||||
panel.get_ids().panel("right") # Returns "{id}_pr"
|
||||
panel.get_ids().left # Returns "{id}_cl"
|
||||
panel.get_ids().right # Returns "{id}_cr"
|
||||
panel.get_ids().main # Returns "{id}_m"
|
||||
panel.get_ids().content("left") # Returns "{id}_cl"
|
||||
```
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------|---------------------------------------------------|
|
||||
| `_mk_panel(side)` | Renders a panel (left or right) with all elements |
|
||||
| `_mk_show_icon(side)` | Renders the show icon for a panel |
|
||||
|
||||
**Method details:**
|
||||
|
||||
- `_mk_panel(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Creates resizer with command and data attributes
|
||||
- Creates hide icon with toggle command
|
||||
- Applies `mf-hidden` class if panel is not visible
|
||||
- Returns None if panel is disabled
|
||||
|
||||
- `_mk_show_icon(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Returns None if panel is disabled or visible
|
||||
- Applies `hidden` (Tailwind) class if panel is visible
|
||||
- Applies positioning class based on side
|
||||
|
||||
### JavaScript Integration
|
||||
|
||||
The Panel component uses JavaScript for manual resizing:
|
||||
|
||||
**initResizer(panelId):**
|
||||
|
||||
- Initializes drag-and-drop resize functionality
|
||||
- Adds/removes `no-transition` class during drag
|
||||
- Sends width updates to server via HTMX
|
||||
- Constrains width between 150px and 500px
|
||||
|
||||
**File:** `src/myfasthtml/assets/core/myfasthtml.js`
|
||||
648
docs/TabsManager.md
Normal file
648
docs/TabsManager.md
Normal file
@@ -0,0 +1,648 @@
|
||||
# TabsManager Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML
|
||||
application. It handles tab creation, activation, closing, and content management with automatic state persistence and
|
||||
HTMX-powered interactions.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Dynamic tab creation and removal at runtime
|
||||
- Automatic content caching for performance
|
||||
- Session-based state persistence (tabs, order, active tab)
|
||||
- Duplicate tab detection based on component identity
|
||||
- Built-in search menu for quick tab navigation
|
||||
- Auto-increment labels for programmatic tab creation
|
||||
- HTMX-powered updates without page reload
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Multi-document editor (code editor, text editor)
|
||||
- Dashboard with multiple data views
|
||||
- Settings interface with different configuration panels
|
||||
- Developer tools with console, inspector, network tabs
|
||||
- Application with dynamic content sections
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a tabbed interface with three views:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create root instance and tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root)
|
||||
|
||||
# Create three tabs with different content
|
||||
tabs.create_tab("Dashboard", Div(H1("Dashboard"), P("Overview of your data")))
|
||||
tabs.create_tab("Settings", Div(H1("Settings"), P("Configure your preferences")))
|
||||
tabs.create_tab("Profile", Div(H1("Profile"), P("Manage your profile")))
|
||||
|
||||
# Render the tabs manager
|
||||
return tabs
|
||||
```
|
||||
|
||||
This creates a complete tabbed interface with:
|
||||
|
||||
- A header bar displaying three clickable tab buttons ("Dashboard", "Settings", "Profile")
|
||||
- Close buttons (×) on each tab for dynamic removal
|
||||
- A main content area showing the active tab's content
|
||||
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
|
||||
- Automatic HTMX updates when switching or closing tabs
|
||||
|
||||
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove
|
||||
tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX
|
||||
integration.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The TabsManager component consists of a header with tab buttons and a content area:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Tab Header │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
|
||||
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Active Tab Content │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|------------------|-----------------------------------------|
|
||||
| Tab buttons | Clickable labels to switch between tabs |
|
||||
| Close button (×) | Removes the tab and its content |
|
||||
| Search menu (⊞) | Dropdown menu to search and filter tabs |
|
||||
| Content area | Displays the active tab's content |
|
||||
|
||||
### Creating a TabsManager
|
||||
|
||||
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application.
|
||||
Create it by providing a parent instance:
|
||||
|
||||
```python
|
||||
tabs = TabsManager(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
tabs = TabsManager(parent=root_instance, _id="my-tabs")
|
||||
```
|
||||
|
||||
### Creating Tabs
|
||||
|
||||
Use the `create_tab()` method to add a new tab:
|
||||
|
||||
```python
|
||||
# Create a tab with custom content
|
||||
tab_id = tabs.create_tab(
|
||||
label="My Tab",
|
||||
component=Div(H1("Content"), P("Tab content here"))
|
||||
)
|
||||
|
||||
# Create with a MyFastHtml control
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
|
||||
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
|
||||
tab_id = tabs.create_tab("Network View", network)
|
||||
|
||||
# Create without activating immediately
|
||||
tab_id = tabs.create_tab("Background Tab", content, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `label` (str): Display text shown in the tab button
|
||||
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
|
||||
- `activate` (bool): Whether to make this tab active immediately (default: True)
|
||||
|
||||
**Returns:** A unique `tab_id` (UUID string) that identifies the tab
|
||||
|
||||
### Showing Tabs
|
||||
|
||||
Use the `show_tab()` method to activate and display a tab:
|
||||
|
||||
```python
|
||||
# Show a tab (makes it active and sends content to client if needed)
|
||||
tabs.show_tab(tab_id)
|
||||
|
||||
# Show without activating (just send content to client)
|
||||
tabs.show_tab(tab_id, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to show
|
||||
- `activate` (bool): Whether to make this tab active (default: True)
|
||||
|
||||
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just
|
||||
toggle visibility without re-sending content.
|
||||
|
||||
### Closing Tabs
|
||||
|
||||
Use the `close_tab()` method to remove a tab:
|
||||
|
||||
```python
|
||||
# Close a specific tab
|
||||
tabs.close_tab(tab_id)
|
||||
```
|
||||
|
||||
**What happens when closing:**
|
||||
|
||||
1. Tab is removed from the tab list and order
|
||||
2. Content is removed from cache and client
|
||||
3. If the closed tab was active, the first remaining tab becomes active
|
||||
4. If no tabs remain, `active_tab` is set to `None`
|
||||
|
||||
### Changing Tab Content
|
||||
|
||||
Use the `change_tab_content()` method to update an existing tab's content and label:
|
||||
|
||||
```python
|
||||
# Update tab content and label
|
||||
new_content = Div(H1("Updated"), P("New content"))
|
||||
tabs.change_tab_content(
|
||||
tab_id=tab_id,
|
||||
label="Updated Tab",
|
||||
component=new_content,
|
||||
activate=True
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to update
|
||||
- `label` (str): New label for the tab
|
||||
- `component` (Any): New content to display
|
||||
- `activate` (bool): Whether to activate the tab after updating (default: True)
|
||||
|
||||
**Note:** This method forces the new content to be sent to the client, even if the tab was already displayed.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Auto-increment Labels
|
||||
|
||||
When creating multiple tabs programmatically, you can use auto-increment to generate unique labels:
|
||||
|
||||
```python
|
||||
# Using the on_new_tab method with auto_increment
|
||||
def create_multiple_tabs():
|
||||
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The TabsManager maintains an internal counter (`_tab_count`)
|
||||
- When `auto_increment=True`, the counter value is appended to the label
|
||||
- Counter increments with each auto-incremented tab creation
|
||||
- Useful for "New Tab 1", "New Tab 2" patterns in editors or tools
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
The TabsManager automatically detects and reuses tabs with identical content to prevent duplicates:
|
||||
|
||||
```python
|
||||
# Create a control instance
|
||||
network = VisNetwork(parent=tabs, nodes=data, edges=edges)
|
||||
|
||||
# First call creates a new tab
|
||||
tab_id_1 = tabs.create_tab("Network", network)
|
||||
|
||||
# Second call with same label and component returns existing tab_id
|
||||
tab_id_2 = tabs.create_tab("Network", network)
|
||||
|
||||
# tab_id_1 == tab_id_2 (True - same tab!)
|
||||
```
|
||||
|
||||
**Detection criteria:**
|
||||
A tab is considered a duplicate if all three match:
|
||||
|
||||
- Same `label`
|
||||
- Same `component_type` (component class prefix)
|
||||
- Same `component_id` (component instance ID)
|
||||
|
||||
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs
|
||||
and will always create new tabs.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
You can update tabs dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial tab creation
|
||||
tab_id = tabs.create_tab("Data View", Div("Loading..."))
|
||||
|
||||
|
||||
# Later, update with actual data
|
||||
def load_data():
|
||||
data_content = Div(H2("Data"), P("Loaded content"))
|
||||
tabs.change_tab_content(tab_id, "Data View", data_content)
|
||||
# Returns HTMX response to update the UI
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Loading data asynchronously
|
||||
- Refreshing tab content based on user actions
|
||||
- Updating visualizations with new data
|
||||
- Switching between different views in the same tab
|
||||
|
||||
### Tab Search Menu
|
||||
|
||||
The built-in search menu helps users navigate when many tabs are open:
|
||||
|
||||
```python
|
||||
# The search menu is automatically created and includes:
|
||||
# - A Search control for filtering tabs by label
|
||||
# - Live filtering as you type
|
||||
# - Click to activate a tab from search results
|
||||
```
|
||||
|
||||
**How to access:**
|
||||
|
||||
- Click the ⊞ icon in the tab header
|
||||
- Start typing to filter tabs by label
|
||||
- Click a result to activate that tab
|
||||
|
||||
The search menu updates automatically when tabs are added or removed.
|
||||
|
||||
### HTMX Out-of-Band Swaps
|
||||
|
||||
For advanced HTMX control, you can customize swap behavior:
|
||||
|
||||
```python
|
||||
# Standard behavior (out-of-band swap enabled)
|
||||
tabs.show_tab(tab_id, oob=True) # Default
|
||||
|
||||
# Custom target behavior (disable out-of-band)
|
||||
tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
|
||||
```
|
||||
|
||||
**When to use `oob=False`:**
|
||||
|
||||
- When you want to control the exact HTMX target
|
||||
- When combining with other HTMX responses
|
||||
- When the tab activation is triggered by a command with a specific target
|
||||
|
||||
**When to use `oob=True` (default):**
|
||||
|
||||
- Most common use case
|
||||
- Allows other controls to trigger tab changes without caring about targets
|
||||
- Enables automatic UI updates across multiple elements
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The TabsManager uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|--------------------------|---------------------------------|
|
||||
| `mf-tabs-manager` | Root tabs manager container |
|
||||
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
|
||||
| `mf-tabs-header` | Tab buttons container |
|
||||
| `mf-tab-button` | Individual tab button |
|
||||
| `mf-tab-active` | Active tab button (modifier) |
|
||||
| `mf-tab-label` | Tab label text |
|
||||
| `mf-tab-close-btn` | Close button (×) |
|
||||
| `mf-tab-content-wrapper` | Content area container |
|
||||
| `mf-tab-content` | Individual tab content |
|
||||
| `mf-empty-content` | Empty state when no tabs |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change active tab color */
|
||||
.mf-tab-active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Customize close button */
|
||||
.mf-tab-close-btn:hover {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Style the content area */
|
||||
.mf-tab-content-wrapper {
|
||||
padding: 2rem;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Multi-view Application
|
||||
|
||||
A typical application with different views accessible through tabs:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="app-tabs")
|
||||
|
||||
# Dashboard view
|
||||
dashboard = Div(
|
||||
H1("Dashboard"),
|
||||
Div(
|
||||
Div("Total Users: 1,234", cls="stat"),
|
||||
Div("Active Sessions: 56", cls="stat"),
|
||||
Div("Revenue: $12,345", cls="stat"),
|
||||
cls="stats-grid"
|
||||
)
|
||||
)
|
||||
|
||||
# Analytics view
|
||||
analytics = Div(
|
||||
H1("Analytics"),
|
||||
P("Detailed analytics and reports"),
|
||||
Div("Chart placeholder", cls="chart-container")
|
||||
)
|
||||
|
||||
# Settings view
|
||||
settings = Div(
|
||||
H1("Settings"),
|
||||
Form(
|
||||
Label("Username:", Input(name="username", value="admin")),
|
||||
Label("Email:", Input(name="email", value="admin@example.com")),
|
||||
Button("Save", type="submit"),
|
||||
)
|
||||
)
|
||||
|
||||
# Create tabs
|
||||
tabs.create_tab("Dashboard", dashboard)
|
||||
tabs.create_tab("Analytics", analytics)
|
||||
tabs.create_tab("Settings", settings)
|
||||
|
||||
# Render
|
||||
return tabs
|
||||
```
|
||||
|
||||
### Example 2: Dynamic Tabs with VisNetwork
|
||||
|
||||
Creating tabs dynamically with interactive network visualizations:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="network-tabs")
|
||||
|
||||
# Create initial tab with welcome message
|
||||
tabs.create_tab("Welcome", Div(
|
||||
H1("Network Visualizer"),
|
||||
P("Click 'Add Network' to create a new network visualization")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new network tab
|
||||
def add_network_tab():
|
||||
# Define network data
|
||||
nodes = [
|
||||
{"id": 1, "label": "Node 1"},
|
||||
{"id": 2, "label": "Node 2"},
|
||||
{"id": 3, "label": "Node 3"}
|
||||
]
|
||||
edges = [
|
||||
{"from": 1, "to": 2},
|
||||
{"from": 2, "to": 3}
|
||||
]
|
||||
|
||||
# Create network instance
|
||||
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
|
||||
|
||||
# Use auto-increment to create unique labels
|
||||
return tabs.on_new_tab("Network", network, auto_increment=True)
|
||||
|
||||
|
||||
# Create command for adding networks
|
||||
add_cmd = Command("add_network", "Add network tab", add_network_tab)
|
||||
|
||||
# Add button to create new network tabs
|
||||
add_button = mk.button("Add Network", command=add_cmd, cls="btn btn-primary")
|
||||
|
||||
# Return tabs and button
|
||||
return Div(add_button, tabs)
|
||||
```
|
||||
|
||||
### Example 3: Tab Management with Content Updates
|
||||
|
||||
An application that updates tab content based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="editor-tabs")
|
||||
|
||||
# Create initial document tabs
|
||||
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
|
||||
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
|
||||
|
||||
|
||||
# Function to refresh a document's content
|
||||
def refresh_document(tab_id, doc_name):
|
||||
# Simulate loading new content
|
||||
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
|
||||
tabs.change_tab_content(tab_id, doc_name, new_content)
|
||||
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
|
||||
|
||||
|
||||
# Create refresh commands
|
||||
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
|
||||
refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id, "Document 2")
|
||||
|
||||
# Add refresh buttons
|
||||
controls = Div(
|
||||
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
|
||||
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
|
||||
cls="controls-bar"
|
||||
)
|
||||
|
||||
return Div(controls, tabs)
|
||||
```
|
||||
|
||||
### Example 4: Using Auto-increment for Dynamic Tabs
|
||||
|
||||
Creating multiple tabs programmatically with auto-generated labels:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="dynamic-tabs")
|
||||
|
||||
# Create initial placeholder tab
|
||||
tabs.create_tab("Start", Div(
|
||||
H2("Welcome"),
|
||||
P("Click 'New Tab' to create numbered tabs")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new numbered tab
|
||||
def create_numbered_tab():
|
||||
content = Div(
|
||||
H2("New Tab Content"),
|
||||
P(f"This tab was created dynamically"),
|
||||
Input(placeholder="Enter some text...", cls="input")
|
||||
)
|
||||
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
|
||||
return tabs.on_new_tab("Tab", content, auto_increment=True)
|
||||
|
||||
|
||||
# Create command
|
||||
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
||||
|
||||
# Add button
|
||||
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
|
||||
|
||||
return Div(
|
||||
Div(new_tab_button, cls="toolbar"),
|
||||
tabs
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TabsManager component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TabsManager component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|--------------------------|----------------|---------------------------------------------------|---------|
|
||||
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
|
||||
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
|
||||
| `active_tab` | str \| None | ID of the currently active tab | `None` |
|
||||
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
|
||||
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
|
||||
|
||||
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------------------------------|--------------------------------------------|
|
||||
| `show_tab(tab_id)` | Activate or show a specific tab |
|
||||
| `close_tab(tab_id)` | Close a specific tab |
|
||||
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------------------------------------------|-------------------------------------------------|
|
||||
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
|
||||
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
|
||||
| `close_tab(tab_id)` | Close and remove a tab |
|
||||
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
|
||||
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
|
||||
| `add_tab_btn()` | Returns add tab button element |
|
||||
| `get_state()` | Returns the TabsManagerState object |
|
||||
| `render()` | Renders the complete TabsManager component |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-tabs-manager")
|
||||
├── Div(id="{id}-controller") # Controller (hidden, manages active state)
|
||||
├── Div(id="{id}-header-wrapper") # Header wrapper
|
||||
│ ├── Div(id="{id}-header") # Tab buttons container
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 1
|
||||
│ │ │ ├── Span (mf-tab-label) # Label (clickable)
|
||||
│ │ │ └── Span (mf-tab-close-btn) # Close button
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 2
|
||||
│ │ └── ...
|
||||
│ └── Div (dropdown) # Search menu
|
||||
│ ├── Icon (tabs24_regular) # Menu toggle button
|
||||
│ └── Div (dropdown-content) # Search component
|
||||
├── Div(id="{id}-content-wrapper") # Content wrapper
|
||||
│ ├── Div(id="{id}-{tab_id_1}-content") # Tab 1 content
|
||||
│ ├── Div(id="{id}-{tab_id_2}-content") # Tab 2 content
|
||||
│ └── ...
|
||||
└── Script # Initialization script
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------------------|-----------------------------------------|
|
||||
| `{id}` | Root tabs manager container |
|
||||
| `{id}-controller` | Hidden controller managing active state |
|
||||
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
|
||||
| `{id}-header` | Tab buttons container |
|
||||
| `{id}-content-wrapper` | Content area wrapper |
|
||||
| `{id}-{tab_id}-content` | Individual tab content |
|
||||
| `{id}-search` | Search component ID |
|
||||
|
||||
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------------|-----------------------------------------------------|
|
||||
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
|
||||
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
|
||||
| `_mk_tab_button(tab_data)` | Renders a single tab button |
|
||||
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
|
||||
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
|
||||
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
|
||||
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
|
||||
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
|
||||
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
|
||||
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
|
||||
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
|
||||
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
|
||||
| `_get_tab_list()` | Returns list of tab dictionaries in order |
|
||||
| `_get_tab_count()` | Returns and increments internal tab counter |
|
||||
|
||||
### Tab Metadata Structure
|
||||
|
||||
Each tab in the `tabs` dictionary has the following structure:
|
||||
|
||||
```python
|
||||
{
|
||||
'id': 'uuid-string', # Unique tab identifier
|
||||
'label': 'Tab Label', # Display label
|
||||
'component_type': 'prefix', # Component class prefix (or None)
|
||||
'component_id': 'instance-id' # Component instance ID (or None)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from
|
||||
`BaseInstance`.
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "myfasthtml"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Set of tools to quickly create HTML pages using FastHTML."
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
@@ -43,6 +43,7 @@ dependencies = [
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
"lark",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -73,10 +74,12 @@ dev = [
|
||||
# -------------------------------------------------------------------
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
packages = ["myfasthtml"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
myfasthtml = [
|
||||
"assets/*.css",
|
||||
"assets/*.js"
|
||||
"assets/**/*.css",
|
||||
"assets/**/*.js"
|
||||
]
|
||||
@@ -33,21 +33,25 @@ jaraco.context==6.0.1
|
||||
jaraco.functools==4.3.0
|
||||
jeepney==0.9.0
|
||||
keyring==25.6.0
|
||||
lark==1.3.1
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.1
|
||||
mydbengine==0.1.0
|
||||
myutils==0.5.0
|
||||
mydbengine==0.2.1
|
||||
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
|
||||
myutils==0.5.1
|
||||
nh3==0.3.1
|
||||
numpy==2.3.5
|
||||
oauthlib==3.3.1
|
||||
openpyxl==3.1.5
|
||||
packaging==25.0
|
||||
pandas==2.3.3
|
||||
pandas-stubs==2.3.3.251201
|
||||
passlib==1.7.4
|
||||
pipdeptree==2.29.0
|
||||
pluggy==1.6.0
|
||||
pyarrow==22.0.0
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.3
|
||||
@@ -77,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
|
||||
|
||||
20
src/app.py
20
src/app.py
@@ -1,7 +1,11 @@
|
||||
import json
|
||||
import logging.config
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from dbengine.handlers import BaseRefHandler, handlers
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
@@ -13,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
|
||||
@@ -34,6 +39,7 @@ app, rt = create_app(protect_routes=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
@@ -82,7 +88,9 @@ def create_sample_treeview(parent):
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||
session_instance = UniqueInstance(session=session,
|
||||
_id=Ids.UserSession,
|
||||
on_init=lambda: handlers.register_handler(DataFrameHandler()))
|
||||
layout = Layout(session_instance, "Testing Layout")
|
||||
layout.footer_left.add("Goodbye World")
|
||||
|
||||
@@ -122,8 +130,16 @@ def index(session):
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
layout.left_drawer.add(btn_popup, "Test")
|
||||
layout.left_drawer.add(tree_view, "TreeView")
|
||||
layout.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "Documents")
|
||||
|
||||
# data grids
|
||||
dgs_manager = DataGridsManager(layout, _id="-datagrids")
|
||||
layout.left_drawer.add_group("Documents", Div("Documents",
|
||||
dgs_manager.mk_main_icons(),
|
||||
cls="mf-layout-group flex gap-3"))
|
||||
layout.left_drawer.add(dgs_manager, "Documents")
|
||||
layout.set_main(tabs_manager)
|
||||
|
||||
# keyboard shortcuts
|
||||
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
|
||||
15
src/myfasthtml/assets/Readme.md
Normal file
15
src/myfasthtml/assets/Readme.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 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/5.65.20/addon/lint/lint.min.css
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.js
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/mode/simple.min.js
|
||||
```
|
||||
1
src/myfasthtml/assets/codemirror/codemirror.min.css
vendored
Normal file
1
src/myfasthtml/assets/codemirror/codemirror.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror/codemirror.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror/codemirror.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror/lint.min.css
vendored
Normal file
1
src/myfasthtml/assets/codemirror/lint.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:#ffd;border:1px solid #000;border-radius:4px 4px 4px 4px;color:#000;font-family:monospace;font-size:10pt;overflow:hidden;padding:2px 5px;position:fixed;white-space:pre;white-space:pre-wrap;z-index:100;max-width:600px;opacity:0;transition:opacity .4s;-moz-transition:opacity .4s;-webkit-transition:opacity .4s;-o-transition:opacity .4s;-ms-transition:opacity .4s}.CodeMirror-lint-mark{background-position:left bottom;background-repeat:repeat-x}.CodeMirror-lint-mark-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=)}.CodeMirror-lint-mark-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==)}.CodeMirror-lint-marker{background-position:center center;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;width:16px;vertical-align:middle;position:relative}.CodeMirror-lint-message{padding-left:18px;background-position:top left;background-repeat:no-repeat}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=)}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=)}.CodeMirror-lint-marker-multiple{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:right bottom;width:100%;height:100%}.CodeMirror-lint-line-error{background-color:rgba(183,76,81,.08)}.CodeMirror-lint-line-warning{background-color:rgba(255,211,0,.1)}
|
||||
1
src/myfasthtml/assets/codemirror/lint.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror/lint.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(p){"use strict";var h="CodeMirror-lint-markers",g="CodeMirror-lint-line-";function u(t){t.parentNode&&t.parentNode.removeChild(t)}function v(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),p.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 p.off(document,"mousemove",a);var e=Math.max(0,t.clientY-i.offsetHeight-5),t=Math.max(0,Math.min(t.clientX+5,i.ownerDocument.defaultView.innerWidth-i.offsetWidth));i.style.top=e+"px",i.style.left=t+"px"}function l(){var t;p.off(o,"mouseout",l),r&&((t=r).parentNode&&(null==t.style.opacity&&u(t),t.style.opacity=0,setTimeout(function(){u(t)},600)),r=null)}var s=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){l();break}}if(!r)return clearInterval(s)},400);p.on(o,"mouseout",l)}function a(s,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=s,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 l=i[a].__annotation;l&&r.push(l)}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))}v(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 C(t){var n,e=t.state.lint;e.hasGutter&&t.clearGutter(h),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 l(e){var t,n,o,i,r,a,l=e.state.lint;function s(){a=-1,o.off("change",s)}!l||(t=(i=l.options).getAnnotations||e.getHelper(p.Pos(0,0),"lint"))&&(i.async||t.async?(i=t,r=(o=e).state.lint,a=++r.waitingFor,o.on("change",s),i(o.getValue(),function(t,e){o.off("change",s),r.waitingFor==a&&(e&&t instanceof p&&(t=e),o.operation(function(){c(o,t)}))},r.linterOptions,o)):(n=t(e.getValue(),l.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=(C(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)),l=0;l<a.length;++l){var s=a[l];if(s){for(var u=null,c=n.hasGutter&&document.createDocumentFragment(),f=0;f<s.length;++f){var m=s[f],d=m.severity;i=d=d||"error",u="error"==(o=u)?o:i,r.formatAnnotation&&(m=r.formatAnnotation(m)),n.hasGutter&&c.appendChild(M(m)),m.to&&n.marked.push(t.markText(m.from,m.to,{className:"CodeMirror-lint-mark CodeMirror-lint-mark-"+d,__annotation:m}))}n.hasGutter&&t.setGutterMarker(l,h,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&&p.on(a,"mouseover",function(t){v(e,t,n,a)}),r}(t,c,u,1<s.length,r.tooltips)),r.highlightLines&&t.addLineClass(l,"wrap",g+u)}}r.onUpdateLinting&&r.onUpdateLinting(e,a,t)}}function s(t){var e=t.state.lint;e&&(clearTimeout(e.timeout),e.timeout=setTimeout(function(){l(t)},e.options.delay))}p.defineOption("lint",!1,function(t,e,n){if(n&&n!=p.Init&&(C(t),!1!==t.state.lint.options.lintOnChange&&t.off("change",s),p.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]==h&&(i=!0);n=t.state.lint=new a(t,e,i);n.options.lintOnChange&&t.on("change",s),0!=n.options.tooltips&&"gutter"!=n.options.tooltips&&p.on(t.getWrapperElement(),"mouseover",n.onMouseOver),l(t)}}),p.defineExtension("performLint",function(){l(this)})});
|
||||
1
src/myfasthtml/assets/codemirror/placeholder.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror/placeholder.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(r){function n(e){e.state.placeholder&&(e.state.placeholder.parentNode.removeChild(e.state.placeholder),e.state.placeholder=null)}function i(e){n(e);var o=e.state.placeholder=document.createElement("pre"),t=(o.style.cssText="height: 0; overflow: visible",o.style.direction=e.getOption("direction"),o.className="CodeMirror-placeholder CodeMirror-line-like",e.getOption("placeholder"));"string"==typeof t&&(t=document.createTextNode(t)),o.appendChild(t),e.display.lineSpace.insertBefore(o,e.display.lineSpace.firstChild)}function l(e){c(e)&&i(e)}function a(e){var o=e.getWrapperElement(),t=c(e);o.className=o.className.replace(" CodeMirror-empty","")+(t?" CodeMirror-empty":""),(t?i:n)(e)}function c(e){return 1===e.lineCount()&&""===e.getLine(0)}r.defineOption("placeholder","",function(e,o,t){var t=t&&t!=r.Init;o&&!t?(e.on("blur",l),e.on("change",a),e.on("swapDoc",a),r.on(e.getInputField(),"compositionupdate",e.state.placeholderCompose=function(){var t;t=e,setTimeout(function(){var e,o=!1;((o=1==t.lineCount()?"TEXTAREA"==(e=t.getInputField()).nodeName?!t.getLine(0).length:!/[^\u200b]/.test(e.querySelector(".CodeMirror-line").textContent):o)?i:n)(t)},20)}),a(e)):!o&&t&&(e.off("blur",l),e.off("change",a),e.off("swapDoc",a),r.off(e.getInputField(),"compositionupdate",e.state.placeholderCompose),n(e),(t=e.getWrapperElement()).className=t.className.replace(" CodeMirror-empty","")),o&&!e.hasFocus()&&l(e)})});
|
||||
1
src/myfasthtml/assets/codemirror/show-hint.min.css
vendored
Normal file
1
src/myfasthtml/assets/codemirror/show-hint.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}
|
||||
1
src/myfasthtml/assets/codemirror/show-hint.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror/show-hint.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/myfasthtml/assets/codemirror/simple.min.js
vendored
Normal file
1
src/myfasthtml/assets/codemirror/simple.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});
|
||||
50
src/myfasthtml/assets/core/boundaries.js
Normal file
50
src/myfasthtml/assets/core/boundaries.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function initBoundaries(elementId, updateUrl) {
|
||||
function updateBoundaries() {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!container) {
|
||||
console.warn("initBoundaries : element " + elementId + " is not found !");
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.floor(rect.width);
|
||||
const height = Math.floor(rect.height);
|
||||
console.log("boundaries: ", rect)
|
||||
|
||||
// Send boundaries to server
|
||||
htmx.ajax('POST', updateUrl, {
|
||||
target: '#' + elementId,
|
||||
swap: 'outerHTML',
|
||||
values: {width: width, height: height}
|
||||
});
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
let resizeTimeout;
|
||||
|
||||
function debouncedUpdate() {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updateBoundaries, 250);
|
||||
}
|
||||
|
||||
// Update on load
|
||||
setTimeout(updateBoundaries, 100);
|
||||
|
||||
// Update on window resize
|
||||
const container = document.getElementById(elementId);
|
||||
container.addEventListener('resize', debouncedUpdate);
|
||||
|
||||
// Cleanup on element removal
|
||||
if (container) {
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
mutation.removedNodes.forEach(function (node) {
|
||||
if (node.id === elementId) {
|
||||
window.removeEventListener('resize', debouncedUpdate);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
observer.observe(container.parentNode, {childList: true});
|
||||
}
|
||||
}
|
||||
57
src/myfasthtml/assets/core/dropdown.css
Normal file
57
src/myfasthtml/assets/core/dropdown.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.mf-dropdown-wrapper {
|
||||
position: relative; /* CRUCIAL for the anchor */
|
||||
}
|
||||
|
||||
.mf-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
|
||||
/* DaisyUI styling */
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000),
|
||||
0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000);
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
display: block;
|
||||
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%);
|
||||
}
|
||||
11
src/myfasthtml/assets/core/dropdown.js
Normal file
11
src/myfasthtml/assets/core/dropdown.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Check if the click was on a dropdown button element.
|
||||
* Used with hx-vals="js:getDropdownExtra()" for Dropdown toggle behavior.
|
||||
*
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @returns {Object} Object with is_button boolean property
|
||||
*/
|
||||
function getDropdownExtra(event) {
|
||||
const button = event.target.closest('.mf-dropdown-btn');
|
||||
return {is_button: button !== null};
|
||||
}
|
||||
329
src/myfasthtml/assets/core/dsleditor.css
Normal file
329
src/myfasthtml/assets/core/dsleditor.css
Normal file
@@ -0,0 +1,329 @@
|
||||
/* *********************************************** */
|
||||
/* ********** CodeMirror DaisyUI Theme *********** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Theme selector - uses DaisyUI variables for automatic theme switching */
|
||||
.cm-s-daisy.CodeMirror {
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
font-family: var(--font-mono, ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, 'Courier New', monospace);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Cursor */
|
||||
.cm-s-daisy .CodeMirror-cursor {
|
||||
border-left-color: var(--color-primary);
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
.cm-s-daisy .CodeMirror-selected {
|
||||
background-color: var(--color-selection) !important;
|
||||
}
|
||||
|
||||
.cm-s-daisy.CodeMirror-focused .CodeMirror-selected {
|
||||
background-color: color-mix(in oklab, var(--color-primary) 30%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Line numbers and gutters */
|
||||
.cm-s-daisy .CodeMirror-gutters {
|
||||
background-color: var(--color-base-200);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.cm-s-daisy .CodeMirror-linenumber {
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Active line */
|
||||
.cm-s-daisy .CodeMirror-activeline-background {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
}
|
||||
|
||||
.cm-s-daisy .CodeMirror-activeline-gutter {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
/* Matching brackets */
|
||||
.cm-s-daisy .CodeMirror-matchingbracket {
|
||||
color: var(--color-success) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cm-s-daisy .CodeMirror-nonmatchingbracket {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** CodeMirror Syntax Highlighting ******* */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Keywords (column, row, cell, if, not, and, or, in, between, case) */
|
||||
.cm-s-daisy .cm-keyword {
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Built-in functions (style, format) */
|
||||
.cm-s-daisy .cm-builtin {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Operators (==, <, >, contains, startswith, etc.) */
|
||||
.cm-s-daisy .cm-operator {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Strings ("error", "EUR", etc.) */
|
||||
.cm-s-daisy .cm-string {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Numbers (0, 100, 3.14) */
|
||||
.cm-s-daisy .cm-number {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Booleans (True, False, true, false) */
|
||||
.cm-s-daisy .cm-atom {
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
/* Special variables (value, col, row, cell) */
|
||||
.cm-s-daisy .cm-variable-2 {
|
||||
color: var(--color-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Cell IDs (tcell_*) */
|
||||
.cm-s-daisy .cm-variable-3 {
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
}
|
||||
|
||||
/* Comments (#...) */
|
||||
.cm-s-daisy .cm-comment {
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Property names (bold=, color=, etc.) */
|
||||
.cm-s-daisy .cm-property {
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Errors/invalid syntax */
|
||||
.cm-s-daisy .cm-error {
|
||||
color: var(--color-error);
|
||||
text-decoration: underline wavy;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** CodeMirror Autocomplete ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Autocomplete dropdown container */
|
||||
.CodeMirror-hints {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 13px;
|
||||
max-height: 20em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Individual hint items */
|
||||
.CodeMirror-hint {
|
||||
color: var(--color-base-content);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hovered/selected hint */
|
||||
.CodeMirror-hint-active {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** CodeMirror Lint Markers ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Lint gutter marker */
|
||||
.CodeMirror-lint-marker {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.CodeMirror-lint-marker-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.CodeMirror-lint-marker-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Lint tooltip */
|
||||
.CodeMirror-lint-tooltip {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
color: var(--color-base-content);
|
||||
font-family: var(--font-sans, ui-sans-serif, system-ui);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.CodeMirror-lint-message-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.CodeMirror-lint-message-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** DslEditor Wrapper Styles *********** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Wrapper container for DslEditor */
|
||||
.mf-dsl-editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Editor container */
|
||||
.mf-dsl-editor {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** Preset Styles *********** */
|
||||
/* *********************************************** */
|
||||
|
||||
.mf-formatting-primary {
|
||||
background-color: color-mix(in oklab, var(--color-primary) 65%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-secondary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-accent {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-neutral {
|
||||
background-color: var(--color-neutral);
|
||||
color: var(--color-neutral-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-info {
|
||||
background-color: var(--color-info);
|
||||
color: var(--color-info-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-success {
|
||||
background-color: var(--color-success);
|
||||
color: var(--color-success-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-warning-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-error {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-error-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
|
||||
.mf-formatting-red {
|
||||
background-color: color-mix(in oklab, red 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
|
||||
.mf-formatting-red {
|
||||
background-color: color-mix(in oklab, red 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-blue {
|
||||
background-color: color-mix(in oklab, blue 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-green {
|
||||
background-color: color-mix(in oklab, green 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-yellow {
|
||||
background-color: color-mix(in oklab, yellow 50%, #0000);
|
||||
color: var(--color-base-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-orange {
|
||||
background-color: color-mix(in oklab, orange 50%, #0000);
|
||||
color: var(--color-base-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-purple {
|
||||
background-color: color-mix(in oklab, purple 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-pink {
|
||||
background-color: color-mix(in oklab, pink 50%, #0000);
|
||||
color: var(--color-base-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-gray {
|
||||
background-color: color-mix(in oklab, gray 50%, #0000);
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-black {
|
||||
background-color: black;
|
||||
color: var(--color-primary-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.mf-formatting-white {
|
||||
background-color: white;
|
||||
color: var(--color-base-content);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
269
src/myfasthtml/assets/core/dsleditor.js
Normal file
269
src/myfasthtml/assets/core/dsleditor.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Initialize DslEditor with CodeMirror 5
|
||||
*
|
||||
* Features:
|
||||
* - DSL-based autocompletion
|
||||
* - Line numbers
|
||||
* - Readonly support
|
||||
* - Placeholder support
|
||||
* - Textarea synchronization
|
||||
* - Debounced HTMX server update via updateCommandId
|
||||
*
|
||||
* Required CodeMirror addons:
|
||||
* - addon/hint/show-hint.js
|
||||
* - addon/hint/show-hint.css
|
||||
* - addon/display/placeholder.js
|
||||
*
|
||||
* Requires:
|
||||
* - htmx loaded globally
|
||||
*
|
||||
* @param {Object} config
|
||||
*/
|
||||
function initDslEditor(config) {
|
||||
const {
|
||||
elementId,
|
||||
textareaId,
|
||||
lineNumbers,
|
||||
autocompletion,
|
||||
linting,
|
||||
placeholder,
|
||||
readonly,
|
||||
updateCommandId,
|
||||
dslId,
|
||||
dsl
|
||||
} = config;
|
||||
|
||||
const wrapper = document.getElementById(elementId);
|
||||
const textarea = document.getElementById(textareaId);
|
||||
const editorContainer = document.getElementById(`cm_${elementId}`);
|
||||
|
||||
if (!wrapper || !textarea || !editorContainer) {
|
||||
console.error(`DslEditor: Missing elements for ${elementId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof CodeMirror === "undefined") {
|
||||
console.error("DslEditor: CodeMirror 5 not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
* DSL autocompletion hint (async via server)
|
||||
* -------------------------------------------------- */
|
||||
|
||||
// Characters that trigger auto-completion
|
||||
const AUTO_TRIGGER_CHARS = [".", "(", '"', " "];
|
||||
|
||||
function dslHint(cm, callback) {
|
||||
const cursor = cm.getCursor();
|
||||
const text = cm.getValue();
|
||||
|
||||
// Build URL with query params
|
||||
const params = new URLSearchParams({
|
||||
e_id: dslId,
|
||||
text: text,
|
||||
line: cursor.line,
|
||||
ch: cursor.ch
|
||||
});
|
||||
|
||||
fetch(`/myfasthtml/completions?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data || !data.suggestions || data.suggestions.length === 0) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
|
||||
callback({
|
||||
list: data.suggestions.map(s => ({
|
||||
text: s.label,
|
||||
displayText: s.detail ? `${s.label} - ${s.detail}` : s.label
|
||||
})),
|
||||
from: CodeMirror.Pos(data.from.line, data.from.ch),
|
||||
to: CodeMirror.Pos(data.to.line, data.to.ch)
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("DslEditor: Completion error", err);
|
||||
callback(null);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark hint function as async for CodeMirror
|
||||
dslHint.async = true;
|
||||
|
||||
/* --------------------------------------------------
|
||||
* DSL linting (async via server)
|
||||
* -------------------------------------------------- */
|
||||
|
||||
function dslLint(text, updateOutput, options, cm) {
|
||||
const cursor = cm.getCursor();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
e_id: dslId,
|
||||
text: text,
|
||||
line: cursor.line,
|
||||
ch: cursor.ch
|
||||
});
|
||||
|
||||
fetch(`/myfasthtml/validations?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data || !data.errors || data.errors.length === 0) {
|
||||
updateOutput([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert server errors to CodeMirror lint format
|
||||
// Server returns 1-based positions, CodeMirror expects 0-based
|
||||
const annotations = data.errors.map(err => ({
|
||||
from: CodeMirror.Pos(err.line - 1, Math.max(0, err.column - 1)),
|
||||
to: CodeMirror.Pos(err.line - 1, err.column),
|
||||
message: err.message,
|
||||
severity: err.severity || "error"
|
||||
}));
|
||||
|
||||
updateOutput(annotations);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("DslEditor: Linting error", err);
|
||||
updateOutput([]);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark lint function as async for CodeMirror
|
||||
dslLint.async = true;
|
||||
|
||||
/* --------------------------------------------------
|
||||
* Register Simple Mode if available and config provided
|
||||
* -------------------------------------------------- */
|
||||
|
||||
let modeName = null;
|
||||
|
||||
if (typeof CodeMirror.defineSimpleMode !== "undefined" && dsl && dsl.simpleModeConfig) {
|
||||
// Generate unique mode name from DSL name
|
||||
modeName = `dsl-${dsl.name.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
// Register the mode if not already registered
|
||||
if (!CodeMirror.modes[modeName]) {
|
||||
try {
|
||||
CodeMirror.defineSimpleMode(modeName, dsl.simpleModeConfig);
|
||||
} catch (err) {
|
||||
console.error(`Failed to register Simple Mode for ${dsl.name}:`, err);
|
||||
modeName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
* Create CodeMirror editor
|
||||
* -------------------------------------------------- */
|
||||
|
||||
const enableCompletion = autocompletion && dslId;
|
||||
// Only enable linting if the lint addon is loaded
|
||||
const lintAddonLoaded = typeof CodeMirror.lint !== "undefined" ||
|
||||
(CodeMirror.defaults && "lint" in CodeMirror.defaults);
|
||||
const enableLinting = linting && dslId && lintAddonLoaded;
|
||||
|
||||
const editorOptions = {
|
||||
value: textarea.value || "",
|
||||
mode: modeName || undefined, // Use Simple Mode if available
|
||||
theme: "daisy", // Use DaisyUI theme for automatic theme switching
|
||||
lineNumbers: !!lineNumbers,
|
||||
readOnly: !!readonly,
|
||||
placeholder: placeholder || "",
|
||||
extraKeys: enableCompletion ? {
|
||||
"Ctrl-Space": "autocomplete"
|
||||
} : {},
|
||||
hintOptions: enableCompletion ? {
|
||||
hint: dslHint,
|
||||
completeSingle: false
|
||||
} : undefined
|
||||
};
|
||||
|
||||
// Add linting options if enabled and addon is available
|
||||
if (enableLinting) {
|
||||
// Include linenumbers gutter if lineNumbers is enabled
|
||||
editorOptions.gutters = lineNumbers
|
||||
? ["CodeMirror-linenumbers", "CodeMirror-lint-markers"]
|
||||
: ["CodeMirror-lint-markers"];
|
||||
editorOptions.lint = {
|
||||
getAnnotations: dslLint,
|
||||
async: true
|
||||
};
|
||||
}
|
||||
|
||||
const editor = CodeMirror(editorContainer, editorOptions);
|
||||
|
||||
/* --------------------------------------------------
|
||||
* Auto-trigger completion on specific characters
|
||||
* -------------------------------------------------- */
|
||||
|
||||
if (enableCompletion) {
|
||||
editor.on("inputRead", function (cm, change) {
|
||||
if (change.origin !== "+input") return;
|
||||
|
||||
const lastChar = change.text[change.text.length - 1];
|
||||
const lastCharOfInput = lastChar.slice(-1);
|
||||
|
||||
if (AUTO_TRIGGER_CHARS.includes(lastCharOfInput)) {
|
||||
cm.showHint({completeSingle: false});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
* Debounced update + HTMX transport
|
||||
* -------------------------------------------------- */
|
||||
|
||||
let debounceTimer = null;
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
editor.on("change", function (cm) {
|
||||
const value = cm.getValue();
|
||||
textarea.value = value;
|
||||
|
||||
if (!updateCommandId) return;
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
wrapper.dispatchEvent(
|
||||
new CustomEvent("dsl-editor-update", {
|
||||
detail: {
|
||||
commandId: updateCommandId,
|
||||
value: value
|
||||
}
|
||||
})
|
||||
);
|
||||
}, DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
/* --------------------------------------------------
|
||||
* HTMX listener (LOCAL to wrapper)
|
||||
* -------------------------------------------------- */
|
||||
|
||||
if (updateCommandId && typeof htmx !== "undefined") {
|
||||
wrapper.addEventListener("dsl-editor-update", function (e) {
|
||||
htmx.ajax("POST", "/myfasthtml/commands", {
|
||||
target: wrapper,
|
||||
swap: "none",
|
||||
values: {
|
||||
c_id: e.detail.commandId,
|
||||
content: e.detail.value
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
* Public API
|
||||
* -------------------------------------------------- */
|
||||
|
||||
wrapper._dslEditor = {
|
||||
editor: editor,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (content) => editor.setValue(content)
|
||||
};
|
||||
|
||||
//console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`);
|
||||
}
|
||||
376
src/myfasthtml/assets/core/keyboard.js
Normal file
376
src/myfasthtml/assets/core/keyboard.js
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Create keyboard bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store keyboard shortcuts for multiple elements
|
||||
*/
|
||||
const KeyboardRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
currentKeys: new Set(),
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500 // 500ms timeout for sequences
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize key names to lowercase for case-insensitive comparison
|
||||
* @param {string} key - The key to normalize
|
||||
* @returns {string} - Normalized key name
|
||||
*/
|
||||
function normalizeKey(key) {
|
||||
const keyMap = {
|
||||
'control': 'ctrl',
|
||||
'escape': 'esc',
|
||||
'delete': 'del'
|
||||
};
|
||||
|
||||
const normalized = key.toLowerCase();
|
||||
return keyMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of keys for Map indexing
|
||||
* @param {Set} keySet - Set of normalized keys
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(keySet) {
|
||||
return Array.from(keySet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a single key or a simultaneous combination)
|
||||
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
|
||||
* @returns {Set} - Set of normalized keys
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Simultaneous combination
|
||||
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
|
||||
}
|
||||
// Single key
|
||||
return new Set([normalizeKey(element.trim())]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
|
||||
* @returns {Array} - Array of Sets representing the sequence
|
||||
*/
|
||||
function parseCombination(combination) {
|
||||
// Check if it's a sequence (contains space)
|
||||
if (combination.includes(' ')) {
|
||||
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||
}
|
||||
|
||||
// Single element (can be a key or simultaneous combination)
|
||||
return [parseElement(combination)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
children: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
let currentNode = root;
|
||||
|
||||
for (const keySet of sequence) {
|
||||
const key = setToKey(keySet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
// Mark as end of sequence and store config
|
||||
currentNode.config = config;
|
||||
currentNode.combinationStr = combinationStr;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're inside an input element where typing should work normally
|
||||
* @returns {boolean} - True if inside an input-like element
|
||||
*/
|
||||
function isInInputContext() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
|
||||
// Check for input/textarea
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for contenteditable
|
||||
if (activeElement.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events and trigger matching combinations
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
function handleKeyboardEvent(event) {
|
||||
const key = normalizeKey(event.key);
|
||||
|
||||
// Add key to current pressed keys
|
||||
KeyboardRegistry.currentKeys.add(key);
|
||||
// console.debug("Received key", key);
|
||||
|
||||
// Create a snapshot of current keyboard state
|
||||
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||
|
||||
// Add snapshot to history
|
||||
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for all elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
// Check all registered elements for matching combinations
|
||||
for (const [elementId, data] of KeyboardRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if focus is inside this element (element itself or any child)
|
||||
const isInside = element.contains(document.activeElement);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree, continue to next element
|
||||
// console.debug("No match in tree for event", key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has a URL)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
// Track if ANY element has longer sequences possible
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
KeyboardRegistry.pendingMatches = currentMatches;
|
||||
const savedEvent = event; // Save event for timeout callback
|
||||
|
||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of KeyboardRegistry.pendingMatches) {
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
}, KeyboardRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
// This handles invalid sequences like "A C" when only "A B" exists
|
||||
if (!foundAnyMatch) {
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyup event to remove keys from current pressed keys
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
function handleKeyUp(event) {
|
||||
const key = normalizeKey(event.key);
|
||||
KeyboardRegistry.currentKeys.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global keyboard event listener if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!KeyboardRegistry.listenerAttached) {
|
||||
document.addEventListener('keydown', handleKeyboardEvent);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
KeyboardRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global keyboard event listener
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (KeyboardRegistry.listenerAttached) {
|
||||
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
KeyboardRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up all state
|
||||
KeyboardRegistry.currentKeys.clear();
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
}
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build tree for this element
|
||||
const tree = buildTree(combinations);
|
||||
|
||||
// Get element reference
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error("Element with ID", elementId, "not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to registry
|
||||
KeyboardRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove keyboard support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_keyboard_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
KeyboardRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (KeyboardRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
270
src/myfasthtml/assets/core/layout.css
Normal file
270
src/myfasthtml/assets/core/layout.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* MF Layout Component - CSS Grid Layout
|
||||
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
/* Main layout container using CSS Grid */
|
||||
.mf-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"left-drawer main right-drawer"
|
||||
"footer footer footer";
|
||||
grid-template-rows: 32px 1fr 32px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header - fixed at top */
|
||||
.mf-layout-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* put one item on each side */
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-base-300);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Footer - fixed at bottom */
|
||||
.mf-layout-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-neutral);
|
||||
color: var(--color-neutral-content);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Main content area - scrollable */
|
||||
.mf-layout-main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Drawer base styles */
|
||||
.mf-layout-drawer {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-base-100);
|
||||
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
|
||||
width: 250px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Left drawer */
|
||||
.mf-layout-left-drawer {
|
||||
grid-area: left-drawer;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right drawer */
|
||||
.mf-layout-right-drawer {
|
||||
grid-area: right-drawer;
|
||||
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Collapsed drawer states */
|
||||
.mf-layout-drawer.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toggle buttons positioning */
|
||||
.mf-layout-toggle-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mf-layout-toggle-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar styling for webkit browsers */
|
||||
.mf-layout-main::-webkit-scrollbar,
|
||||
.mf-layout-drawer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-track,
|
||||
.mf-layout-drawer::-webkit-scrollbar-track {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb:hover,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.mf-layout-drawer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mf-layout-header,
|
||||
.mf-layout-footer {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.mf-layout-main {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle layouts with no drawers */
|
||||
.mf-layout[data-left-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main right-drawer"
|
||||
"footer footer";
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.mf-layout[data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"left-drawer main"
|
||||
"footer footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"footer";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
* Styles for the resizable drawer borders with visual feedback
|
||||
*/
|
||||
|
||||
/* Ensure drawer has relative positioning and no overflow */
|
||||
.mf-layout-drawer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content wrapper handles scrolling */
|
||||
.mf-layout-drawer-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Base resizer styles */
|
||||
.mf-layout-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Resizer on the right side (for left drawer) */
|
||||
.mf-layout-resizer-right {
|
||||
right: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Resizer on the left side (for right drawer) */
|
||||
.mf-layout-resizer-left {
|
||||
left: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.mf-layout-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-layout-drawer-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-layout-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-layout-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-layout-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-right::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-left::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer:hover::before,
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.mf-layout-group {
|
||||
font-weight: bold;
|
||||
/*font-size: var(--text-sm);*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
4
src/myfasthtml/assets/core/layout.js
Normal file
4
src/myfasthtml/assets/core/layout.js
Normal file
@@ -0,0 +1,4 @@
|
||||
function initLayout(elementId) {
|
||||
initResizer(elementId);
|
||||
bindTooltipsWithDelegation(elementId);
|
||||
}
|
||||
1082
src/myfasthtml/assets/core/mouse.js
Normal file
1082
src/myfasthtml/assets/core/mouse.js
Normal file
File diff suppressed because it is too large
Load Diff
164
src/myfasthtml/assets/core/myfasthtml.css
Normal file
164
src/myfasthtml/assets/core/myfasthtml.css
Normal file
@@ -0,0 +1,164 @@
|
||||
:root {
|
||||
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
--color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000);
|
||||
|
||||
--datagrid-resize-zindex: 1;
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--font-weight-medium: 500;
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--properties-font-size: var(--text-xs);
|
||||
--mf-tooltip-zindex: 10;
|
||||
}
|
||||
|
||||
.mf-icon-16 {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mf-icon-24 {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
|
||||
}
|
||||
|
||||
.mf-icon-28 {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mf-icon-32 {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.mf-button {
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mf-button:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
.mf-tooltip-container {
|
||||
background: var(--color-base-200);
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none; /* Prevent interfering with mouse events */
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0; /* Default to invisible */
|
||||
visibility: hidden; /* Prevent interaction when invisible */
|
||||
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
||||
position: fixed; /* Keep it above other content and adjust position */
|
||||
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
|
||||
}
|
||||
|
||||
.mf-tooltip-container[data-visible="true"] {
|
||||
opacity: 1;
|
||||
visibility: visible; /* Show tooltip */
|
||||
transition: opacity 0.3s ease; /* No delay when becoming visible */
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** Generic Resizer Classes ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Generic resizer - used by both Layout and Panel */
|
||||
.mf-resizer {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-resizing .mf-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-resizer:hover::before,
|
||||
.mf-resizing .mf-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Resizer positioning */
|
||||
/* Left resizer is on the right side of the left panel */
|
||||
.mf-resizer-left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Right resizer is on the left side of the right panel */
|
||||
.mf-resizer-right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Position indicator for resizer */
|
||||
.mf-resizer-left::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-resizer-right::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-item-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
383
src/myfasthtml/assets/core/myfasthtml.js
Normal file
383
src/myfasthtml/assets/core/myfasthtml.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Generic Resizer
|
||||
*
|
||||
* Handles resizing of elements with drag functionality.
|
||||
* Communicates with server via HTMX to persist width changes.
|
||||
* Works for both Layout drawers and Panel sides.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for a specific container
|
||||
*
|
||||
* @param {string} containerId - The ID of the container instance to initialize
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
|
||||
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
|
||||
*/
|
||||
function initResizer(containerId, options = {}) {
|
||||
|
||||
const MIN_WIDTH = options.minWidth || 150;
|
||||
const MAX_WIDTH = options.maxWidth || 600;
|
||||
|
||||
let isResizing = false;
|
||||
let currentResizer = null;
|
||||
let currentItem = null;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let side = null;
|
||||
|
||||
const containerElement = document.getElementById(containerId);
|
||||
|
||||
if (!containerElement) {
|
||||
console.error(`Container element with ID "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for this container instance
|
||||
*/
|
||||
function initResizers() {
|
||||
const resizers = containerElement.querySelectorAll('.mf-resizer');
|
||||
|
||||
resizers.forEach(resizer => {
|
||||
// Remove existing listener if any to avoid duplicates
|
||||
resizer.removeEventListener('mousedown', handleMouseDown);
|
||||
resizer.addEventListener('mousedown', handleMouseDown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down event on resizer
|
||||
*/
|
||||
function handleMouseDown(e) {
|
||||
e.preventDefault();
|
||||
|
||||
currentResizer = e.target;
|
||||
side = currentResizer.dataset.side;
|
||||
currentItem = currentResizer.parentElement;
|
||||
|
||||
if (!currentItem) {
|
||||
console.error('Could not find item element');
|
||||
return;
|
||||
}
|
||||
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = currentItem.offsetWidth;
|
||||
|
||||
// Add event listeners for mouse move and up
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Add resizing class for visual feedback
|
||||
document.body.classList.add('mf-resizing');
|
||||
currentItem.classList.add('mf-item-resizing');
|
||||
// Disable transition during manual resize
|
||||
currentItem.classList.add('no-transition');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move event during resize
|
||||
*/
|
||||
function handleMouseMove(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let newWidth;
|
||||
|
||||
if (side === 'left') {
|
||||
// Left drawer: increase width when moving right
|
||||
newWidth = startWidth + (e.clientX - startX);
|
||||
} else if (side === 'right') {
|
||||
// Right drawer: increase width when moving left
|
||||
newWidth = startWidth - (e.clientX - startX);
|
||||
}
|
||||
|
||||
// Constrain width between min and max
|
||||
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
|
||||
|
||||
// Update item width visually
|
||||
currentItem.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up event - end resize and save to server
|
||||
*/
|
||||
function handleMouseUp(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
isResizing = false;
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Remove resizing classes
|
||||
document.body.classList.remove('mf-resizing');
|
||||
currentItem.classList.remove('mf-item-resizing');
|
||||
// Re-enable transition after manual resize
|
||||
currentItem.classList.remove('no-transition');
|
||||
|
||||
// Get final width
|
||||
const finalWidth = currentItem.offsetWidth;
|
||||
const commandId = currentResizer.dataset.commandId;
|
||||
|
||||
if (!commandId) {
|
||||
console.error('No command ID found on resizer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send width update to server
|
||||
saveWidth(commandId, finalWidth);
|
||||
|
||||
// Reset state
|
||||
currentResizer = null;
|
||||
currentItem = null;
|
||||
side = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save width to server via HTMX
|
||||
*/
|
||||
function saveWidth(commandId, width) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}, swap: "outerHTML", target: `#${currentItem.id}`, values: {
|
||||
c_id: commandId, width: width
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize resizers
|
||||
initResizers();
|
||||
|
||||
// Re-initialize after HTMX swaps within this container
|
||||
containerElement.addEventListener('htmx:afterSwap', function (event) {
|
||||
initResizers();
|
||||
});
|
||||
}
|
||||
|
||||
function bindTooltipsWithDelegation(elementId) {
|
||||
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
|
||||
// Then
|
||||
// the 'truncate' to show only when the text is truncated
|
||||
// the class 'mmt-tooltip' for force the display
|
||||
|
||||
console.info("bindTooltips on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
const tooltipContainer = document.getElementById(`tt_${elementId}`);
|
||||
|
||||
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tooltipContainer) {
|
||||
console.error(`Invalid tooltip 'tt_${elementId}' container.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Throttling flag to limit mouseenter processing
|
||||
let tooltipRafScheduled = false;
|
||||
|
||||
// Add a single mouseenter and mouseleave listener to the parent element
|
||||
element.addEventListener("mouseenter", (event) => {
|
||||
const target = event.target;
|
||||
|
||||
// Early exit - check mf-no-tooltip on the registered element OR any ancestor of the target
|
||||
if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Throttle mouseenter events (max 1 per frame)
|
||||
if (tooltipRafScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = target.closest("[data-tooltip]");
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Move ALL DOM reads into RAF to avoid forced synchronous layouts
|
||||
tooltipRafScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
tooltipRafScheduled = false;
|
||||
|
||||
// Check again in case tooltip was disabled during RAF delay
|
||||
if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// All DOM reads happen here (batched in RAF)
|
||||
const content = cell.querySelector(".truncate") || cell;
|
||||
const isOverflowing = content.scrollWidth > content.clientWidth;
|
||||
const forceShow = cell.classList.contains("mf-tooltip");
|
||||
|
||||
if (isOverflowing || forceShow) {
|
||||
const tooltipText = cell.getAttribute("data-tooltip");
|
||||
if (tooltipText) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const tooltipRect = tooltipContainer.getBoundingClientRect();
|
||||
|
||||
let top = rect.top - 30; // Above the cell
|
||||
let left = rect.left;
|
||||
|
||||
// Adjust tooltip position to prevent it from going off-screen
|
||||
if (top < 0) top = rect.bottom + 5; // Move below if no space above
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
|
||||
}
|
||||
|
||||
// Apply styles (already in RAF)
|
||||
tooltipContainer.textContent = tooltipText;
|
||||
tooltipContainer.setAttribute("data-visible", "true");
|
||||
tooltipContainer.style.top = `${top}px`;
|
||||
tooltipContainer.style.left = `${left}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, true); // Capture phase required: mouseenter doesn't bubble
|
||||
|
||||
element.addEventListener("mouseleave", (event) => {
|
||||
const cell = event.target.closest("[data-tooltip]");
|
||||
if (cell) {
|
||||
tooltipContainer.setAttribute("data-visible", "false");
|
||||
}
|
||||
}, true); // Capture phase required: mouseleave doesn't bubble
|
||||
}
|
||||
|
||||
function disableTooltip() {
|
||||
const elementId = tooltipElementId
|
||||
// console.debug("disableTooltip on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
element.setAttribute("mmt-no-tooltip", "");
|
||||
}
|
||||
|
||||
function enableTooltip() {
|
||||
const elementId = tooltipElementId
|
||||
// console.debug("enableTooltip on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
element.removeAttribute("mmt-no-tooltip");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared utility function for triggering HTMX actions from keyboard/mouse bindings.
|
||||
* Handles dynamic hx-vals with "js:functionName()" syntax.
|
||||
*
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the focus/click is inside the element
|
||||
* @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent)
|
||||
*/
|
||||
function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
let url = null;
|
||||
|
||||
const methodMap = {
|
||||
'hx-post': 'POST', 'hx-get': 'GET', 'hx-put': 'PUT', 'hx-delete': 'DELETE', 'hx-patch': 'PATCH'
|
||||
};
|
||||
|
||||
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||
if (config[attr]) {
|
||||
method = httpMethod;
|
||||
url = config[attr];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('No HTTP method attribute found in config:', config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build htmx.ajax options
|
||||
const htmxOptions = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
|
||||
// 1. Merge static hx-vals from command (if present)
|
||||
if (config['hx-vals'] && typeof config['hx-vals'] === 'object') {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
|
||||
// 2. Merge hx-vals-extra (user overrides)
|
||||
if (config['hx-vals-extra']) {
|
||||
const extra = config['hx-vals-extra'];
|
||||
|
||||
// Merge static dict values
|
||||
if (extra.dict && typeof extra.dict === 'object') {
|
||||
Object.assign(values, extra.dict);
|
||||
}
|
||||
|
||||
// Call dynamic JS function and merge result
|
||||
if (extra.js) {
|
||||
try {
|
||||
const func = window[extra.js];
|
||||
if (typeof func === 'function') {
|
||||
const dynamicValues = func(event, element, combinationStr);
|
||||
if (dynamicValues && typeof dynamicValues === 'object') {
|
||||
Object.assign(values, dynamicValues);
|
||||
}
|
||||
} else {
|
||||
console.error(`Function "${extra.js}" not found on window`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error calling dynamic hx-vals function:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
//console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions);
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
117
src/myfasthtml/assets/core/panel.css
Normal file
117
src/myfasthtml/assets/core/panel.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* *********************************************** */
|
||||
/* *************** Panel Component *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Common properties for side panels */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
|
||||
padding-top: 25px;
|
||||
}
|
||||
|
||||
/* Left panel specific */
|
||||
.mf-panel-left {
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right panel specific */
|
||||
.mf-panel-right {
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||
}
|
||||
|
||||
/* Hidden state - common for both panels */
|
||||
.mf-panel-left.mf-hidden,
|
||||
.mf-panel-right.mf-hidden {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* No transition during manual resize - common for both panels */
|
||||
.mf-panel-left.no-transition,
|
||||
.mf-panel-right.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Common properties for panel toggle icons */
|
||||
.mf-panel-hide-icon,
|
||||
.mf-panel-show-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.mf-panel-hide-icon:hover,
|
||||
.mf-panel-show-icon:hover {
|
||||
background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* Show icon positioning */
|
||||
.mf-panel-show-icon-left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-show-icon-right {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panel with title - grid layout for header + scrollable content */
|
||||
.mf-panel-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Override absolute positioning for hide icon when inside header */
|
||||
.mf-panel-header .mf-panel-hide-icon {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.mf-panel-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Remove padding-top when using title layout */
|
||||
.mf-panel-left.mf-panel-with-title,
|
||||
.mf-panel-right.mf-panel-with-title {
|
||||
padding-top: 0;
|
||||
}
|
||||
88
src/myfasthtml/assets/core/properties.css
Normal file
88
src/myfasthtml/assets/core/properties.css
Normal file
@@ -0,0 +1,88 @@
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/*!* Properties container *!*/
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/*!* Group card - using DaisyUI card styling *!*/
|
||||
.mf-properties-group-card {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-properties-group-container {
|
||||
display: inline-block; /* important */
|
||||
min-width: max-content; /* important */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/*!* Group header - gradient using DaisyUI primary color *!*/
|
||||
.mf-properties-group-header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||
color: var(--color-primary-content);
|
||||
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
||||
font-weight: 700;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/*!* Group content area *!*/
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/*!* Property row *!*/
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
|
||||
align-items: center;
|
||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
gap: calc(var(--properties-font-size) * 0.75);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/*!* Property key - normal font *!*/
|
||||
.mf-properties-key {
|
||||
align-items: start;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
flex: 0 0 40%;
|
||||
font-size: var(--properties-font-size);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/*!* Property value - monospace font *!*/
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: var(--properties-font-size);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
5
src/myfasthtml/assets/core/search.css
Normal file
5
src/myfasthtml/assets/core/search.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
/*max-height: 400px;*/
|
||||
overflow: auto;
|
||||
}
|
||||
107
src/myfasthtml/assets/core/tabs.css
Normal file
107
src/myfasthtml/assets/core/tabs.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* *********************************************** */
|
||||
/* *********** Tabs Manager Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Tabs Manager Container */
|
||||
.mf-tabs-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
/* Tabs Header using DaisyUI tabs component */
|
||||
.mf-tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
min-height: 25px;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-tabs-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
/*overflow: hidden; important */
|
||||
}
|
||||
|
||||
/* Individual Tab Button using DaisyUI tab classes */
|
||||
.mf-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-button:hover {
|
||||
color: var(--color-base-content); /* Change text color on hover */
|
||||
}
|
||||
|
||||
.mf-tab-button.mf-tab-active {
|
||||
--depth: 1;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-radius: .25rem;
|
||||
border-bottom: 4px solid var(--color-primary);
|
||||
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||
}
|
||||
|
||||
/* Tab Label */
|
||||
.mf-tab-label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Tab Close Button */
|
||||
.mf-tab-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@apply text-base-content/50;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-close-btn:hover {
|
||||
@apply bg-base-300 text-error;
|
||||
}
|
||||
|
||||
/* Tab Content Area */
|
||||
.mf-tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-tab-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
.mf-empty-content {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
@apply text-base-content/50;
|
||||
font-style: italic;
|
||||
}
|
||||
59
src/myfasthtml/assets/core/tabs.js
Normal file
59
src/myfasthtml/assets/core/tabs.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Updates the tabs display by showing the active tab content and scrolling to make it visible.
|
||||
* This function is called when switching between tabs to update both the content visibility
|
||||
* and the tab button states.
|
||||
*
|
||||
* @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-controller")
|
||||
*/
|
||||
function updateTabs(controllerId) {
|
||||
const controller = document.getElementById(controllerId);
|
||||
if (!controller) {
|
||||
console.warn(`Controller ${controllerId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTabId = controller.dataset.activeTab;
|
||||
if (!activeTabId) {
|
||||
console.warn('No active tab ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract manager ID from controller ID (remove '-controller' suffix)
|
||||
const managerId = controllerId.replace('-controller', '');
|
||||
|
||||
// Hide all tab contents for this manager
|
||||
const contentWrapper = document.getElementById(`${managerId}-content-wrapper`);
|
||||
if (contentWrapper) {
|
||||
contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show the active tab content
|
||||
const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`);
|
||||
if (activeContent) {
|
||||
activeContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update active tab button styling
|
||||
const header = document.getElementById(`${managerId}-header`);
|
||||
if (header) {
|
||||
// Remove active class from all tabs
|
||||
header.querySelectorAll('.mf-tab-button').forEach(btn => {
|
||||
btn.classList.remove('mf-tab-active');
|
||||
});
|
||||
|
||||
// Add active class to current tab
|
||||
const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`);
|
||||
if (activeButton) {
|
||||
activeButton.classList.add('mf-tab-active');
|
||||
|
||||
// Scroll to make active tab visible if needed
|
||||
activeButton.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/myfasthtml/assets/core/treeview.css
Normal file
78
src/myfasthtml/assets/core/treeview.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* *********************************************** */
|
||||
/* ************** TreeView Component ************* */
|
||||
/* *********************************************** */
|
||||
|
||||
/* TreeView Container */
|
||||
.mf-treeview {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* TreeNode Container */
|
||||
.mf-treenode-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* TreeNode Element */
|
||||
.mf-treenode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Input for Editing */
|
||||
.mf-treenode-input {
|
||||
flex: 1;
|
||||
padding: 2px 0.25rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-base-100);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode:hover {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-treenode.selected {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
/* Toggle Icon */
|
||||
.mf-treenode-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Node Label */
|
||||
.mf-treenode-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode-input:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Action Buttons - Hidden by default, shown on hover */
|
||||
.mf-treenode-actions {
|
||||
display: none;
|
||||
gap: 0.1rem;
|
||||
white-space: nowrap;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
display: flex;
|
||||
}
|
||||
304
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
304
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
/* ********************************************* */
|
||||
/* ************* Datagrid Component ************ */
|
||||
/* ********************************************* */
|
||||
|
||||
/* Header and Footer */
|
||||
.dt2-header,
|
||||
.dt2-footer {
|
||||
background-color: var(--color-base-200);
|
||||
border-radius: 10px 10px 0 0;
|
||||
min-width: max-content; /* Content width propagates to scrollable parent */
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.dt2-body {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Content width propagates to scrollable parent */
|
||||
}
|
||||
|
||||
/* Row */
|
||||
.dt2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Cell */
|
||||
.dt2-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 2px 8px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Cell content types */
|
||||
.dt2-cell-content-text {
|
||||
text-align: inherit;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.dt2-cell-content-checkbox {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dt2-cell-content-number {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
/* Footer cell */
|
||||
.dt2-footer-cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.dt2-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.dt2-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: var(--datagrid-resize-zindex);
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
top: calc(50% - 60% * 0.5);
|
||||
background-color: var(--color-resize);
|
||||
}
|
||||
|
||||
/* Hidden column */
|
||||
.dt2-col-hidden {
|
||||
width: 5px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.dt2-highlight-1 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
|
||||
.dt2-selected-focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -3px; /* Ensure the outline is snug to the cell */
|
||||
}
|
||||
|
||||
.dt2-cell:hover,
|
||||
.dt2-selected-cell {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-selected-row {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-selected-column {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-hover-row {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-hover-column {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
.dt2-drag-preview {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
|
||||
/* Selection border - outlines the entire selection rectangle */
|
||||
.dt2-selection-border-top { border-top: 2px solid var(--color-primary); }
|
||||
.dt2-selection-border-bottom { border-bottom: 2px solid var(--color-primary); }
|
||||
.dt2-selection-border-left { border-left: 2px solid var(--color-primary); }
|
||||
.dt2-selection-border-right { border-right: 2px solid var(--color-primary); }
|
||||
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Fixed Header/Footer ******** */
|
||||
/* *********************************************** */
|
||||
|
||||
/*
|
||||
* DataGrid with CSS Grid + Custom Scrollbars
|
||||
* - Wrapper takes 100% of parent height
|
||||
* - Table uses Grid: header (auto) + body (1fr) + footer (auto)
|
||||
* - Native scrollbars hidden, custom scrollbars overlaid
|
||||
* - Vertical scrollbar: right side of entire table
|
||||
* - Horizontal scrollbar: bottom, under footer
|
||||
*/
|
||||
|
||||
/* Main wrapper - takes full parent height, contains table + scrollbars */
|
||||
.dt2-table-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Table with Grid layout - horizontal scroll enabled, scrollbars hidden */
|
||||
.dt2-table {
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
height: fit-content;
|
||||
max-height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(auto, 1fr) auto; /* header, body, footer */
|
||||
overflow-x: auto; /* Enable horizontal scroll */
|
||||
overflow-y: hidden; /* No vertical scroll on table */
|
||||
scrollbar-width: none; /* Firefox: hide scrollbar */
|
||||
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Chrome/Safari: hide scrollbar */
|
||||
.dt2-table::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header - no scroll, takes natural height */
|
||||
.dt2-header-container {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Body - scrollable vertically via JS, scrollbars hidden */
|
||||
.dt2-body-container {
|
||||
overflow: hidden; /* Scrollbars hidden, scroll via JS */
|
||||
min-height: 0; /* Important for Grid to allow shrinking */
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Footer - no scroll, takes natural height */
|
||||
.dt2-footer-container {
|
||||
overflow: hidden;
|
||||
min-width: max-content; /* Force table to be as wide as content */
|
||||
}
|
||||
|
||||
/* Custom scrollbars container - overlaid on table */
|
||||
.dt2-scrollbars {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none; /* Let clicks pass through */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Scrollbar wrappers - clickable/draggable */
|
||||
.dt2-scrollbars-vertical-wrapper,
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
position: absolute;
|
||||
background-color: var(--color-base-200);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
}
|
||||
|
||||
/* Vertical scrollbar wrapper - right side, full table height */
|
||||
.dt2-scrollbars-vertical-wrapper {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Extra row reserved when horizontal scrollbar is visible */
|
||||
.dt2-table.dt2-has-hscroll {
|
||||
grid-template-rows: auto minmax(auto, 1fr) auto 8px; /* header, body, footer, scrollbar */
|
||||
}
|
||||
|
||||
/* Horizontal scrollbar wrapper - bottom, full width minus vertical scrollbar */
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
left: 0;
|
||||
right: 8px; /* Leave space for vertical scrollbar */
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbar thumbs */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Vertical scrollbar thumb */
|
||||
.dt2-scrollbars-vertical {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Horizontal scrollbar thumb */
|
||||
.dt2-scrollbars-horizontal {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hover and dragging states */
|
||||
.dt2-scrollbars-vertical:hover,
|
||||
.dt2-scrollbars-horizontal:hover,
|
||||
.dt2-scrollbars-vertical.dt2-dragging,
|
||||
.dt2-scrollbars-horizontal.dt2-dragging {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Column Drag & Drop ********** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Column being dragged - visual feedback */
|
||||
.dt2-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Column animation during swap */
|
||||
.dt2-moving {
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Column Manager ********** */
|
||||
/* *********************************************** */
|
||||
.dt2-column-manager-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dt2-column-manager-label:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
808
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
808
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
@@ -0,0 +1,808 @@
|
||||
function initDataGrid(gridId) {
|
||||
initDataGridScrollbars(gridId);
|
||||
initDataGridMouseOver(gridId);
|
||||
makeDatagridColumnsResizable(gridId);
|
||||
makeDatagridColumnsMovable(gridId);
|
||||
updateDatagridSelection(gridId)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize DataGrid hover effects using event delegation.
|
||||
*
|
||||
* Optimizations:
|
||||
* - Event delegation: 1 listener instead of N×2 (where N = number of cells)
|
||||
* - Row mode: O(1) via class toggle on parent row
|
||||
* - Column mode: RAF batching + cached cells for efficient class removal
|
||||
* - Works with HTMX swaps: listener on stable parent, querySelectorAll finds new cells
|
||||
* - No mouseout: hover selection stays visible when leaving the table
|
||||
*
|
||||
* @param {string} gridId - The DataGrid instance ID
|
||||
*/
|
||||
function initDataGridMouseOver(gridId) {
|
||||
const table = document.getElementById(`t_${gridId}`);
|
||||
if (!table) {
|
||||
console.error(`Table with id "t_${gridId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.getElementById(`tw_${gridId}`);
|
||||
|
||||
// Track hover state
|
||||
let currentHoverRow = null;
|
||||
let currentHoverColId = null;
|
||||
let currentHoverColCells = null;
|
||||
|
||||
table.addEventListener('mouseover', (e) => {
|
||||
// Skip hover during scrolling
|
||||
if (wrapper?.hasAttribute('mf-no-hover')) return;
|
||||
|
||||
const cell = e.target.closest('.dt2-cell');
|
||||
if (!cell) return;
|
||||
|
||||
const selectionModeDiv = document.getElementById(`tsm_${gridId}`);
|
||||
const selectionMode = selectionModeDiv?.getAttribute('selection-mode');
|
||||
|
||||
if (selectionMode === 'row') {
|
||||
const rowElement = cell.parentElement;
|
||||
if (rowElement !== currentHoverRow) {
|
||||
if (currentHoverRow) {
|
||||
currentHoverRow.classList.remove('dt2-hover-row');
|
||||
}
|
||||
rowElement.classList.add('dt2-hover-row');
|
||||
currentHoverRow = rowElement;
|
||||
}
|
||||
} else if (selectionMode === 'column') {
|
||||
const colId = cell.dataset.col;
|
||||
|
||||
// Skip if same column
|
||||
if (colId === currentHoverColId) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Remove old column highlight
|
||||
if (currentHoverColCells) {
|
||||
currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column'));
|
||||
}
|
||||
|
||||
// Query and add new column highlight
|
||||
currentHoverColCells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`);
|
||||
currentHoverColCells.forEach(c => c.classList.add('dt2-hover-column'));
|
||||
|
||||
currentHoverColId = colId;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up when leaving the table entirely
|
||||
table.addEventListener('mouseout', (e) => {
|
||||
if (!table.contains(e.relatedTarget)) {
|
||||
if (currentHoverRow) {
|
||||
currentHoverRow.classList.remove('dt2-hover-row');
|
||||
currentHoverRow = null;
|
||||
}
|
||||
if (currentHoverColCells) {
|
||||
currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column'));
|
||||
currentHoverColCells = null;
|
||||
currentHoverColId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize DataGrid with CSS Grid layout + Custom Scrollbars
|
||||
*
|
||||
* Adapted from previous custom scrollbar implementation to work with CSS Grid.
|
||||
* - Grid handles layout (no height calculations needed)
|
||||
* - Custom scrollbars for visual consistency and positioning control
|
||||
* - Vertical scroll: on body container (.dt2-body-container)
|
||||
* - Horizontal scroll: on table (.dt2-table) to scroll header, body, footer together
|
||||
*
|
||||
* @param {string} gridId - The ID of the DataGrid instance
|
||||
*/
|
||||
function initDataGridScrollbars(gridId) {
|
||||
const wrapper = document.getElementById(`tw_${gridId}`);
|
||||
|
||||
if (!wrapper) {
|
||||
console.error(`DataGrid wrapper "tw_${gridId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup previous listeners if any
|
||||
if (wrapper._scrollbarAbortController) {
|
||||
wrapper._scrollbarAbortController.abort();
|
||||
}
|
||||
wrapper._scrollbarAbortController = new AbortController();
|
||||
const signal = wrapper._scrollbarAbortController.signal;
|
||||
|
||||
|
||||
const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical");
|
||||
const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper");
|
||||
const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal");
|
||||
const horizontalWrapper = wrapper.querySelector(".dt2-scrollbars-horizontal-wrapper");
|
||||
const bodyContainer = wrapper.querySelector(".dt2-body-container");
|
||||
const table = wrapper.querySelector(".dt2-table");
|
||||
|
||||
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !bodyContainer || !table) {
|
||||
console.error("Essential scrollbar or content elements are missing in the datagrid.");
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Cache element references to avoid repeated querySelector calls
|
||||
const header = table.querySelector(".dt2-header");
|
||||
const body = table.querySelector(".dt2-body");
|
||||
|
||||
// OPTIMIZATION: RequestAnimationFrame flags to throttle visual updates
|
||||
let rafScheduledVertical = false;
|
||||
let rafScheduledHorizontal = false;
|
||||
let rafScheduledUpdate = false;
|
||||
|
||||
// OPTIMIZATION: Pre-calculated scroll ratios (updated in updateScrollbars)
|
||||
// Allows instant mousedown with zero DOM reads
|
||||
let cachedVerticalScrollRatio = 0;
|
||||
let cachedHorizontalScrollRatio = 0;
|
||||
|
||||
// OPTIMIZATION: Cached scroll positions to avoid DOM reads in mousedown
|
||||
// Initialized once at setup, updated in RAF handlers after each scroll change
|
||||
let cachedBodyScrollTop = bodyContainer.scrollTop;
|
||||
let cachedTableScrollLeft = table.scrollLeft;
|
||||
|
||||
/**
|
||||
* OPTIMIZED: Batched update function
|
||||
* Phase 1: Read all DOM properties (no writes)
|
||||
* Phase 2: Calculate all values
|
||||
* Phase 3: Write all DOM properties in single RAF
|
||||
*/
|
||||
const updateScrollbars = () => {
|
||||
if (rafScheduledUpdate) return;
|
||||
|
||||
rafScheduledUpdate = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduledUpdate = false;
|
||||
|
||||
// PHASE 1: Read all DOM properties
|
||||
const metrics = {
|
||||
bodyScrollHeight: bodyContainer.scrollHeight,
|
||||
bodyClientHeight: bodyContainer.clientHeight,
|
||||
bodyScrollTop: bodyContainer.scrollTop,
|
||||
tableClientWidth: table.clientWidth,
|
||||
tableScrollLeft: table.scrollLeft,
|
||||
verticalWrapperHeight: verticalWrapper.offsetHeight,
|
||||
horizontalWrapperWidth: horizontalWrapper.offsetWidth,
|
||||
headerScrollWidth: header ? header.scrollWidth : 0,
|
||||
bodyScrollWidth: body ? body.scrollWidth : 0
|
||||
};
|
||||
|
||||
// PHASE 2: Calculate all values
|
||||
const contentWidth = Math.max(metrics.headerScrollWidth, metrics.bodyScrollWidth);
|
||||
|
||||
// Visibility
|
||||
const isVerticalRequired = metrics.bodyScrollHeight > metrics.bodyClientHeight;
|
||||
const isHorizontalRequired = contentWidth > metrics.tableClientWidth;
|
||||
|
||||
// Scrollbar sizes
|
||||
let scrollbarHeight = 0;
|
||||
if (metrics.bodyScrollHeight > 0) {
|
||||
scrollbarHeight = (metrics.bodyClientHeight / metrics.bodyScrollHeight) * metrics.verticalWrapperHeight;
|
||||
}
|
||||
|
||||
let scrollbarWidth = 0;
|
||||
if (contentWidth > 0) {
|
||||
scrollbarWidth = (metrics.tableClientWidth / contentWidth) * metrics.horizontalWrapperWidth;
|
||||
}
|
||||
|
||||
// Scrollbar positions
|
||||
const maxScrollTop = metrics.bodyScrollHeight - metrics.bodyClientHeight;
|
||||
let verticalTop = 0;
|
||||
if (maxScrollTop > 0) {
|
||||
const scrollRatio = metrics.verticalWrapperHeight / metrics.bodyScrollHeight;
|
||||
verticalTop = metrics.bodyScrollTop * scrollRatio;
|
||||
}
|
||||
|
||||
const maxScrollLeft = contentWidth - metrics.tableClientWidth;
|
||||
let horizontalLeft = 0;
|
||||
if (maxScrollLeft > 0 && contentWidth > 0) {
|
||||
const scrollRatio = metrics.horizontalWrapperWidth / contentWidth;
|
||||
horizontalLeft = metrics.tableScrollLeft * scrollRatio;
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Pre-calculate and cache scroll ratios for instant mousedown
|
||||
// Vertical scroll ratio
|
||||
if (maxScrollTop > 0 && scrollbarHeight > 0) {
|
||||
cachedVerticalScrollRatio = maxScrollTop / (metrics.verticalWrapperHeight - scrollbarHeight);
|
||||
} else {
|
||||
cachedVerticalScrollRatio = 0;
|
||||
}
|
||||
|
||||
// Horizontal scroll ratio
|
||||
if (maxScrollLeft > 0 && scrollbarWidth > 0) {
|
||||
cachedHorizontalScrollRatio = maxScrollLeft / (metrics.horizontalWrapperWidth - scrollbarWidth);
|
||||
} else {
|
||||
cachedHorizontalScrollRatio = 0;
|
||||
}
|
||||
|
||||
// PHASE 3: Write all DOM properties (already in RAF)
|
||||
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
|
||||
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
|
||||
table.classList.toggle("dt2-has-hscroll", isHorizontalRequired && isVerticalRequired);
|
||||
verticalScrollbar.style.height = `${scrollbarHeight}px`;
|
||||
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
|
||||
verticalScrollbar.style.top = `${verticalTop}px`;
|
||||
horizontalScrollbar.style.left = `${horizontalLeft}px`;
|
||||
});
|
||||
};
|
||||
|
||||
// Consolidated drag management
|
||||
let isDraggingVertical = false;
|
||||
let isDraggingHorizontal = false;
|
||||
let dragStartY = 0;
|
||||
let dragStartX = 0;
|
||||
let dragStartScrollTop = 0;
|
||||
let dragStartScrollLeft = 0;
|
||||
|
||||
// Vertical scrollbar mousedown
|
||||
verticalScrollbar.addEventListener("mousedown", (e) => {
|
||||
isDraggingVertical = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartScrollTop = cachedBodyScrollTop;
|
||||
wrapper.setAttribute("mf-no-tooltip", "");
|
||||
wrapper.setAttribute("mf-no-hover", "");
|
||||
}, {signal});
|
||||
|
||||
// Horizontal scrollbar mousedown
|
||||
horizontalScrollbar.addEventListener("mousedown", (e) => {
|
||||
isDraggingHorizontal = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartScrollLeft = cachedTableScrollLeft;
|
||||
wrapper.setAttribute("mf-no-tooltip", "");
|
||||
wrapper.setAttribute("mf-no-hover", "");
|
||||
}, {signal});
|
||||
|
||||
// Consolidated mousemove listener
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (isDraggingVertical) {
|
||||
const deltaY = e.clientY - dragStartY;
|
||||
|
||||
if (!rafScheduledVertical) {
|
||||
rafScheduledVertical = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduledVertical = false;
|
||||
const scrollDelta = deltaY * cachedVerticalScrollRatio;
|
||||
bodyContainer.scrollTop = dragStartScrollTop + scrollDelta;
|
||||
cachedBodyScrollTop = bodyContainer.scrollTop;
|
||||
updateScrollbars();
|
||||
});
|
||||
}
|
||||
} else if (isDraggingHorizontal) {
|
||||
const deltaX = e.clientX - dragStartX;
|
||||
|
||||
if (!rafScheduledHorizontal) {
|
||||
rafScheduledHorizontal = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduledHorizontal = false;
|
||||
const scrollDelta = deltaX * cachedHorizontalScrollRatio;
|
||||
table.scrollLeft = dragStartScrollLeft + scrollDelta;
|
||||
cachedTableScrollLeft = table.scrollLeft;
|
||||
updateScrollbars();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, {signal});
|
||||
|
||||
// Consolidated mouseup listener
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (isDraggingVertical) {
|
||||
isDraggingVertical = false;
|
||||
wrapper.removeAttribute("mf-no-tooltip");
|
||||
wrapper.removeAttribute("mf-no-hover");
|
||||
} else if (isDraggingHorizontal) {
|
||||
isDraggingHorizontal = false;
|
||||
wrapper.removeAttribute("mf-no-tooltip");
|
||||
wrapper.removeAttribute("mf-no-hover");
|
||||
}
|
||||
}, {signal});
|
||||
|
||||
// Wheel scrolling - OPTIMIZED with RAF throttling
|
||||
let rafScheduledWheel = false;
|
||||
let pendingWheelDeltaX = 0;
|
||||
let pendingWheelDeltaY = 0;
|
||||
let wheelEndTimeout = null;
|
||||
|
||||
const handleWheelScrolling = (event) => {
|
||||
// Disable hover and tooltip during wheel scroll
|
||||
wrapper.setAttribute("mf-no-hover", "");
|
||||
wrapper.setAttribute("mf-no-tooltip", "");
|
||||
|
||||
// Clear previous timeout and re-enable after 150ms of no wheel events
|
||||
if (wheelEndTimeout) clearTimeout(wheelEndTimeout);
|
||||
wheelEndTimeout = setTimeout(() => {
|
||||
wrapper.removeAttribute("mf-no-hover");
|
||||
wrapper.removeAttribute("mf-no-tooltip");
|
||||
}, 150);
|
||||
|
||||
// Accumulate wheel deltas
|
||||
pendingWheelDeltaX += event.deltaX;
|
||||
pendingWheelDeltaY += event.deltaY;
|
||||
|
||||
// Schedule update in next animation frame (throttle)
|
||||
if (!rafScheduledWheel) {
|
||||
rafScheduledWheel = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduledWheel = false;
|
||||
|
||||
// Apply accumulated scroll
|
||||
bodyContainer.scrollTop += pendingWheelDeltaY;
|
||||
table.scrollLeft += pendingWheelDeltaX;
|
||||
|
||||
// Update caches with clamped values (read back from DOM in RAF - OK)
|
||||
cachedBodyScrollTop = bodyContainer.scrollTop;
|
||||
cachedTableScrollLeft = table.scrollLeft;
|
||||
|
||||
// Reset pending deltas
|
||||
pendingWheelDeltaX = 0;
|
||||
pendingWheelDeltaY = 0;
|
||||
|
||||
// Update all scrollbars in a single batched operation
|
||||
updateScrollbars();
|
||||
});
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false, signal});
|
||||
|
||||
// Initialize scrollbars with single batched update
|
||||
updateScrollbars();
|
||||
|
||||
// Recompute on window resize with RAF throttling
|
||||
let resizeScheduled = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!resizeScheduled) {
|
||||
resizeScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
resizeScheduled = false;
|
||||
updateScrollbars();
|
||||
});
|
||||
}
|
||||
}, {signal});
|
||||
}
|
||||
|
||||
function makeDatagridColumnsResizable(datagridId) {
|
||||
//console.debug("makeResizable on element " + datagridId);
|
||||
|
||||
const tableId = 't_' + datagridId;
|
||||
const table = document.getElementById(tableId);
|
||||
const resizeHandles = table.querySelectorAll('.dt2-resize-handle');
|
||||
const MIN_WIDTH = 30; // Prevent columns from becoming too narrow
|
||||
|
||||
// Attach event listeners using delegation
|
||||
resizeHandles.forEach(handle => {
|
||||
handle.addEventListener('mousedown', onStartResize);
|
||||
handle.addEventListener('touchstart', onStartResize, {passive: false});
|
||||
handle.addEventListener('dblclick', onDoubleClick); // Reset column width
|
||||
});
|
||||
|
||||
let resizingState = null; // Maintain resizing state information
|
||||
|
||||
function onStartResize(event) {
|
||||
event.preventDefault(); // Prevent unintended selections
|
||||
|
||||
const isTouch = event.type === 'touchstart';
|
||||
const startX = isTouch ? event.touches[0].pageX : event.pageX;
|
||||
const handle = event.target;
|
||||
const cell = handle.parentElement;
|
||||
const colIndex = cell.getAttribute('data-col');
|
||||
const commandId = handle.dataset.commandId;
|
||||
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
|
||||
|
||||
// Store initial state
|
||||
const startWidth = cell.offsetWidth + 8;
|
||||
resizingState = {startX, startWidth, colIndex, commandId, cells};
|
||||
|
||||
// Attach event listeners for resizing
|
||||
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize);
|
||||
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize);
|
||||
}
|
||||
|
||||
function onResize(event) {
|
||||
if (!resizingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTouch = event.type === 'touchmove';
|
||||
const currentX = isTouch ? event.touches[0].pageX : event.pageX;
|
||||
const {startX, startWidth, cells} = resizingState;
|
||||
|
||||
// Calculate new width and apply constraints
|
||||
const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX));
|
||||
cells.forEach(cell => {
|
||||
cell.style.width = `${newWidth}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function onStopResize(event) {
|
||||
if (!resizingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {colIndex, commandId, cells} = resizingState;
|
||||
|
||||
const finalWidth = cells[0].offsetWidth;
|
||||
|
||||
// Send width update to server via HTMX
|
||||
if (commandId) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
swap: 'none',
|
||||
values: {
|
||||
c_id: commandId,
|
||||
col_id: colIndex,
|
||||
width: finalWidth
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
resizingState = null;
|
||||
document.removeEventListener('mousemove', onResize);
|
||||
document.removeEventListener('mouseup', onStopResize);
|
||||
document.removeEventListener('touchmove', onResize);
|
||||
document.removeEventListener('touchend', onStopResize);
|
||||
}
|
||||
|
||||
function onDoubleClick(event) {
|
||||
const handle = event.target;
|
||||
const cell = handle.parentElement;
|
||||
const colIndex = cell.getAttribute('data-col');
|
||||
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
|
||||
|
||||
// Reset column width
|
||||
cells.forEach(cell => {
|
||||
cell.style.width = ''; // Use CSS default width
|
||||
});
|
||||
|
||||
// Emit reset event
|
||||
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
|
||||
table.dispatchEvent(resetEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable column reordering via drag and drop on a DataGrid.
|
||||
* Columns can be dragged to new positions with animated transitions.
|
||||
* @param {string} gridId - The DataGrid instance ID
|
||||
*/
|
||||
function makeDatagridColumnsMovable(gridId) {
|
||||
const table = document.getElementById(`t_${gridId}`);
|
||||
const headerRow = document.getElementById(`th_${gridId}`);
|
||||
|
||||
if (!table || !headerRow) {
|
||||
console.error(`DataGrid elements not found for ${gridId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const moveCommandId = headerRow.dataset.moveCommandId;
|
||||
const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)');
|
||||
|
||||
let sourceColumn = null; // Column being dragged (original position)
|
||||
let lastMoveTarget = null; // Last column we moved to (for persistence)
|
||||
let hoverColumn = null; // Current hover target (for delayed move check)
|
||||
|
||||
headerCells.forEach(cell => {
|
||||
cell.setAttribute('draggable', 'true');
|
||||
|
||||
// Prevent drag when clicking resize handle
|
||||
const resizeHandle = cell.querySelector('.dt2-resize-handle');
|
||||
if (resizeHandle) {
|
||||
resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false'));
|
||||
resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true'));
|
||||
}
|
||||
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
sourceColumn = cell.getAttribute('data-col');
|
||||
lastMoveTarget = null;
|
||||
hoverColumn = null;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', sourceColumn);
|
||||
cell.classList.add('dt2-dragging');
|
||||
});
|
||||
|
||||
cell.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
const targetColumn = cell.getAttribute('data-col');
|
||||
hoverColumn = targetColumn;
|
||||
|
||||
if (sourceColumn && sourceColumn !== targetColumn) {
|
||||
// Delay to skip columns when dragging fast
|
||||
setTimeout(() => {
|
||||
if (hoverColumn === targetColumn) {
|
||||
moveColumn(table, sourceColumn, targetColumn);
|
||||
lastMoveTarget = targetColumn;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
cell.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
// Persist to server
|
||||
if (moveCommandId && sourceColumn && lastMoveTarget) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
||||
swap: 'none',
|
||||
values: {
|
||||
c_id: moveCommandId,
|
||||
source_col_id: sourceColumn,
|
||||
target_col_id: lastMoveTarget
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cell.addEventListener('dragend', () => {
|
||||
headerCells.forEach(c => c.classList.remove('dt2-dragging'));
|
||||
sourceColumn = null;
|
||||
lastMoveTarget = null;
|
||||
hoverColumn = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a column to a new position with animation.
|
||||
* All columns between source and target shift to fill the gap.
|
||||
* @param {HTMLElement} table - The table element
|
||||
* @param {string} sourceColId - Column ID to move
|
||||
* @param {string} targetColId - Column ID to move next to
|
||||
*/
|
||||
function moveColumn(table, sourceColId, targetColId) {
|
||||
const ANIMATION_DURATION = 300; // Must match CSS transition duration
|
||||
|
||||
const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`);
|
||||
const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`);
|
||||
|
||||
if (!sourceHeader || !targetHeader) return;
|
||||
if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress
|
||||
|
||||
const headerCells = Array.from(sourceHeader.parentNode.children);
|
||||
const sourceIdx = headerCells.indexOf(sourceHeader);
|
||||
const targetIdx = headerCells.indexOf(targetHeader);
|
||||
|
||||
if (sourceIdx === targetIdx) return;
|
||||
|
||||
const movingRight = sourceIdx < targetIdx;
|
||||
const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`);
|
||||
|
||||
// Collect cells that need to shift (between source and target)
|
||||
const cellsToShift = [];
|
||||
let shiftWidth = 0;
|
||||
const [startIdx, endIdx] = movingRight
|
||||
? [sourceIdx + 1, targetIdx]
|
||||
: [targetIdx, sourceIdx - 1];
|
||||
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
const colId = headerCells[i].getAttribute('data-col');
|
||||
cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`));
|
||||
shiftWidth += headerCells[i].offsetWidth;
|
||||
}
|
||||
|
||||
// Calculate animation distances
|
||||
const sourceWidth = sourceHeader.offsetWidth;
|
||||
const sourceDistance = movingRight ? shiftWidth : -shiftWidth;
|
||||
const shiftDistance = movingRight ? -sourceWidth : sourceWidth;
|
||||
|
||||
// Animate source column
|
||||
sourceCells.forEach(cell => {
|
||||
cell.classList.add('dt2-moving');
|
||||
cell.style.transform = `translateX(${sourceDistance}px)`;
|
||||
});
|
||||
|
||||
// Animate shifted columns
|
||||
cellsToShift.forEach(cell => {
|
||||
cell.classList.add('dt2-moving');
|
||||
cell.style.transform = `translateX(${shiftDistance}px)`;
|
||||
});
|
||||
|
||||
// After animation: reset transforms and update DOM
|
||||
setTimeout(() => {
|
||||
[...sourceCells, ...cellsToShift].forEach(cell => {
|
||||
cell.classList.remove('dt2-moving');
|
||||
cell.style.transform = '';
|
||||
});
|
||||
|
||||
// Move source column in DOM
|
||||
table.querySelectorAll('.dt2-row').forEach(row => {
|
||||
const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`);
|
||||
const targetCell = row.querySelector(`[data-col="${targetColId}"]`);
|
||||
if (sourceCell && targetCell) {
|
||||
movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell);
|
||||
}
|
||||
});
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
function updateDatagridSelection(datagridId) {
|
||||
const selectionManager = document.getElementById(`tsm_${datagridId}`);
|
||||
if (!selectionManager) {
|
||||
console.warn(`DataGrid selection manager not found for ${datagridId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-enable tooltips after drag
|
||||
const wrapper = document.getElementById(`tw_${datagridId}`);
|
||||
if (wrapper) wrapper.removeAttribute('mf-no-tooltip');
|
||||
|
||||
// Clear browser text selection to prevent stale ranges from reappearing
|
||||
// But skip if an input/textarea/contenteditable has focus (would clear text cursor)
|
||||
if (!document.activeElement?.closest('input, textarea, [contenteditable]')) {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
// OPTIMIZATION: scope to table instead of scanning the entire document
|
||||
const table = document.getElementById(`t_${datagridId}`);
|
||||
const searchRoot = table ?? document;
|
||||
|
||||
// Clear previous selections and drag preview
|
||||
searchRoot.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column, .dt2-drag-preview, .dt2-selection-border-top, .dt2-selection-border-bottom, .dt2-selection-border-left, .dt2-selection-border-right').forEach((element) => {
|
||||
element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column', 'dt2-drag-preview', 'dt2-selection-border-top', 'dt2-selection-border-bottom', 'dt2-selection-border-left', 'dt2-selection-border-right');
|
||||
element.style.userSelect = '';
|
||||
});
|
||||
|
||||
// Loop through the children of the selection manager
|
||||
Array.from(selectionManager.children).forEach((selection) => {
|
||||
const selectionType = selection.getAttribute('selection-type');
|
||||
const elementId = selection.getAttribute('element-id');
|
||||
|
||||
if (selectionType === 'focus') {
|
||||
const cellElement = document.getElementById(`${elementId}`);
|
||||
if (cellElement) {
|
||||
cellElement.classList.add('dt2-selected-focus');
|
||||
cellElement.style.userSelect = 'text';
|
||||
}
|
||||
} else if (selectionType === 'cell') {
|
||||
const cellElement = document.getElementById(`${elementId}`);
|
||||
if (cellElement) {
|
||||
cellElement.classList.add('dt2-selected-cell');
|
||||
}
|
||||
} else if (selectionType === 'row') {
|
||||
const rowElement = document.getElementById(`${elementId}`);
|
||||
if (rowElement) {
|
||||
rowElement.classList.add('dt2-selected-row');
|
||||
}
|
||||
} else if (selectionType === 'column') {
|
||||
// Select all elements in the specified column
|
||||
searchRoot.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => {
|
||||
columnElement.classList.add('dt2-selected-column');
|
||||
});
|
||||
} else if (selectionType === 'range') {
|
||||
// Parse range tuple string: "(min_col,min_row,max_col,max_row)"
|
||||
// Remove parentheses and split
|
||||
const cleanedId = elementId.replace(/[()]/g, '');
|
||||
const parts = cleanedId.split(',');
|
||||
if (parts.length === 4) {
|
||||
const [minCol, minRow, maxCol, maxRow] = parts;
|
||||
|
||||
// Convert to integers
|
||||
const minColNum = parseInt(minCol);
|
||||
const maxColNum = parseInt(maxCol);
|
||||
const minRowNum = parseInt(minRow);
|
||||
const maxRowNum = parseInt(maxRow);
|
||||
|
||||
// Iterate through range and select cells by reconstructed ID
|
||||
for (let col = minColNum; col <= maxColNum; col++) {
|
||||
for (let row = minRowNum; row <= maxRowNum; row++) {
|
||||
const cellId = `tcell_${datagridId}-${col}-${row}`;
|
||||
const cell = document.getElementById(cellId);
|
||||
if (cell) {
|
||||
cell.classList.add('dt2-selected-cell');
|
||||
if (row === minRowNum) cell.classList.add('dt2-selection-border-top');
|
||||
if (row === maxRowNum) cell.classList.add('dt2-selection-border-bottom');
|
||||
if (col === minColNum) cell.classList.add('dt2-selection-border-left');
|
||||
if (col === maxColNum) cell.classList.add('dt2-selection-border-right');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the parent element with .dt2-cell class and return its id.
|
||||
* Used with hx-vals="js:getCellId()" for DataGrid cell identification.
|
||||
*
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @returns {Object} Object with cell_id property, or empty object if not found
|
||||
*/
|
||||
function getCellId(event) {
|
||||
const cell = event.target.closest('.dt2-cell');
|
||||
if (cell && cell.id) {
|
||||
return {cell_id: cell.id};
|
||||
}
|
||||
return {cell_id: null};
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Cache of highlighted cells per grid to avoid querySelectorAll on every animation frame
|
||||
const _dragHighlightCache = new Map();
|
||||
|
||||
/**
|
||||
* Highlight the drag selection range in real time during a mousedown>mouseup drag.
|
||||
* Called by mouse.js on each animation frame while dragging.
|
||||
* Applies .dt2-drag-preview to all cells in the rectangle between the start and
|
||||
* current cell. The preview is cleared by updateDatagridSelection() when the server
|
||||
* responds with the final selection.
|
||||
*
|
||||
* @param {MouseEvent} event - The current mousemove event
|
||||
* @param {string} combination - The active mouse combination (e.g. "mousedown>mouseup")
|
||||
* @param {Object|null} mousedownResult - Result of getCellId() at mousedown, or null
|
||||
*/
|
||||
function highlightDatagridDragRange(event, combination, mousedownResult) {
|
||||
if (!mousedownResult || !mousedownResult.cell_id) return;
|
||||
|
||||
const currentCell = event.target.closest('.dt2-cell');
|
||||
if (!currentCell || !currentCell.id) return;
|
||||
|
||||
const startCellId = mousedownResult.cell_id;
|
||||
const endCellId = currentCell.id;
|
||||
|
||||
// Find the table from the start cell to scope the query
|
||||
const startCell = document.getElementById(startCellId);
|
||||
if (!startCell) return;
|
||||
const table = startCell.closest('.dt2-table');
|
||||
if (!table) return;
|
||||
|
||||
// Extract grid ID from table id: "t_{gridId}" -> "{gridId}"
|
||||
const gridId = table.id.substring(2);
|
||||
|
||||
// Disable tooltips during drag
|
||||
const wrapper = document.getElementById(`tw_${gridId}`);
|
||||
if (wrapper) wrapper.setAttribute('mf-no-tooltip', '');
|
||||
|
||||
// Parse col/row by splitting on "-" and taking the last two numeric parts
|
||||
const startParts = startCellId.split('-');
|
||||
const startCol = parseInt(startParts[startParts.length - 2]);
|
||||
const startRow = parseInt(startParts[startParts.length - 1]);
|
||||
|
||||
const endParts = endCellId.split('-');
|
||||
const endCol = parseInt(endParts[endParts.length - 2]);
|
||||
const endRow = parseInt(endParts[endParts.length - 1]);
|
||||
|
||||
if (isNaN(startCol) || isNaN(startRow) || isNaN(endCol) || isNaN(endRow)) return;
|
||||
|
||||
// OPTIMIZATION: Clear only previously highlighted cells instead of querySelectorAll on all table cells
|
||||
const prevHighlighted = _dragHighlightCache.get(gridId);
|
||||
if (prevHighlighted) {
|
||||
prevHighlighted.forEach(c => c.classList.remove('dt2-drag-preview', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column', 'dt2-selected-focus', 'dt2-selection-border-top', 'dt2-selection-border-bottom', 'dt2-selection-border-left', 'dt2-selection-border-right'));
|
||||
}
|
||||
|
||||
// Apply preview to all cells in the rectangular range and track them
|
||||
const minCol = Math.min(startCol, endCol);
|
||||
const maxCol = Math.max(startCol, endCol);
|
||||
const minRow = Math.min(startRow, endRow);
|
||||
const maxRow = Math.max(startRow, endRow);
|
||||
|
||||
const newHighlighted = [];
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
for (let row = minRow; row <= maxRow; row++) {
|
||||
const cell = document.getElementById(`tcell_${gridId}-${col}-${row}`);
|
||||
if (cell) {
|
||||
cell.classList.add('dt2-drag-preview');
|
||||
if (row === minRow) cell.classList.add('dt2-selection-border-top');
|
||||
if (row === maxRow) cell.classList.add('dt2-selection-border-bottom');
|
||||
if (col === minCol) cell.classList.add('dt2-selection-border-left');
|
||||
if (col === maxCol) cell.classList.add('dt2-selection-border-right');
|
||||
newHighlighted.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
_dragHighlightCache.set(gridId, newHighlighted);
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
:root {
|
||||
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--font-weight-medium: 500;
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--properties-font-size: var(--text-xs);
|
||||
--mf-tooltip-zindex: 10;
|
||||
}
|
||||
|
||||
|
||||
.mf-icon-16 {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-24 {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
margin-top: auto;
|
||||
|
||||
}
|
||||
|
||||
.mf-icon-28 {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-32 {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* MF Layout Component - CSS Grid Layout
|
||||
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
.mf-tooltip-container {
|
||||
background: var(--color-base-200);
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none; /* Prevent interfering with mouse events */
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0; /* Default to invisible */
|
||||
visibility: hidden; /* Prevent interaction when invisible */
|
||||
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
||||
position: fixed; /* Keep it above other content and adjust position */
|
||||
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
|
||||
}
|
||||
|
||||
.mf-tooltip-container[data-visible="true"] {
|
||||
opacity: 1;
|
||||
visibility: visible; /* Show tooltip */
|
||||
transition: opacity 0.3s ease; /* No delay when becoming visible */
|
||||
}
|
||||
|
||||
/* Main layout container using CSS Grid */
|
||||
.mf-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"left-drawer main right-drawer"
|
||||
"footer footer footer";
|
||||
grid-template-rows: 32px 1fr 32px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header - fixed at top */
|
||||
.mf-layout-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* put one item on each side */
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-base-300);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Footer - fixed at bottom */
|
||||
.mf-layout-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-neutral);
|
||||
color: var(--color-neutral-content);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Main content area - scrollable */
|
||||
.mf-layout-main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Drawer base styles */
|
||||
.mf-layout-drawer {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-base-100);
|
||||
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
|
||||
width: 250px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Left drawer */
|
||||
.mf-layout-left-drawer {
|
||||
grid-area: left-drawer;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right drawer */
|
||||
.mf-layout-right-drawer {
|
||||
grid-area: right-drawer;
|
||||
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Collapsed drawer states */
|
||||
.mf-layout-drawer.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toggle buttons positioning */
|
||||
.mf-layout-toggle-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mf-layout-toggle-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar styling for webkit browsers */
|
||||
.mf-layout-main::-webkit-scrollbar,
|
||||
.mf-layout-drawer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-track,
|
||||
.mf-layout-drawer::-webkit-scrollbar-track {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb:hover,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.mf-layout-drawer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mf-layout-header,
|
||||
.mf-layout-footer {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.mf-layout-main {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle layouts with no drawers */
|
||||
.mf-layout[data-left-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main right-drawer"
|
||||
"footer footer";
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.mf-layout[data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"left-drawer main"
|
||||
"footer footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"footer";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
* Styles for the resizable drawer borders with visual feedback
|
||||
*/
|
||||
|
||||
/* Ensure drawer has relative positioning and no overflow */
|
||||
.mf-layout-drawer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content wrapper handles scrolling */
|
||||
.mf-layout-drawer-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Base resizer styles */
|
||||
.mf-layout-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Resizer on the right side (for left drawer) */
|
||||
.mf-layout-resizer-right {
|
||||
right: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Resizer on the left side (for right drawer) */
|
||||
.mf-layout-resizer-left {
|
||||
left: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.mf-layout-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-layout-drawer-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-layout-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-layout-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-layout-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-right::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-left::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer:hover::before,
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.mf-layout-group {
|
||||
font-weight: bold;
|
||||
/*font-size: var(--text-sm);*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* *********** Tabs Manager Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Tabs Manager Container */
|
||||
.mf-tabs-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
/* Tabs Header using DaisyUI tabs component */
|
||||
.mf-tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
min-height: 25px;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-tabs-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
/*overflow: hidden; important */
|
||||
}
|
||||
|
||||
/* Individual Tab Button using DaisyUI tab classes */
|
||||
.mf-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-button:hover {
|
||||
color: var(--color-base-content); /* Change text color on hover */
|
||||
}
|
||||
|
||||
.mf-tab-button.mf-tab-active {
|
||||
--depth: 1;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-radius: .25rem;
|
||||
border-bottom: 4px solid var(--color-primary);
|
||||
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||
}
|
||||
|
||||
/* Tab Label */
|
||||
.mf-tab-label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Tab Close Button */
|
||||
.mf-tab-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@apply text-base-content/50;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-close-btn:hover {
|
||||
@apply bg-base-300 text-error;
|
||||
}
|
||||
|
||||
/* Tab Content Area */
|
||||
.mf-tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-tab-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
.mf-empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
@apply text-base-content/50;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mf-vis {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-dropdown-wrapper {
|
||||
position: relative; /* CRUCIAL for the anchor */
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
.mf-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0px;
|
||||
z-index: 1;
|
||||
width: 200px;
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
/*opacity: 0;*/
|
||||
/*transition: opacity 0.2s ease-in-out;*/
|
||||
}
|
||||
|
||||
.mf-dropdown.is-visible {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************** TreeView Component ************* */
|
||||
/* *********************************************** */
|
||||
|
||||
/* TreeView Container */
|
||||
.mf-treeview {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* TreeNode Container */
|
||||
.mf-treenode-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* TreeNode Element */
|
||||
.mf-treenode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Input for Editing */
|
||||
.mf-treenode-input {
|
||||
flex: 1;
|
||||
padding: 2px 0.25rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-base-100);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode:hover {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-treenode.selected {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
/* Toggle Icon */
|
||||
.mf-treenode-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Node Label */
|
||||
.mf-treenode-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode-input:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Action Buttons - Hidden by default, shown on hover */
|
||||
.mf-treenode-actions {
|
||||
display: none;
|
||||
gap: 0.1rem;
|
||||
white-space: nowrap;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** Generic Resizer Classes ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Generic resizer - used by both Layout and Panel */
|
||||
.mf-resizer {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-resizing .mf-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-resizer:hover::before,
|
||||
.mf-resizing .mf-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Resizer positioning */
|
||||
/* Left resizer is on the right side of the left panel */
|
||||
.mf-resizer-left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Right resizer is on the left side of the right panel */
|
||||
.mf-resizer-right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Position indicator for resizer */
|
||||
.mf-resizer-left::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-resizer-right::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-item-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* *************** Panel Component *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||
}
|
||||
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Properties container */
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Group card - using DaisyUI card styling */
|
||||
.mf-properties-group-card {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Group header - gradient using DaisyUI primary color */
|
||||
.mf-properties-group-header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||
color: var(--color-primary-content);
|
||||
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
||||
font-weight: 700;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/* Group content area */
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Property row */
|
||||
.mf-properties-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
gap: calc(var(--properties-font-size) * 0.75);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/* Property key - normal font */
|
||||
.mf-properties-key {
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
flex: 0 0 40%;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/* Property value - monospace font */
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--properties-font-size);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
4
src/myfasthtml/assets/vis/visnetwork.css
Normal file
4
src/myfasthtml/assets/vis/visnetwork.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.mf-vis {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -26,8 +26,7 @@ DEFAULT_SKIP_PATTERNS = [
|
||||
r'/static/.*',
|
||||
r'.*\.css',
|
||||
r'.*\.js',
|
||||
r'/myfasthtml/.*\.css',
|
||||
r'/myfasthtml/.*\.js',
|
||||
r'/myfasthtml/assets/.*',
|
||||
'/login',
|
||||
'/register',
|
||||
'/logout',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
@@ -17,6 +16,7 @@ class Commands(BaseCommands):
|
||||
def update_boundaries(self):
|
||||
return Command(f"{self._prefix}UpdateBoundaries",
|
||||
"Update component boundaries",
|
||||
self._owner,
|
||||
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
@@ -9,22 +9,18 @@ class CommandsDebugger(SingleInstance):
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
def render(self):
|
||||
commands = self._get_commands()
|
||||
nodes, edges = from_parent_child_list(commands,
|
||||
id_getter=lambda x: str(x.id),
|
||||
label_getter=lambda x: x.name,
|
||||
parent_getter=lambda x: str(self.get_command_parent(x))
|
||||
)
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
||||
return vis_network
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent(command):
|
||||
def get_command_parent_from_ft(command):
|
||||
if (ft := command.get_ft()) is None:
|
||||
return None
|
||||
if hasattr(ft, "get_id") and callable(ft.get_id):
|
||||
@@ -36,6 +32,30 @@ class CommandsDebugger(SingleInstance):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent_from_instance(command):
|
||||
if command.owner is None:
|
||||
return None
|
||||
|
||||
return command.owner.get_full_id()
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
commands = self._get_commands()
|
||||
nodes, edges = from_parent_child_list(commands,
|
||||
id_getter=lambda x: str(x.id),
|
||||
label_getter=lambda x: x.name,
|
||||
parent_getter=lambda x: str(self.get_command_parent_from_instance(x)),
|
||||
ghost_label_getter=lambda x: InstancesManager.get(*x.split("#")).get_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "blue"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
return nodes, edges
|
||||
|
||||
def _get_commands(self):
|
||||
return list(CommandsManager.commands.values())
|
||||
|
||||
|
||||
56
src/myfasthtml/controls/CycleStateControl.py
Normal file
56
src/myfasthtml/controls/CycleStateControl.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger("CycleStateControl")
|
||||
|
||||
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=_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):
|
||||
logger.debug(f"cycle_state datagrid={self._parent.get_table_name()}")
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
277
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
277
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGridFormulaEditor import DataGridFormulaEditor
|
||||
from myfasthtml.controls.DslEditor import DslEditorConf
|
||||
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.dsls import DslsManager
|
||||
from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine
|
||||
from myfasthtml.core.formula.dsl.parser import FormulaParser
|
||||
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 save_column_details(self, col_id):
|
||||
return Command(f"SaveColumnDetails",
|
||||
f"Save column {col_id}",
|
||||
self._owner,
|
||||
self._owner.save_column_details,
|
||||
kwargs={"col_id": col_id}
|
||||
).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def on_new_column(self):
|
||||
return Command(f"OnNewColumn",
|
||||
f"New column",
|
||||
self._owner,
|
||||
self._owner.on_new_column).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def on_column_type_changed(self):
|
||||
return Command(f"OnColumnTypeChanged",
|
||||
f"Column Type changed",
|
||||
self._owner,
|
||||
self._owner.on_column_type_changed).htmx(target=f"#{self._id}", swap="innerHTML", trigger="change")
|
||||
|
||||
|
||||
class DataGridColumnsManager(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._new_column = False
|
||||
|
||||
completion_engine = FormulaCompletionEngine(
|
||||
self._parent._parent,
|
||||
self._parent.get_table_name(),
|
||||
)
|
||||
conf = DslEditorConf(name="formula", save_button=False, line_numbers=False, engine_id=completion_engine.get_id())
|
||||
self._formula_editor = DataGridFormulaEditor(self, conf=conf, _id=f"{self._id}-formula-editor")
|
||||
DslsManager.register(completion_engine, FormulaParser())
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return self._parent.get_state().columns
|
||||
|
||||
def _get_col_def_from_col_id(self, col_id, copy=True):
|
||||
"""
|
||||
"""
|
||||
cols_defs = [c for c in self.columns if c.col_id == col_id]
|
||||
if not cols_defs:
|
||||
return None
|
||||
|
||||
return cols_defs[0].copy() if copy else cols_defs[0]
|
||||
|
||||
def _get_updated_col_def_from_col_id(self, col_id, updates=None, copy=True):
|
||||
col_def = self._get_col_def_from_col_id(col_id, copy=copy)
|
||||
if col_def is None:
|
||||
col_def = DataGridColumnState(col_id, -1)
|
||||
|
||||
if updates is not None:
|
||||
updates["visible"] = "visible" in updates and updates["visible"] == "on"
|
||||
for k, v in [(k, v) for k, v in updates.items() if hasattr(col_def, k)]:
|
||||
if k == "type":
|
||||
col_def.type = ColumnType(v)
|
||||
elif k == "width":
|
||||
col_def.width = int(v)
|
||||
elif k == "formula":
|
||||
col_def.formula = v or ""
|
||||
# self._register_formula(col_def), Will be done in save_column_details()
|
||||
else:
|
||||
setattr(col_def, k, v)
|
||||
|
||||
return col_def
|
||||
|
||||
def toggle_column(self, col_id):
|
||||
logger.debug(f"toggle_column {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id, copy=False)
|
||||
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_updated_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_inner_content()
|
||||
|
||||
def save_column_details(self, col_id, client_response):
|
||||
logger.debug(f"save_column_details {col_id=}, {client_response=}")
|
||||
col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
|
||||
if col_def.col_id == "__new__":
|
||||
self._parent.add_new_column(col_def) # sets the correct col_id before _register_formula
|
||||
self._register_formula(col_def)
|
||||
self._parent.save_state()
|
||||
|
||||
return self._mk_inner_content()
|
||||
|
||||
def on_new_column(self):
|
||||
self._new_column = True
|
||||
col_def = self._get_updated_col_def_from_col_id("__new__")
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
def on_column_type_changed(self, col_id, client_response):
|
||||
logger.debug(f"on_column_type_changed {col_id=}, {client_response=}")
|
||||
col_def = self._get_updated_col_def_from_col_id(col_id, client_response)
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
def _register_formula(self, col_def) -> None:
|
||||
"""Register or remove a formula column with the FormulaEngine.
|
||||
|
||||
Registers only when col_def.type is Formula and the formula text is
|
||||
non-empty. Removes the formula in all other cases so the engine stays
|
||||
consistent with the column definition.
|
||||
"""
|
||||
engine = self._parent.get_formula_engine()
|
||||
if engine is None:
|
||||
return
|
||||
table = self._parent.get_table_name()
|
||||
if col_def.type == ColumnType.Formula and col_def.formula:
|
||||
try:
|
||||
engine.set_formula(table, col_def.col_id, col_def.formula)
|
||||
logger.debug("Registered formula for %s.%s", table, col_def.col_id)
|
||||
except Exception as e:
|
||||
logger.warning("Formula error for %s.%s: %s", table, col_def.col_id, e)
|
||||
else:
|
||||
engine.remove_formula(table, col_def.col_id)
|
||||
|
||||
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),
|
||||
|
||||
Label("Title"),
|
||||
Input(name="title",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.title),
|
||||
|
||||
Label("type"),
|
||||
mk.mk(
|
||||
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,
|
||||
), command=self.commands.on_column_type_changed()
|
||||
),
|
||||
|
||||
*([
|
||||
Label("Formula"),
|
||||
self._formula_editor,
|
||||
] if col_def.type == ColumnType.Formula else []),
|
||||
|
||||
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",
|
||||
),
|
||||
|
||||
legend="Column details",
|
||||
cls="fieldset border-base-300 rounded-box"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self.commands.save_column_details(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 mk_new_column(self):
|
||||
return Div(
|
||||
mk.button("New Column", command=self.commands.on_new_column()),
|
||||
cls="mb-1",
|
||||
)
|
||||
|
||||
def _mk_inner_content(self):
|
||||
return (self.mk_all_columns(),
|
||||
self.mk_new_column())
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*self._mk_inner_content(),
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
137
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
137
src/myfasthtml/controls/DataGridFormattingEditor.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from myfasthtml.controls.DslEditor import DslEditor
|
||||
from myfasthtml.controls.datagrid_objects import DataGridRowState
|
||||
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
|
||||
logger = logging.getLogger("DataGridFormattingEditor")
|
||||
|
||||
|
||||
class DataGridFormattingEditor(DslEditor):
|
||||
|
||||
def _find_column_by_name(self, name: str):
|
||||
"""
|
||||
Find a column by name, searching col_id first, then title.
|
||||
|
||||
Returns:
|
||||
tuple (col_pos, col_def) if found, (None, None) otherwise
|
||||
"""
|
||||
# First pass: match by col_id
|
||||
for col_pos, col_def in enumerate(self._parent.get_state().columns):
|
||||
if col_def.col_id == name:
|
||||
return col_pos, col_def
|
||||
|
||||
# Second pass: match by title
|
||||
for col_pos, col_def in enumerate(self._parent.get_state().columns):
|
||||
if col_def.title == name:
|
||||
return col_pos, col_def
|
||||
|
||||
return None, None
|
||||
|
||||
def _get_cell_id(self, scope: CellScope):
|
||||
"""
|
||||
Get cell_id from CellScope.
|
||||
|
||||
If scope has cell_id, use it directly.
|
||||
Otherwise, resolve coordinates (column, row) to cell_id.
|
||||
|
||||
Returns:
|
||||
cell_id string or None if column not found
|
||||
"""
|
||||
if scope.cell_id:
|
||||
return scope.cell_id
|
||||
|
||||
col_pos, _ = self._find_column_by_name(scope.column)
|
||||
if col_pos is None:
|
||||
logger.warning(f"Column '{scope.column}' not found for CellScope")
|
||||
return None
|
||||
|
||||
return self._parent._get_element_id_from_pos("cell", (col_pos, scope.row))
|
||||
|
||||
def on_content_changed(self):
|
||||
dsl = self.get_content()
|
||||
|
||||
# Step 1: Parse DSL
|
||||
try:
|
||||
scoped_rules = parse_dsl(dsl)
|
||||
except DSLSyntaxError as e:
|
||||
logger.debug(f"DSL syntax error, keeping old formatting: {e}")
|
||||
return
|
||||
|
||||
# Step 2: Group rules by scope
|
||||
columns_rules = defaultdict(list) # key = column name
|
||||
rows_rules = defaultdict(list) # key = row index
|
||||
cells_rules = defaultdict(list) # key = cell_id
|
||||
table_rules = [] # rules for this table
|
||||
tables_rules = [] # global rules for all tables
|
||||
|
||||
for scoped_rule in scoped_rules:
|
||||
scope = scoped_rule.scope
|
||||
rule = scoped_rule.rule
|
||||
|
||||
if isinstance(scope, ColumnScope):
|
||||
columns_rules[scope.column].append(rule)
|
||||
elif isinstance(scope, RowScope):
|
||||
rows_rules[scope.row].append(rule)
|
||||
elif isinstance(scope, CellScope):
|
||||
cell_id = self._get_cell_id(scope)
|
||||
if cell_id:
|
||||
cells_rules[cell_id].append(rule)
|
||||
elif isinstance(scope, TableScope):
|
||||
# Validate table name matches current grid
|
||||
if scope.table == self._parent.get_table_name():
|
||||
table_rules.append(rule)
|
||||
else:
|
||||
logger.warning(f"Table name '{scope.table}' does not match grid name '{self._parent.get_table_name()}', skipping rules")
|
||||
elif isinstance(scope, TablesScope):
|
||||
tables_rules.append(rule)
|
||||
|
||||
# Step 3: Copy state for atomic update
|
||||
state = self._parent.get_state().copy()
|
||||
|
||||
# Step 4: Clear existing formats on the copy
|
||||
for col in state.columns:
|
||||
col.format = None
|
||||
for row in state.rows:
|
||||
row.format = None
|
||||
state.cell_formats.clear()
|
||||
state.table_format = []
|
||||
|
||||
# Step 5: Apply grouped rules on the copy
|
||||
for column_name, rules in columns_rules.items():
|
||||
col_pos, col_def = self._find_column_by_name(column_name)
|
||||
if col_def:
|
||||
# Find the column in the copied state
|
||||
state.columns[col_pos].format = rules
|
||||
else:
|
||||
logger.warning(f"Column '{column_name}' not found, skipping rules")
|
||||
|
||||
for row_index, rules in rows_rules.items():
|
||||
row_state = next((r for r in state.rows if r.row_id == row_index), None)
|
||||
if row_state is None:
|
||||
row_state = DataGridRowState(row_id=row_index)
|
||||
state.rows.append(row_state)
|
||||
row_state.format = rules
|
||||
|
||||
for cell_id, rules in cells_rules.items():
|
||||
state.cell_formats[cell_id] = rules
|
||||
|
||||
# Apply table-level rules
|
||||
if table_rules:
|
||||
state.table_format = table_rules
|
||||
|
||||
# Apply global tables-level rules to manager
|
||||
if tables_rules:
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
|
||||
if manager:
|
||||
manager.all_tables_formats = tables_rules
|
||||
|
||||
# Step 6: Update state atomically
|
||||
self._parent.get_state().update(state)
|
||||
|
||||
# Step 7: Refresh the DataGrid
|
||||
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells, table: {len(table_rules)}, tables: {len(tables_rules)}")
|
||||
return self._parent.render_partial("body")
|
||||
66
src/myfasthtml/controls/DataGridFormulaEditor.py
Normal file
66
src/myfasthtml/controls/DataGridFormulaEditor.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
DataGridFormulaEditor — DslEditor for formula column expressions.
|
||||
|
||||
Extends DslEditor with formula-specific behavior:
|
||||
- Parses the formula on content change
|
||||
- Registers the formula with FormulaEngine
|
||||
- Triggers a body re-render on the parent DataGrid
|
||||
"""
|
||||
import logging
|
||||
|
||||
from myfasthtml.controls.DslEditor import DslEditor
|
||||
from myfasthtml.core.formula.dsl.definition import FormulaDSL
|
||||
|
||||
logger = logging.getLogger("DataGridFormulaEditor")
|
||||
|
||||
|
||||
class DataGridFormulaEditor(DslEditor):
|
||||
"""
|
||||
Formula editor for a specific DataGrid column.
|
||||
|
||||
Args:
|
||||
parent: The parent DataGrid instance.
|
||||
col_def: The DataGridColumnState for the formula column.
|
||||
conf: DslEditorConf for CodeMirror configuration.
|
||||
_id: Optional instance ID.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, FormulaDSL(), conf=conf, _id=_id)
|
||||
|
||||
# def on_content_changed(self):
|
||||
# """
|
||||
# Called when the formula text is changed in the editor.
|
||||
#
|
||||
# 1. Updates col_def.formula with the new text.
|
||||
# 2. Registers the formula with the FormulaEngine.
|
||||
# 3. Triggers a body re-render of the parent DataGrid.
|
||||
# """
|
||||
# formula_text = self.get_content()
|
||||
#
|
||||
# # Update the column definition
|
||||
# self._col_def.formula = formula_text or ""
|
||||
#
|
||||
# # Register with the FormulaEngine
|
||||
# engine = self._parent.get_formula_engine()
|
||||
# if engine is not None:
|
||||
# table = self._parent.get_table_name()
|
||||
# try:
|
||||
# engine.set_formula(table, self._col_def.col_id, formula_text)
|
||||
# logger.debug(
|
||||
# "Formula updated for %s.%s: %s",
|
||||
# table, self._col_def.col_id, formula_text,
|
||||
# )
|
||||
# except FormulaSyntaxError as e:
|
||||
# logger.debug("Formula syntax error, keeping old formula: %s", e)
|
||||
# return
|
||||
# except FormulaCycleError as e:
|
||||
# logger.warning("Formula cycle detected for %s.%s: %s", table, self._col_def.col_id, e)
|
||||
# return
|
||||
# except Exception as e:
|
||||
# logger.warning("Formula engine error for %s.%s: %s", table, self._col_def.col_id, e)
|
||||
# return
|
||||
#
|
||||
# # Save state and re-render the grid body
|
||||
# self._parent.save_state()
|
||||
# return self._parent.render_partial("body")
|
||||
97
src/myfasthtml/controls/DataGridQuery.py
Normal file
97
src/myfasthtml/controls/DataGridQuery.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent import brain_circuit20_regular
|
||||
from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
|
||||
from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
|
||||
|
||||
logger = logging.getLogger("DataGridQuery")
|
||||
|
||||
DG_QUERY_FILTER = "filter"
|
||||
DG_QUERY_SEARCH = "search"
|
||||
DG_QUERY_AI = "ai"
|
||||
|
||||
query_type = {
|
||||
DG_QUERY_FILTER: filter20_regular,
|
||||
DG_QUERY_SEARCH: search20_regular,
|
||||
DG_QUERY_AI: brain_circuit20_regular
|
||||
}
|
||||
|
||||
|
||||
class DataGridFilterState(DbObject):
|
||||
def __init__(self, owner):
|
||||
with self.initializing():
|
||||
super().__init__(owner)
|
||||
self.filter_type: str = "filter"
|
||||
self.query: Optional[str] = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def change_filter_type(self):
|
||||
return Command("ChangeFilterType",
|
||||
"Change filter type",
|
||||
self._owner,
|
||||
self._owner.change_query_type).htmx(target=f"#{self._id}")
|
||||
|
||||
def on_filter_changed(self):
|
||||
return Command("QueryChanged",
|
||||
"Query changed",
|
||||
self._owner,
|
||||
self._owner.query_changed).htmx(target=None)
|
||||
|
||||
def on_cancel_query(self):
|
||||
return Command("CancelQuery",
|
||||
"Cancel query",
|
||||
self._owner,
|
||||
self._owner.query_changed,
|
||||
kwargs={"query": ""}
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class DataGridQuery(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridFilterState(self)
|
||||
|
||||
def get_query(self):
|
||||
return self._state.query
|
||||
|
||||
def get_query_type(self):
|
||||
return self._state.filter_type
|
||||
|
||||
def change_query_type(self):
|
||||
keys = list(query_type.keys()) # ["filter", "search", "ai"]
|
||||
current_idx = keys.index(self._state.filter_type)
|
||||
self._state.filter_type = keys[(current_idx + 1) % len(keys)]
|
||||
return self
|
||||
|
||||
def query_changed(self, query):
|
||||
logger.debug(f"query_changed {query=}")
|
||||
self._state.query = query.strip() if query is not None else None
|
||||
return self
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.label(
|
||||
Input(name="query",
|
||||
value=self._state.query if self._state.query is not None else "",
|
||||
placeholder="Search...",
|
||||
**self.commands.on_filter_changed().get_htmx_params(values_encode="json")),
|
||||
icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()),
|
||||
cls="input input-xs flex gap-3"
|
||||
),
|
||||
mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()),
|
||||
cls="flex",
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,56 +1,280 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView
|
||||
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.instances import MultipleInstance, InstancesManager
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||
from myfasthtml.core.formula.engine import FormulaEngine
|
||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentDefinition:
|
||||
document_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
type: str # table, card,
|
||||
tab_id: str
|
||||
datagrid_id: str
|
||||
|
||||
|
||||
class DataGridsState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.elements: list[DocumentDefinition] = []
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def upload_from_source(self):
|
||||
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
|
||||
return Command("UploadFromSource",
|
||||
"Upload from source",
|
||||
self._owner,
|
||||
self._owner.upload_from_source).htmx(target=None)
|
||||
|
||||
def new_grid(self):
|
||||
return Command("NewGrid", "New grid", self._owner.new_grid)
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.new_grid)
|
||||
|
||||
def open_from_excel(self, tab_id, get_content_callback):
|
||||
excel_content = get_content_callback()
|
||||
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
|
||||
def open_from_excel(self, tab_id, file_upload):
|
||||
return Command("OpenFromExcel",
|
||||
"Open from Excel",
|
||||
self._owner,
|
||||
self._owner.open_from_excel,
|
||||
args=[tab_id,
|
||||
file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def clear_tree(self):
|
||||
return Command("ClearTree",
|
||||
"Clear tree",
|
||||
self._owner,
|
||||
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def show_document(self):
|
||||
return Command("ShowDocument",
|
||||
"Show document",
|
||||
self._owner,
|
||||
self._owner.select_document,
|
||||
key="SelectNode")
|
||||
|
||||
|
||||
class DataGridsManager(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
|
||||
return
|
||||
super().__init__(parent, _id=_id)
|
||||
self.tree = TreeView(self, _id="-treeview")
|
||||
self.commands = Commands(self)
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
self._state = DataGridsState(self)
|
||||
self._tree = self._mk_tree()
|
||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
|
||||
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()
|
||||
self.all_tables_formats: list = []
|
||||
|
||||
# Formula engine shared across all DataGrids in this session
|
||||
self._formula_engine = FormulaEngine(
|
||||
registry_resolver=self._resolve_store_for_table
|
||||
)
|
||||
|
||||
def upload_from_source(self):
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
|
||||
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
|
||||
file_upload = FileUpload(self)
|
||||
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
|
||||
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
|
||||
return self._tabs_manager.show_tab(tab_id)
|
||||
|
||||
def open_from_excel(self, tab_id, excel_content):
|
||||
df = pd.read_excel(excel_content)
|
||||
content = df.to_html(index=False)
|
||||
self._tabs_manager.switch(tab_id, content)
|
||||
def open_from_excel(self, tab_id, file_upload: FileUpload):
|
||||
excel_content = file_upload.get_content()
|
||||
df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
|
||||
namespace = file_upload.get_file_basename()
|
||||
name = file_upload.get_sheet_name()
|
||||
dg_conf = DatagridConf(namespace=namespace, name=name)
|
||||
dg = DataGrid(self, conf=dg_conf, save_state=True) # first time the Datagrid is created
|
||||
dg.init_from_dataframe(df)
|
||||
self._registry.put(namespace, name, dg.get_id())
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=dg.get_id()
|
||||
)
|
||||
self._state.elements = self._state.elements + [document] # do not use append() other it won't be saved
|
||||
parent_id = self._tree.ensure_path(document.namespace)
|
||||
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, dg)
|
||||
|
||||
def select_document(self, node_id):
|
||||
document_id = self._tree.get_bag(node_id)
|
||||
try:
|
||||
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
|
||||
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
|
||||
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
|
||||
except StopIteration:
|
||||
# the selected node is not a document (it's a folder)
|
||||
return None
|
||||
|
||||
def create_tab_content(self, tab_id):
|
||||
"""
|
||||
Recreate the content for a tab managed by this DataGridsManager.
|
||||
Called by TabsManager when the content is not in cache (e.g., after restart).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab to recreate content for
|
||||
|
||||
Returns:
|
||||
The recreated component (Panel with DataGrid)
|
||||
"""
|
||||
# Find the document associated with this tab
|
||||
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
|
||||
|
||||
if document is None:
|
||||
raise ValueError(f"No document found for tab {tab_id}")
|
||||
|
||||
# Recreate the DataGrid with its saved state
|
||||
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
|
||||
return dg
|
||||
|
||||
def clear_tree(self):
|
||||
self._state.elements = []
|
||||
self._tree.clear()
|
||||
return self._tree
|
||||
|
||||
# === DatagridMetadataProvider ===
|
||||
|
||||
def list_tables(self):
|
||||
return self._registry.get_all_tables()
|
||||
|
||||
def list_columns(self, table_name):
|
||||
return self._registry.get_columns(table_name)
|
||||
|
||||
def list_column_values(self, table_name, column_name):
|
||||
return self._registry.get_column_values(table_name, column_name)
|
||||
|
||||
def get_row_count(self, table_name):
|
||||
return self._registry.get_row_count(table_name)
|
||||
|
||||
def get_column_type(self, table_name, column_name):
|
||||
return self._registry.get_column_type(table_name, column_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())
|
||||
|
||||
def _resolve_store_for_table(self, table_name: str):
|
||||
"""
|
||||
Resolve the DatagridStore for a given table name.
|
||||
|
||||
Used by FormulaEngine as the registry_resolver callback.
|
||||
|
||||
Args:
|
||||
table_name: Full table name in ``"namespace.name"`` format.
|
||||
|
||||
Returns:
|
||||
DatagridStore instance or None if not found.
|
||||
"""
|
||||
try:
|
||||
as_fullname_dict = self._registry._get_entries_as_full_name_dict()
|
||||
grid_id = as_fullname_dict.get(table_name)
|
||||
if grid_id is None:
|
||||
return None
|
||||
datagrid = InstancesManager.get(self._session, grid_id, None)
|
||||
if datagrid is None:
|
||||
return None
|
||||
return datagrid._df_store
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
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 get_formula_engine(self) -> FormulaEngine:
|
||||
"""The FormulaEngine shared across all DataGrids in this session."""
|
||||
return self._formula_engine
|
||||
|
||||
def add_style_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a style preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_highlight")
|
||||
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
|
||||
"""
|
||||
self.style_presets[name] = preset
|
||||
|
||||
def add_formatter_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a formatter preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_currency")
|
||||
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||
"""
|
||||
self.formatter_presets[name] = preset
|
||||
|
||||
def remove_style_preset(self, name: str):
|
||||
"""Remove a style preset."""
|
||||
if name in self.style_presets:
|
||||
del self.style_presets[name]
|
||||
|
||||
def remove_formatter_preset(self, name: str):
|
||||
"""Remove a formatter preset."""
|
||||
if name in self.formatter_presets:
|
||||
del self.formatter_presets[name]
|
||||
|
||||
# === UI ===
|
||||
|
||||
def mk_main_icons(self):
|
||||
return Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()),
|
||||
cls="flex"
|
||||
)
|
||||
|
||||
def _mk_tree(self):
|
||||
tree = TreeView(self, _id="-treeview")
|
||||
for element in self._state.elements:
|
||||
parent_id = tree.ensure_path(element.namespace)
|
||||
tree.add_node(TreeNode(id=element.document_id,
|
||||
label=element.name,
|
||||
type=element.type,
|
||||
parent=parent_id,
|
||||
bag=element.document_id))
|
||||
return tree
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid"),
|
||||
cls="flex"
|
||||
),
|
||||
self.tree,
|
||||
self._tree,
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,16 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def close(self):
|
||||
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
return Command("Close",
|
||||
"Close Dropdown",
|
||||
self._owner,
|
||||
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
def click(self):
|
||||
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
return Command("Click",
|
||||
"Click on Dropdown",
|
||||
self._owner,
|
||||
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
|
||||
class DropdownState:
|
||||
@@ -23,17 +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
|
||||
@@ -43,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":
|
||||
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';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
|
||||
222
src/myfasthtml/controls/DslEditor.py
Normal file
222
src/myfasthtml/controls/DslEditor.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
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
|
||||
engine_id: str = None # id of the DSL engine to use for autocompletion
|
||||
save_button: bool = True
|
||||
|
||||
|
||||
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 save_content(self):
|
||||
return Command("SaveContent",
|
||||
"Save content",
|
||||
self._owner,
|
||||
self._owner.save_content
|
||||
).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=self.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 = ""):
|
||||
"""Handler for content update from CodeMirror."""
|
||||
self._state.content = content
|
||||
logger.debug(f"Content updated: {len(content)} chars")
|
||||
|
||||
if self._state.auto_save:
|
||||
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
|
||||
|
||||
return None
|
||||
|
||||
def save_content(self):
|
||||
logger.debug("save_content")
|
||||
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
|
||||
|
||||
def toggle_auto_save(self):
|
||||
logger.debug("toggle_auto_save")
|
||||
self._state.auto_save = not self._state.auto_save
|
||||
logger.debug(f" auto_save={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."""
|
||||
# Get Simple Mode config if available
|
||||
simple_mode_config = None
|
||||
if hasattr(self._dsl, 'simple_mode_config'):
|
||||
simple_mode_config = self._dsl.simple_mode_config
|
||||
|
||||
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.conf.engine_id,
|
||||
"dsl": {
|
||||
"name": self._dsl.name,
|
||||
"completions": self._dsl.completions,
|
||||
"simpleModeConfig": simple_mode_config,
|
||||
},
|
||||
}
|
||||
return config
|
||||
|
||||
def _mk_textarea(self):
|
||||
"""Create the hidden textarea for form submission."""
|
||||
return Textarea(
|
||||
self._state.content,
|
||||
id=f"ta_{self._id}",
|
||||
name=self.conf.name if (self.conf and self.conf.name) else 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):
|
||||
if not self.conf.save_button:
|
||||
return None
|
||||
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.save_content()),
|
||||
cls="flex justify-between items-center p-2",
|
||||
id=f"as_{self._id}",
|
||||
),
|
||||
|
||||
def render(self):
|
||||
"""Render the DslEditor component."""
|
||||
return Div(
|
||||
self._mk_auto_save(),
|
||||
self._mk_textarea(),
|
||||
self._mk_editor_container(),
|
||||
self._mk_init_script(),
|
||||
id=self._id,
|
||||
cls="mf-dsl-editor-wrapper",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
@@ -25,14 +25,25 @@ class FileUploadState(DbObject):
|
||||
self.ns_sheets_names: list | None = None
|
||||
self.ns_selected_sheet_name: str | None = None
|
||||
self.ns_file_content: bytes | None = None
|
||||
self.ns_on_ok = None
|
||||
self.ns_on_cancel = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def upload_file(self):
|
||||
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
def on_file_uploaded(self):
|
||||
return Command("UploadFile",
|
||||
"Upload file",
|
||||
self._owner,
|
||||
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
def on_sheet_selected(self):
|
||||
return Command("SheetSelected",
|
||||
"Sheet selected",
|
||||
self._owner,
|
||||
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
@@ -49,16 +60,26 @@ class FileUpload(MultipleInstance):
|
||||
super().__init__(parent, _id=_id, **kwargs)
|
||||
self.commands = Commands(self)
|
||||
self._state = FileUploadState(self)
|
||||
self._state.ns_on_ok = None
|
||||
|
||||
def set_on_ok(self, callback):
|
||||
self._state.ns_on_ok = callback
|
||||
|
||||
def upload_file(self, file: UploadFile):
|
||||
logger.debug(f"upload_file: {file=}")
|
||||
if file:
|
||||
self._state.ns_file_content = file.file.read()
|
||||
self._state.ns_file_name = file.filename
|
||||
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
|
||||
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
||||
|
||||
return self.mk_sheet_selector()
|
||||
|
||||
def select_sheet(self, sheet_name: str):
|
||||
logger.debug(f"select_sheet: {sheet_name=}")
|
||||
self._state.ns_selected_sheet_name = sheet_name
|
||||
return self.mk_sheet_selector()
|
||||
|
||||
def mk_sheet_selector(self):
|
||||
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
|
||||
[Option(
|
||||
@@ -66,16 +87,27 @@ class FileUpload(MultipleInstance):
|
||||
selected=True if name == self._state.ns_selected_sheet_name else None,
|
||||
) for name in self._state.ns_sheets_names]
|
||||
|
||||
return Select(
|
||||
return mk.mk(Select(
|
||||
*options,
|
||||
name="sheet_name",
|
||||
id=f"sn_{self._id}", # sn stands for 'sheet name'
|
||||
cls="select select-bordered select-sm w-full ml-2"
|
||||
)
|
||||
), command=self.commands.on_sheet_selected())
|
||||
|
||||
def get_content(self):
|
||||
return self._state.ns_file_content
|
||||
|
||||
def get_file_name(self):
|
||||
return self._state.ns_file_name
|
||||
|
||||
def get_file_basename(self):
|
||||
if self._state.ns_file_name is None:
|
||||
return None
|
||||
|
||||
return self._state.ns_file_name.split(".")[0]
|
||||
|
||||
def get_sheet_name(self):
|
||||
return self._state.ns_selected_sheet_name
|
||||
|
||||
@staticmethod
|
||||
def get_sheets_names(file_content):
|
||||
@@ -99,12 +131,12 @@ class FileUpload(MultipleInstance):
|
||||
hx_encoding='multipart/form-data',
|
||||
cls="file-input file-input-bordered file-input-sm w-full",
|
||||
),
|
||||
command=self.commands.upload_file()
|
||||
command=self.commands.on_file_uploaded()
|
||||
),
|
||||
self.mk_sheet_selector(),
|
||||
cls="flex"
|
||||
),
|
||||
mk.dialog_buttons(),
|
||||
mk.dialog_buttons(on_ok=self._state.ns_on_ok, on_cancel=self._state.ns_on_cancel),
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -3,7 +3,7 @@ from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
@@ -12,7 +12,8 @@ class InstancesDebugger(SingleInstance):
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
@@ -20,7 +21,9 @@ class InstancesDebugger(SingleInstance):
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
session, instance_id = event_data["nodes"][0].split("#")
|
||||
parts = event_data["nodes"][0].split("#")
|
||||
session = parts[0]
|
||||
instance_id = "#".join(parts[1:])
|
||||
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
@@ -36,7 +39,7 @@ class InstancesDebugger(SingleInstance):
|
||||
instances,
|
||||
id_getter=lambda x: x.get_full_id(),
|
||||
label_getter=lambda x: f"{x.get_id()}",
|
||||
parent_getter=lambda x: x.get_full_parent_id()
|
||||
parent_getter=lambda x: x.get_parent_full_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Keyboard(MultipleInstance):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
def add(self, sequence: str, command: Command):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ class LayoutState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
return Command("ToggleDrawer",
|
||||
f"Toggle {side} layout drawer",
|
||||
self._owner,
|
||||
self._owner.toggle_drawer,
|
||||
args=[side])
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
|
||||
"""
|
||||
@@ -50,12 +54,11 @@ class Commands(BaseCommands):
|
||||
Returns:
|
||||
Command: Command object for updating drawer width
|
||||
"""
|
||||
return Command(
|
||||
f"UpdateDrawerWidth_{side}",
|
||||
return Command(f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner,
|
||||
self._owner.update_drawer_width,
|
||||
side
|
||||
)
|
||||
args=[side])
|
||||
|
||||
|
||||
class Layout(SingleInstance):
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@@ -12,17 +12,189 @@ 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.
|
||||
|
||||
Supported base actions:
|
||||
- ``click`` - Left mouse click (detected globally)
|
||||
- ``right_click`` (or alias ``rclick``) - Right mouse click (detected on element only)
|
||||
- ``mousedown>mouseup`` - Left mouse press-and-release (captures data at both phases)
|
||||
- ``rmousedown>mouseup`` - Right mouse press-and-release
|
||||
|
||||
Modifiers can be combined with ``+``: ``ctrl+click``, ``shift+mousedown>mouseup``.
|
||||
Sequences use space separation: ``click right_click``, ``click mousedown>mouseup``.
|
||||
|
||||
For ``mousedown>mouseup`` actions with ``hx_vals="js:functionName()"``, the JS function
|
||||
is called at both mousedown and mouseup. Results are suffixed: ``key_mousedown`` and
|
||||
``key_mouseup`` in the server request.
|
||||
"""
|
||||
|
||||
VALID_ACTIONS = {
|
||||
'click', 'right_click', 'rclick',
|
||||
'mousedown>mouseup', 'rmousedown>mouseup'
|
||||
}
|
||||
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
def _validate_sequence(self, sequence: str):
|
||||
"""
|
||||
Validate a mouse event sequence string.
|
||||
|
||||
Checks that all elements in the sequence use valid action names and modifiers.
|
||||
|
||||
Args:
|
||||
sequence: Mouse event sequence string (e.g., "click", "ctrl+mousedown>mouseup")
|
||||
|
||||
Raises:
|
||||
ValueError: If any action or modifier is invalid.
|
||||
"""
|
||||
elements = sequence.strip().split()
|
||||
for element in elements:
|
||||
parts = element.split('+')
|
||||
# Last part should be the action, others are modifiers
|
||||
action = parts[-1].lower()
|
||||
modifiers = [p.lower() for p in parts[:-1]]
|
||||
|
||||
if action not in self.VALID_ACTIONS:
|
||||
raise ValueError(
|
||||
f"Invalid action '{action}' in sequence '{sequence}'. "
|
||||
f"Valid actions: {', '.join(sorted(self.VALID_ACTIONS))}"
|
||||
)
|
||||
|
||||
for mod in modifiers:
|
||||
if mod not in self.VALID_MODIFIERS:
|
||||
raise ValueError(
|
||||
f"Invalid modifier '{mod}' in sequence '{sequence}'. "
|
||||
f"Valid modifiers: {', '.join(sorted(self.VALID_MODIFIERS))}"
|
||||
)
|
||||
|
||||
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,
|
||||
on_move: str = None):
|
||||
"""
|
||||
Add a mouse combination with optional command and HTMX parameters.
|
||||
|
||||
Args:
|
||||
sequence: Mouse event sequence string. Supports:
|
||||
- Simple actions: ``"click"``, ``"right_click"``, ``"mousedown>mouseup"``
|
||||
- Modifiers: ``"ctrl+click"``, ``"shift+mousedown>mouseup"``
|
||||
- Sequences: ``"click right_click"``, ``"click mousedown>mouseup"``
|
||||
- Aliases: ``"rclick"`` for ``"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.
|
||||
For mousedown>mouseup actions, the JS function is called at both
|
||||
mousedown and mouseup, with results suffixed ``_mousedown`` and ``_mouseup``.
|
||||
on_move: Client-side JS function called on each animation frame during a drag,
|
||||
using ``"js:functionName()"`` format. Only valid with ``mousedown>mouseup``
|
||||
sequences. The function receives ``(event, combination, mousedown_result)``
|
||||
where ``mousedown_result`` is the raw result of ``hx_vals`` at mousedown,
|
||||
or ``None`` if ``hx_vals`` is not set. Return value is ignored.
|
||||
|
||||
Returns:
|
||||
self for method chaining
|
||||
|
||||
Raises:
|
||||
ValueError: If the sequence contains invalid actions or modifiers.
|
||||
"""
|
||||
self._validate_sequence(sequence)
|
||||
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,
|
||||
"on_move": on_move,
|
||||
}
|
||||
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}")
|
||||
|
||||
# Handle on_move - client-side function for real-time drag feedback
|
||||
on_move = combination_data.get("on_move")
|
||||
if on_move is not None:
|
||||
if isinstance(on_move, str) and on_move.startswith("js:"):
|
||||
func_name = on_move[3:].rstrip("()")
|
||||
params["on-move"] = func_name
|
||||
else:
|
||||
raise ValueError(f"on_move must be 'js:functionName()', got: {on_move!r}")
|
||||
|
||||
return params
|
||||
|
||||
def render(self):
|
||||
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||
str_combinations = {
|
||||
sequence: self._build_htmx_params(data)
|
||||
for sequence, data in self.combinations.items()
|
||||
}
|
||||
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -1,23 +1,81 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from typing import Literal, 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
|
||||
from myfasthtml.icons.fluent_p1 import more_horizontal20_regular
|
||||
from myfasthtml.icons.fluent_p2 import subtract20_regular
|
||||
|
||||
logger = logging.getLogger("Panel")
|
||||
|
||||
class PanelIds:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
return f"{self._owner.get_id()}_m"
|
||||
|
||||
@property
|
||||
def right(self):
|
||||
""" Right panel's content"""
|
||||
return f"{self._owner.get_id()}_cr"
|
||||
|
||||
@property
|
||||
def left(self):
|
||||
""" Left panel's content"""
|
||||
return f"{self._owner.get_id()}_cl"
|
||||
|
||||
def panel(self, side: Literal["left", "right"]):
|
||||
return f"{self._owner.get_id()}_pl" if side == "left" else f"{self._owner.get_id()}_pr"
|
||||
|
||||
def content(self, side: Literal["left", "right"]):
|
||||
return self.left if side == "left" else self.right
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelConf:
|
||||
left: bool = False
|
||||
right: bool = True
|
||||
left_title: str = "Left"
|
||||
right_title: str = "Right"
|
||||
show_left_title: bool = True
|
||||
show_right_title: bool = True
|
||||
show_display_left: bool = True
|
||||
show_display_right: bool = True
|
||||
|
||||
|
||||
class PanelState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.left_visible: bool = True
|
||||
self.right_visible: bool = True
|
||||
self.left_width: int = 250
|
||||
self.right_width: int = 250
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
self._owner,
|
||||
self._owner.set_side_visible,
|
||||
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
|
||||
def toggle_side(self, side: Literal["left", "right"]):
|
||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
self._owner,
|
||||
self._owner.toggle_side,
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
@@ -29,12 +87,11 @@ class Commands(BaseCommands):
|
||||
Returns:
|
||||
Command: Command object for updating panel's side width
|
||||
"""
|
||||
return Command(
|
||||
f"UpdatePanelSideWidth_{side}",
|
||||
return Command(f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner,
|
||||
self._owner.update_side_width,
|
||||
side
|
||||
)
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
@@ -47,19 +104,39 @@ 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)
|
||||
self._state = PanelState(self)
|
||||
self._main = None
|
||||
self._right = None
|
||||
self._left = None
|
||||
self._ids = PanelIds(self)
|
||||
|
||||
def get_ids(self):
|
||||
return self._ids
|
||||
|
||||
def update_side_width(self, side, width):
|
||||
pass
|
||||
logger.debug(f"update_side_width {side=} {width=}")
|
||||
if side == "left":
|
||||
self._state.left_width = width
|
||||
else:
|
||||
self._state.right_width = width
|
||||
|
||||
return self._mk_panel(side)
|
||||
|
||||
def set_side_visible(self, side, visible):
|
||||
if side == "left":
|
||||
self._state.left_visible = visible
|
||||
else:
|
||||
self._state.right_visible = visible
|
||||
|
||||
return self._mk_panel(side), self._mk_show_icon(side)
|
||||
|
||||
def toggle_side(self, side):
|
||||
pass
|
||||
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
|
||||
@@ -67,41 +144,139 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
return Div(self._right, id=self._ids.right)
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
return Div(self._left, id=self._ids.left)
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
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:
|
||||
return None
|
||||
|
||||
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
content = self._right if side == "right" else self._left
|
||||
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
|
||||
title = self.conf.left_title if side == "left" else self.conf.right_title
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
cls=f"mf-resizer mf-resizer-{side}",
|
||||
data_command_id=self.commands.update_side_width(side).id,
|
||||
data_side=side
|
||||
)
|
||||
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
hide_icon = mk.icon(
|
||||
subtract20_regular,
|
||||
size=20,
|
||||
command=self.commands.set_side_visible(side, False),
|
||||
cls="mf-panel-hide-icon"
|
||||
)
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
panel_cls = f"mf-panel-{side}"
|
||||
if not visible:
|
||||
panel_cls += " mf-hidden"
|
||||
if show_title:
|
||||
panel_cls += " mf-panel-with-title"
|
||||
|
||||
# Left panel: content then resizer (resizer on the right)
|
||||
# Right panel: resizer then content (resizer on the left)
|
||||
if show_title:
|
||||
header = Div(
|
||||
Div(title),
|
||||
hide_icon,
|
||||
cls="mf-panel-header"
|
||||
)
|
||||
body = Div(
|
||||
header,
|
||||
Div(content, id=self._ids.content(side), cls="mf-panel-content"),
|
||||
cls="mf-panel-body"
|
||||
)
|
||||
if side == "left":
|
||||
return Div(
|
||||
body,
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
body,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.right_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
if side == "left":
|
||||
return Div(
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
def _mk_main(self):
|
||||
return Div(
|
||||
self._mk_show_icon("left"),
|
||||
Div(self._main, id=self._ids.main, cls="mf-panel-main"),
|
||||
self._mk_show_icon("right"),
|
||||
cls="mf-panel-main"
|
||||
),
|
||||
|
||||
def _mk_show_icon(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create show icon for a panel side if it's hidden.
|
||||
|
||||
Args:
|
||||
side: Which panel side ("left" or "right")
|
||||
|
||||
Returns:
|
||||
Div with icon if panel is hidden, None otherwise
|
||||
"""
|
||||
enabled = self.conf.left if side == "left" else self.conf.right
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
|
||||
if not show_display:
|
||||
return None
|
||||
|
||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
||||
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||
|
||||
return mk.icon(
|
||||
more_horizontal20_regular,
|
||||
command=self.commands.set_side_visible(side, True),
|
||||
cls=icon_cls,
|
||||
id=f"{self._id}_show_{side}"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_left(),
|
||||
Div(self._main, cls="mf-panel-main"),
|
||||
self._mk_right(),
|
||||
self._mk_panel("left"),
|
||||
self._mk_main(),
|
||||
self._mk_panel("right"),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
cls="mf-panel",
|
||||
id=self._id,
|
||||
|
||||
@@ -16,21 +16,38 @@ class Properties(MultipleInstance):
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key", data_tooltip=f"{k}"),
|
||||
self._mk_property_value(v),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in properties.items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
Div(
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key"),
|
||||
Div(str(v), cls="mf-properties-value", title=str(v)),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in proxy.as_dict().items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
|
||||
@@ -14,8 +14,10 @@ logger = logging.getLogger("Search")
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
|
||||
htmx(target=f"#{self._owner.get_id()}-results",
|
||||
return (Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
|
||||
@@ -36,13 +38,15 @@ class Search(MultipleInstance):
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
items_names=None, # what is the name of the items to filter
|
||||
items=None, # first set of items to filter
|
||||
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
||||
template: Callable[[Any], Any] = None): # once filtered, what to render ?
|
||||
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.
|
||||
|
||||
@@ -61,14 +65,21 @@ 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
|
||||
self.filtered = self.items.copy()
|
||||
return self
|
||||
|
||||
def get_items(self):
|
||||
return self.items
|
||||
|
||||
def get_filtered(self):
|
||||
return self.filtered
|
||||
|
||||
def on_search(self, query):
|
||||
logger.debug(f"on_search {query=}")
|
||||
self.search(query)
|
||||
@@ -97,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}",
|
||||
)
|
||||
|
||||
@@ -52,32 +52,47 @@ class TabsManagerState(DbObject):
|
||||
self.active_tab: str | None = None
|
||||
|
||||
# must not be persisted in DB
|
||||
self._tabs_content: dict[str, Any] = {}
|
||||
self.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped)
|
||||
self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
return Command(f"ShowTab",
|
||||
"Activate or show a specific tab",
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
self._owner,
|
||||
self._owner.show_tab,
|
||||
args=[tab_id,
|
||||
True,
|
||||
False],
|
||||
key=f"{self._owner.get_full_id()}-ShowTab-{tab_id}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
def close_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}CloseTab",
|
||||
return Command(f"CloseTab",
|
||||
"Close a specific tab",
|
||||
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
self._owner,
|
||||
self._owner.close_tab,
|
||||
kwargs={"tab_id": tab_id},
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
def add_tab(self, label: str, component: Any, auto_increment=False):
|
||||
return (Command(f"{self._prefix}AddTab",
|
||||
return Command(f"AddTab",
|
||||
"Add a new tab",
|
||||
self._owner.on_new_tab, label, component, auto_increment).
|
||||
htmx(target=f"#{self._id}-controller"))
|
||||
self._owner,
|
||||
self._owner.on_new_tab,
|
||||
args=[label,
|
||||
component,
|
||||
auto_increment],
|
||||
key="#{id-name-args}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._tab_count = 0
|
||||
self._state = TabsManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
self._boundaries = Boundaries()
|
||||
@@ -86,6 +101,7 @@ class TabsManager(MultipleInstance):
|
||||
get_attr=lambda x: x["label"],
|
||||
template=self._mk_tab_button,
|
||||
_id="-search")
|
||||
|
||||
logger.debug(f"TabsManager created with id: {self._id}")
|
||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||
logger.debug(f" active tab : {self._state.active_tab}")
|
||||
@@ -96,22 +112,64 @@ class TabsManager(MultipleInstance):
|
||||
def _get_ordered_tabs(self):
|
||||
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
|
||||
|
||||
def _get_tab_content(self, tab_id):
|
||||
def _dynamic_get_content(self, tab_id):
|
||||
if tab_id not in self._state.tabs:
|
||||
return None
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component_type"] is None:
|
||||
return None
|
||||
try:
|
||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error while retrieving tab content: {e}")
|
||||
return Div("Tab not found.")
|
||||
|
||||
@staticmethod
|
||||
def _get_tab_count():
|
||||
res = TabsManager._tab_count
|
||||
TabsManager._tab_count += 1
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component"] is None:
|
||||
return Div("Tab content does not support serialization.")
|
||||
|
||||
# 1. Try to get existing component instance
|
||||
res = InstancesManager.get(self._session, tab_config["component"][1], None)
|
||||
if res is not None:
|
||||
logger.debug(f"Component {tab_config['component'][1]} already exists")
|
||||
return res
|
||||
|
||||
# 2. Get or create parent
|
||||
if tab_config["component_parent"] is None:
|
||||
logger.error(f"No parent defined for tab {tab_id}")
|
||||
return Div("Failed to retrieve tab content (no parent).")
|
||||
|
||||
parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None)
|
||||
if parent is None:
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} not found for tab {tab_id}")
|
||||
return Div("Parent component not available")
|
||||
|
||||
# 3. If parent supports create_tab_content, use it
|
||||
if hasattr(parent, 'create_tab_content'):
|
||||
try:
|
||||
logger.debug(f"Asking parent {tab_config['component_parent'][1]} to create tab content for {tab_id}")
|
||||
content = parent.create_tab_content(tab_id)
|
||||
# Store in cache
|
||||
self._state.ns_tabs_content[tab_id] = content
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.error(f"Error while parent creating tab content: {e}")
|
||||
return Div("Failed to retrieve tab content (cannot create).")
|
||||
else:
|
||||
# Parent doesn't support create_tab_content, fallback to error
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} doesn't support create_tab_content")
|
||||
return Div("Failed to retrieve tab content (create tab not supported).")
|
||||
|
||||
def _get_or_create_tab_content(self, tab_id):
|
||||
"""
|
||||
Get tab content from cache or create it.
|
||||
This method ensures content is always stored in raw form (not wrapped).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab
|
||||
|
||||
Returns:
|
||||
Raw content component (not wrapped in Div)
|
||||
"""
|
||||
if tab_id not in self._state.ns_tabs_content:
|
||||
self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id)
|
||||
return self._state.ns_tabs_content[tab_id]
|
||||
|
||||
def _get_tab_count(self):
|
||||
res = self._tab_count
|
||||
self._tab_count += 1
|
||||
return res
|
||||
|
||||
def on_new_tab(self, label: str, component: Any, auto_increment=False):
|
||||
@@ -120,20 +178,20 @@ class TabsManager(MultipleInstance):
|
||||
label = f"{label}_{self._get_tab_count()}"
|
||||
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
|
||||
|
||||
tab_id = self._tab_already_exists(label, component)
|
||||
if tab_id:
|
||||
return self.show_tab(tab_id)
|
||||
tab_id = self.create_tab(label, component)
|
||||
return self.show_tab(tab_id, oob=False)
|
||||
|
||||
tab_id = self.add_tab(label, component)
|
||||
return (
|
||||
self._mk_tabs_controller(),
|
||||
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
|
||||
self._mk_tabs_header_wrapper(True),
|
||||
)
|
||||
def show_or_create_tab(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"show_or_create_tab {tab_id=}, {label=}, {component=}, {activate=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
|
||||
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
return self.show_tab(tab_id, activate=activate, oob=True)
|
||||
|
||||
def create_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
"""
|
||||
Add a new tab or update an existing one with the same component type, ID and label.
|
||||
The tab is not yet sent to the client.
|
||||
|
||||
Args:
|
||||
label: Display label for the tab
|
||||
@@ -144,73 +202,55 @@ class TabsManager(MultipleInstance):
|
||||
tab_id: The UUID of the tab (new or existing)
|
||||
"""
|
||||
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
|
||||
# copy the state to avoid multiple database call
|
||||
state = self._state.copy()
|
||||
|
||||
# Extract component ID if the component has a get_id() method
|
||||
component_type, component_id = None, None
|
||||
if isinstance(component, BaseInstance):
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_id = component.get_id()
|
||||
|
||||
# Check if a tab with the same component_type, component_id AND label already exists
|
||||
existing_tab_id = self._tab_already_exists(label, component)
|
||||
|
||||
if existing_tab_id:
|
||||
# Update existing tab (only the component instance in memory)
|
||||
tab_id = existing_tab_id
|
||||
state._tabs_content[tab_id] = component
|
||||
else:
|
||||
# Create new tab
|
||||
tab_id = str(uuid.uuid4())
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# Add tab to order
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Store component in memory
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
state.active_tab = tab_id
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4())
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
return tab_id
|
||||
|
||||
def show_tab(self, tab_id):
|
||||
def show_tab(self, tab_id, activate: bool = True, oob=True, is_new=True):
|
||||
"""
|
||||
Send the tab to the client if needed.
|
||||
If the tab was already sent, just update the active tab.
|
||||
:param tab_id:
|
||||
:param activate:
|
||||
:param oob: default=True so other control will not care of the target
|
||||
:param is_new: is it a new tab or an existing one?
|
||||
:return:
|
||||
"""
|
||||
logger.debug(f"show_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return None
|
||||
|
||||
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
|
||||
|
||||
if activate:
|
||||
self._state.active_tab = tab_id
|
||||
|
||||
if tab_id not in self._state._tabs_content:
|
||||
logger.debug(f" Content does not exist. Creating it.")
|
||||
content = self._get_tab_content(tab_id)
|
||||
tab_content = self._mk_tab_content(tab_id, content)
|
||||
self._state._tabs_content[tab_id] = tab_content
|
||||
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
|
||||
else:
|
||||
logger.debug(f" Content already exists. Just switch.")
|
||||
return self._mk_tabs_controller()
|
||||
# Get or create content (always stored in raw form)
|
||||
content = self._get_or_create_tab_content(tab_id)
|
||||
|
||||
def switch_tab(self, tab_id, label, component, activate=True):
|
||||
if tab_id not in self._state.ns_tabs_sent_to_client:
|
||||
logger.debug(f" Content not in client memory. Sending it.")
|
||||
self._state.ns_tabs_sent_to_client.add(tab_id)
|
||||
tab_content = self._mk_tab_content(tab_id, content)
|
||||
return (self._mk_tabs_controller(oob),
|
||||
self._mk_tabs_header_wrapper(oob),
|
||||
self._wrap_tab_content(tab_content, is_new))
|
||||
else:
|
||||
logger.debug(f" Content already in client memory. Just switch.")
|
||||
return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date
|
||||
|
||||
def change_tab_content(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
|
||||
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.error(f" Tab {tab_id} not found. Cannot change its content.")
|
||||
return None
|
||||
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
return self.show_tab(tab_id) #
|
||||
self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client
|
||||
return self.show_tab(tab_id, activate=activate, oob=True, is_new=False)
|
||||
|
||||
def close_tab(self, tab_id: str):
|
||||
"""
|
||||
@@ -220,10 +260,12 @@ class TabsManager(MultipleInstance):
|
||||
tab_id: ID of the tab to close
|
||||
|
||||
Returns:
|
||||
Self for chaining
|
||||
tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping,
|
||||
or self if tab not found
|
||||
"""
|
||||
logger.debug(f"close_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return self
|
||||
|
||||
# Copy state
|
||||
@@ -234,8 +276,12 @@ class TabsManager(MultipleInstance):
|
||||
state.tabs_order.remove(tab_id)
|
||||
|
||||
# Remove from content
|
||||
if tab_id in state._tabs_content:
|
||||
del state._tabs_content[tab_id]
|
||||
if tab_id in state.ns_tabs_content:
|
||||
del state.ns_tabs_content[tab_id]
|
||||
|
||||
# Remove from content sent
|
||||
if tab_id in state.ns_tabs_sent_to_client:
|
||||
state.ns_tabs_sent_to_client.remove(tab_id)
|
||||
|
||||
# If closing active tab, activate another one
|
||||
if state.active_tab == tab_id:
|
||||
@@ -249,7 +295,8 @@ class TabsManager(MultipleInstance):
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
return self
|
||||
content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete")
|
||||
return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove
|
||||
|
||||
def add_tab_btn(self):
|
||||
return mk.icon(tab_add24_regular,
|
||||
@@ -259,10 +306,11 @@ class TabsManager(MultipleInstance):
|
||||
None,
|
||||
True))
|
||||
|
||||
def _mk_tabs_controller(self):
|
||||
return Div(
|
||||
Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"),
|
||||
Script(f'updateTabs("{self._id}-controller");'),
|
||||
def _mk_tabs_controller(self, oob=False):
|
||||
return Div(id=f"{self._id}-controller",
|
||||
data_active_tab=f"{self._state.active_tab}",
|
||||
hx_on__after_settle=f'updateTabs("{self._id}-controller");',
|
||||
hx_swap_oob="true" if oob else None,
|
||||
)
|
||||
|
||||
def _mk_tabs_header_wrapper(self, oob=False):
|
||||
@@ -273,24 +321,20 @@ class TabsManager(MultipleInstance):
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
header_content = [*visible_tab_buttons]
|
||||
|
||||
return Div(
|
||||
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
Div(*visible_tab_buttons, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
self._mk_show_tabs_menu(),
|
||||
id=f"{self._id}-header-wrapper",
|
||||
cls="mf-tabs-header-wrapper",
|
||||
hx_swap_oob="true" if oob else None
|
||||
)
|
||||
|
||||
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
|
||||
def _mk_tab_button(self, tab_data: dict):
|
||||
"""
|
||||
Create a single tab button with its label and close button.
|
||||
|
||||
Args:
|
||||
tab_id: Unique identifier for the tab
|
||||
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
||||
in_dropdown: Whether this tab is rendered in the dropdown menu
|
||||
|
||||
Returns:
|
||||
Button element representing the tab
|
||||
@@ -308,12 +352,10 @@ class TabsManager(MultipleInstance):
|
||||
command=self.commands.show_tab(tab_id)
|
||||
)
|
||||
|
||||
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
|
||||
|
||||
return Div(
|
||||
tab_label,
|
||||
close_btn,
|
||||
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
|
||||
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
@@ -325,15 +367,9 @@ class TabsManager(MultipleInstance):
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
|
||||
if self._state.active_tab:
|
||||
active_tab = self._state.active_tab
|
||||
if active_tab in self._state._tabs_content:
|
||||
tab_content = self._state._tabs_content[active_tab]
|
||||
else:
|
||||
content = self._get_tab_content(active_tab)
|
||||
tab_content = self._mk_tab_content(active_tab, content)
|
||||
self._state._tabs_content[active_tab] = tab_content
|
||||
content = self._get_or_create_tab_content(self._state.active_tab)
|
||||
tab_content = self._mk_tab_content(self._state.active_tab, content)
|
||||
else:
|
||||
tab_content = self._mk_tab_content(None, None)
|
||||
|
||||
@@ -344,10 +380,13 @@ class TabsManager(MultipleInstance):
|
||||
)
|
||||
|
||||
def _mk_tab_content(self, tab_id: str, content):
|
||||
if tab_id is None:
|
||||
return Div("No Content", cls="mf-empty-content mf-tab-content hidden")
|
||||
|
||||
is_active = tab_id == self._state.active_tab
|
||||
return Div(
|
||||
content if content else Div("No Content", cls="mf-empty-content"),
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}",
|
||||
id=f"{self._id}-{tab_id}-content",
|
||||
)
|
||||
|
||||
@@ -366,23 +405,26 @@ class TabsManager(MultipleInstance):
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def _wrap_tab_content(self, tab_content):
|
||||
def _wrap_tab_content(self, tab_content, is_new=True):
|
||||
if is_new:
|
||||
return Div(
|
||||
tab_content,
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper"
|
||||
)
|
||||
else:
|
||||
tab_content.attrs["hx-swap-oob"] = "outerHTML"
|
||||
return tab_content
|
||||
|
||||
def _tab_already_exists(self, label, component):
|
||||
if not isinstance(component, BaseInstance):
|
||||
return None
|
||||
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_type = component.get_prefix()
|
||||
component_id = component.get_id()
|
||||
|
||||
if component_id is not None:
|
||||
for tab_id, tab_data in self._state.tabs.items():
|
||||
if (tab_data.get('component_type') == component_type and
|
||||
tab_data.get('component_id') == component_id and
|
||||
if (tab_data.get('component') == (component_type, component_id) and
|
||||
tab_data.get('label') == label):
|
||||
return tab_id
|
||||
|
||||
@@ -396,20 +438,29 @@ class TabsManager(MultipleInstance):
|
||||
|
||||
# Extract component ID if the component has a get_id() method
|
||||
component_type, component_id = None, None
|
||||
parent_type, parent_id = None, None
|
||||
if isinstance(component, BaseInstance):
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_type = component.get_prefix()
|
||||
component_id = component.get_id()
|
||||
parent = component.get_parent()
|
||||
if parent:
|
||||
parent_type = parent.get_prefix()
|
||||
parent_id = parent.get_id()
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
'component': (component_type, component_id) if component_type else None,
|
||||
'component_parent': (parent_type, parent_id) if parent_type else None
|
||||
}
|
||||
|
||||
# Add tab to order list
|
||||
if tab_id not in state.tabs_order:
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Add the content
|
||||
state._tabs_content[tab_id] = component
|
||||
state.ns_tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
@@ -433,6 +484,7 @@ class TabsManager(MultipleInstance):
|
||||
self._mk_tabs_controller(),
|
||||
self._mk_tabs_header_wrapper(),
|
||||
self._mk_tab_content_wrapper(),
|
||||
Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from fasthtml.components import Div, Input, Span
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import Command, CommandTemplate
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
|
||||
@@ -37,6 +37,7 @@ class TreeNode:
|
||||
type: str = "default"
|
||||
parent: Optional[str] = None
|
||||
children: list[str] = field(default_factory=list)
|
||||
bag: Optional[dict] = None # to keep extra info
|
||||
|
||||
|
||||
class TreeViewState(DbObject):
|
||||
@@ -66,73 +67,81 @@ class Commands(BaseCommands):
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Create command to expand/collapse a node."""
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
return Command("ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner,
|
||||
self._owner._toggle_node,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-ToggleNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_child(self, parent_id: str):
|
||||
"""Create command to add a child node."""
|
||||
return Command(
|
||||
"AddChild",
|
||||
return Command("AddChild",
|
||||
f"Add child to {parent_id}",
|
||||
self._owner,
|
||||
self._owner._add_child,
|
||||
parent_id
|
||||
kwargs={"parent_id": parent_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddChild"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_sibling(self, node_id: str):
|
||||
"""Create command to add a sibling node."""
|
||||
return Command(
|
||||
"AddSibling",
|
||||
return Command("AddSibling",
|
||||
f"Add sibling to {node_id}",
|
||||
self._owner,
|
||||
self._owner._add_sibling,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddSibling"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def start_rename(self, node_id: str):
|
||||
"""Create command to start renaming a node."""
|
||||
return Command(
|
||||
"StartRename",
|
||||
return Command("StartRename",
|
||||
f"Start renaming {node_id}",
|
||||
self._owner,
|
||||
self._owner._start_rename,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-StartRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def save_rename(self, node_id: str):
|
||||
"""Create command to save renamed node."""
|
||||
return Command(
|
||||
"SaveRename",
|
||||
return Command("SaveRename",
|
||||
f"Save rename for {node_id}",
|
||||
self._owner,
|
||||
self._owner._save_rename,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def cancel_rename(self):
|
||||
"""Create command to cancel renaming."""
|
||||
return Command(
|
||||
"CancelRename",
|
||||
return Command("CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner._cancel_rename
|
||||
self._owner,
|
||||
self._owner._cancel_rename,
|
||||
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def delete_node(self, node_id: str):
|
||||
"""Create command to delete a node."""
|
||||
return Command(
|
||||
"DeleteNode",
|
||||
return Command("DeleteNode",
|
||||
f"Delete node {node_id}",
|
||||
self._owner,
|
||||
self._owner._delete_node,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def select_node(self, node_id: str):
|
||||
"""Create command to select a node."""
|
||||
return Command(
|
||||
"SelectNode",
|
||||
return Command("SelectNode",
|
||||
f"Select node {node_id}",
|
||||
self._owner,
|
||||
self._owner._select_node,
|
||||
node_id
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
@@ -185,6 +194,9 @@ class TreeView(MultipleInstance):
|
||||
If None, appends to end. If provided, inserts at that position.
|
||||
"""
|
||||
self._state.items[node.id] = node
|
||||
if parent_id is None and node.parent is not None:
|
||||
parent_id = node.parent
|
||||
|
||||
node.parent = parent_id
|
||||
|
||||
if parent_id and parent_id in self._state.items:
|
||||
@@ -195,12 +207,72 @@ class TreeView(MultipleInstance):
|
||||
else:
|
||||
parent.children.append(node.id)
|
||||
|
||||
def ensure_path(self, path: str):
|
||||
"""Add a node to the tree based on a path string.
|
||||
|
||||
Args:
|
||||
path: Dot-separated path string (e.g., "folder1.folder2.file")
|
||||
|
||||
Raises:
|
||||
ValueError: If path contains empty parts after stripping
|
||||
"""
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid path: path is None")
|
||||
|
||||
path = path.strip().strip(".")
|
||||
if path == "":
|
||||
raise ValueError(f"Invalid path: path is empty")
|
||||
|
||||
parent_id = None
|
||||
current_nodes = [node for node in self._state.items.values() if node.parent is None]
|
||||
|
||||
path_parts = path.split(".")
|
||||
for part in path_parts:
|
||||
part = part.strip()
|
||||
|
||||
# Validate that part is not empty after stripping
|
||||
if part == "":
|
||||
raise ValueError(f"Invalid path: path contains empty parts")
|
||||
|
||||
node = [node for node in current_nodes if node.label == part]
|
||||
if len(node) == 0:
|
||||
# create the node
|
||||
node = TreeNode(label=part, type="folder")
|
||||
self.add_node(node, parent_id=parent_id)
|
||||
else:
|
||||
node = node[0]
|
||||
|
||||
current_nodes = [self._state.items[node_id] for node_id in node.children]
|
||||
parent_id = node.id
|
||||
|
||||
return parent_id
|
||||
|
||||
def get_selected_id(self):
|
||||
if self._state.selected is None:
|
||||
return None
|
||||
return self._state.items[self._state.selected].id
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all nodes that have children."""
|
||||
for node_id, node in self._state.items.items():
|
||||
if node.children and node_id not in self._state.opened:
|
||||
self._state.opened.append(node_id)
|
||||
|
||||
def clear(self):
|
||||
state = self._state.copy()
|
||||
state.items = {}
|
||||
state.opened = []
|
||||
state.selected = None
|
||||
state.editing = None
|
||||
self._state.update(state)
|
||||
return self
|
||||
|
||||
def get_bag(self, node_id: str):
|
||||
try:
|
||||
return self._state.items[node_id].bag
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
@@ -343,7 +415,7 @@ class TreeView(MultipleInstance):
|
||||
name="node_label",
|
||||
value=node.label,
|
||||
cls="mf-treenode-input input input-sm"
|
||||
), command=self.commands.save_rename(node_id))
|
||||
), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
|
||||
else:
|
||||
label_element = mk.mk(
|
||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
||||
|
||||
@@ -33,7 +33,10 @@ class UserProfileState:
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def update_dark_mode(self):
|
||||
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
|
||||
return Command("UpdateDarkMode",
|
||||
"Set the dark mode",
|
||||
self._owner,
|
||||
self._owner.update_dark_mode).htmx(target=None)
|
||||
|
||||
|
||||
class UserProfile(SingleInstance):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
|
||||
from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -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,13 @@ class DataGridColumnState:
|
||||
title: str = None
|
||||
type: ColumnType = ColumnType.Text
|
||||
visible: bool = True
|
||||
usable: bool = True
|
||||
width: int = DEFAULT_COLUMN_WIDTH
|
||||
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
|
||||
format: list = field(default_factory=list) #
|
||||
formula: str = "" # formula expression for ColumnType.Formula columns
|
||||
|
||||
def copy(self):
|
||||
props = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||
return DataGridColumnState(**props)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,10 +35,15 @@ class DatagridEditionState:
|
||||
|
||||
@dataclass
|
||||
class DatagridSelectionState:
|
||||
selected: tuple[int, int] | None = None
|
||||
"""
|
||||
element_id: str
|
||||
"tcell_grid_id_col_row" for cell
|
||||
(min_col, min_row, max_col, max_row) for range
|
||||
"""
|
||||
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))
|
||||
selection_mode: str = None # valid values are "row", "column", "range" or None for "cell"
|
||||
extra_selected: list[tuple[str, str | int | tuple]] = field(default_factory=list) # (selection_mode, element_id)
|
||||
last_extra_selected: tuple[int, int] = None
|
||||
|
||||
|
||||
@@ -40,8 +52,6 @@ class DataGridHeaderFooterConf:
|
||||
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatagridView:
|
||||
name: str
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import pandas as pd
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.core.commands import Command
|
||||
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, math_formula16_regular
|
||||
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:
|
||||
@@ -14,7 +22,7 @@ class Ids:
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
def button(element, command: Command | CommandTemplate = None, binding: Binding = None, **kwargs):
|
||||
"""
|
||||
Defines a static method for creating a Button object with specific configurations.
|
||||
|
||||
@@ -33,7 +41,7 @@ class mk:
|
||||
@staticmethod
|
||||
def dialog_buttons(ok_title: str = "OK",
|
||||
cancel_title: str = "Cancel",
|
||||
on_ok: Command = None,
|
||||
on_ok: Command | CommandTemplate = None,
|
||||
on_cancel: Command = None,
|
||||
cls=None):
|
||||
return Div(
|
||||
@@ -52,7 +60,7 @@ class mk:
|
||||
can_hover=False,
|
||||
tooltip=None,
|
||||
cls='',
|
||||
command: Command = None,
|
||||
command: Command | CommandTemplate = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
@@ -78,6 +86,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)
|
||||
|
||||
@@ -92,12 +101,12 @@ class mk:
|
||||
icon=None,
|
||||
size: str = "sm",
|
||||
cls='',
|
||||
command: Command = None,
|
||||
command: Command | CommandTemplate = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex", cls, kwargs)
|
||||
merged_cls = merge_classes("flex truncate items-center pr-2", "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}")
|
||||
text_part = Span(text, cls=f"text-{size} truncate")
|
||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
@@ -109,7 +118,10 @@ class mk:
|
||||
replace("xl", "32"))
|
||||
|
||||
@staticmethod
|
||||
def manage_command(ft, command: Command):
|
||||
def manage_command(ft, command: Command | CommandTemplate):
|
||||
if isinstance(command, CommandTemplate):
|
||||
command = command.command
|
||||
|
||||
if command:
|
||||
ft = command.bind_ft(ft)
|
||||
|
||||
@@ -130,7 +142,32 @@ class mk:
|
||||
return ft
|
||||
|
||||
@staticmethod
|
||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||
def mk(ft, command: Command | CommandTemplate = None, binding: Binding = None, init_binding=True):
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
return ft
|
||||
|
||||
|
||||
icons = {
|
||||
None: question20_regular,
|
||||
True: checkbox_checked20_regular,
|
||||
False: checkbox_unchecked20_regular,
|
||||
|
||||
"Brain": brain_circuit20_regular,
|
||||
|
||||
ColumnType.RowIndex: number_symbol20_regular,
|
||||
ColumnType.Text: text_field20_regular,
|
||||
ColumnType.Number: number_row20_regular,
|
||||
ColumnType.Datetime: calendar_ltr20_regular,
|
||||
ColumnType.Bool: checkbox_checked20_filled,
|
||||
ColumnType.Enum: text_bullet_list_square20_regular,
|
||||
ColumnType.Formula: math_formula16_regular,
|
||||
}
|
||||
|
||||
column_type_defaults = {
|
||||
ColumnType.Number: 0,
|
||||
ColumnType.Text: "",
|
||||
ColumnType.Bool: False,
|
||||
ColumnType.Datetime: pd.NaT,
|
||||
}
|
||||
|
||||
107
src/myfasthtml/core/DataGridsRegistry.py
Normal file
107
src/myfasthtml/core/DataGridsRegistry.py
Normal file
@@ -0,0 +1,107 @@
|
||||
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 from dedicated store
|
||||
store = self._db_manager.load(f"{grid_id}#df")
|
||||
df = store["ne_df"] if store 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 from dedicated store
|
||||
store = self._db_manager.load(f"{grid_id}#df")
|
||||
df = store["ne_df"] if store else None
|
||||
return len(df) if df is not None else 0
|
||||
|
||||
except KeyError:
|
||||
return 0
|
||||
|
||||
def get_column_type(self, table_name, column_name):
|
||||
"""
|
||||
Get the type of a column.
|
||||
|
||||
Args:
|
||||
table_name: The DataGrid name
|
||||
column_name: The column name
|
||||
|
||||
Returns:
|
||||
ColumnType enum value or None if not found
|
||||
"""
|
||||
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)
|
||||
|
||||
if state and "columns" in state:
|
||||
for col in state["columns"]:
|
||||
if col.col_id == column_name:
|
||||
return col.type
|
||||
return None
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _get_all_entries(self):
|
||||
return {k: v for k, v in self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY).items()
|
||||
if not k.startswith("__")}
|
||||
|
||||
def _get_entries_as_id_dict(self):
|
||||
all_entries = self._get_all_entries()
|
||||
return {_id: f"{namespace}.{name}" for _id, (namespace, name) in all_entries.items()}
|
||||
|
||||
def _get_entries_as_full_name_dict(self):
|
||||
all_entries = self._get_all_entries()
|
||||
return {f"{namespace}.{name}": _id for _id, (namespace, name) in all_entries.items()}
|
||||
@@ -1,14 +1,21 @@
|
||||
import html
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
|
||||
from myutils.observable import NotObservableError, ObservableResultCollector
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.utils import flatten
|
||||
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
AUTO_SWAP_OOB = "__auto_swap_oob__"
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
class Command:
|
||||
"""
|
||||
Represents the base command class for defining executable actions.
|
||||
|
||||
@@ -25,28 +32,129 @@ class BaseCommand:
|
||||
:type description: str
|
||||
"""
|
||||
|
||||
def __init__(self, name, description):
|
||||
@staticmethod
|
||||
def process_key(key, name, owner, args, kwargs):
|
||||
def _compute_from_args():
|
||||
res = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "get_full_id"):
|
||||
res.append(arg.get_full_id())
|
||||
else:
|
||||
res.append(str(arg))
|
||||
return "-".join(res)
|
||||
|
||||
# special management when kwargs are provided
|
||||
# In this situation,
|
||||
# either there is no parameter (so one single instance of the command is enough)
|
||||
# or the parameter is a kwargs (so the parameters are provided when the command is called)
|
||||
if key is None:
|
||||
key_parts = []
|
||||
if owner is not None:
|
||||
key_parts.append(f"{owner.get_full_id()}")
|
||||
key_parts.append(name)
|
||||
if args:
|
||||
key_parts.append(_compute_from_args())
|
||||
key = "-".join(key_parts)
|
||||
else:
|
||||
key = key.replace("#{args}", _compute_from_args())
|
||||
if owner is not None:
|
||||
key = key.replace("#{id}", owner.get_full_id())
|
||||
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
|
||||
|
||||
return key
|
||||
|
||||
def __init__(self, name,
|
||||
description,
|
||||
owner=None,
|
||||
callback=None,
|
||||
args: list = None,
|
||||
kwargs: dict = None,
|
||||
key=None,
|
||||
auto_register=True):
|
||||
self.id = uuid.uuid4()
|
||||
self.name = name
|
||||
self.description = description
|
||||
self._htmx_extra = {}
|
||||
self.owner = owner
|
||||
self.callback = callback
|
||||
self.default_args = args or []
|
||||
self.default_kwargs = kwargs or {}
|
||||
self._htmx_extra = {AUTO_SWAP_OOB: True}
|
||||
self._bindings = []
|
||||
self._ft = None
|
||||
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||
self._key = self.process_key(key, self.name, self.owner, self.default_args, self.default_kwargs)
|
||||
|
||||
# register the command
|
||||
if auto_register:
|
||||
if self._key is not None:
|
||||
if self._key in CommandsManager.commands_by_key:
|
||||
logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
|
||||
self.id = CommandsManager.commands_by_key[self._key].id
|
||||
else:
|
||||
logger.debug(f"Command {self.name} with key={self._key} will be registered.")
|
||||
CommandsManager.register(self)
|
||||
else:
|
||||
logger.warning(f"Command {self.name} has no key, it will not be registered.")
|
||||
|
||||
def get_htmx_params(self):
|
||||
return {
|
||||
def get_key(self):
|
||||
return self._key
|
||||
|
||||
def get_htmx_params(self, escaped=False, values_encode=None):
|
||||
res = {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": {"c_id": f"{self.id}"},
|
||||
} | self._htmx_extra
|
||||
}
|
||||
|
||||
for k, v in self._htmx_extra.items():
|
||||
if k == "hx-post":
|
||||
continue # cannot override this one
|
||||
elif k == "hx-vals":
|
||||
res["hx-vals"] |= v
|
||||
else:
|
||||
res[k] = v
|
||||
|
||||
# kwarg are given to the callback as values
|
||||
res["hx-vals"] |= self.default_kwargs
|
||||
|
||||
if escaped:
|
||||
res["hx-vals"] = html.escape(json.dumps(res["hx-vals"]))
|
||||
|
||||
if values_encode == "json":
|
||||
res["hx-vals"] = json.dumps(res["hx-vals"])
|
||||
|
||||
return res
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
raise NotImplementedError
|
||||
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
|
||||
with ObservableResultCollector(self._bindings) as collector:
|
||||
kwargs = self._create_kwargs(self.default_kwargs,
|
||||
client_response,
|
||||
{"client_response": client_response or {}})
|
||||
ret = self.callback(*self.default_args, **kwargs)
|
||||
|
||||
ret_from_bound_commands = []
|
||||
if self.owner:
|
||||
for command in self.owner.get_bound_commands(self.name):
|
||||
logger.debug(f" will execute bound command {command.name}...")
|
||||
r = command.execute(client_response)
|
||||
ret_from_bound_commands.append(r) # it will be flatten if needed later
|
||||
|
||||
all_ret = flatten(ret, ret_from_bound_commands, collector.results)
|
||||
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||
for r in all_ret[1:]:
|
||||
if (hasattr(r, 'attrs')
|
||||
and "hx-swap-oob" not in r.attrs
|
||||
and r.get("id", None) is not None):
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
|
||||
return all_ret[0] if len(all_ret) == 1 else all_ret
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True):
|
||||
self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||
# Note that the default value is the same than in get_htmx_params()
|
||||
if target is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
@@ -99,49 +207,22 @@ class BaseCommand:
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
return {
|
||||
res = {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": {}
|
||||
"values": self.default_kwargs
|
||||
}
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
|
||||
return res
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Represents a command that encapsulates a callable action with parameters.
|
||||
|
||||
This class is designed to hold a defined action (callback) alongside its arguments
|
||||
and keyword arguments.
|
||||
|
||||
:ivar name: The name of the command.
|
||||
:type name: str
|
||||
:ivar description: A brief description of the command.
|
||||
:type description: str
|
||||
:ivar callback: The function or callable to be executed.
|
||||
:type callback: Callable
|
||||
:ivar args: Positional arguments to be passed to the callback.
|
||||
:type args: tuple
|
||||
:ivar kwargs: Keyword arguments to be passed to the callback.
|
||||
:type kwargs: dict
|
||||
"""
|
||||
|
||||
def __init__(self, name, description, callback, *args, **kwargs):
|
||||
super().__init__(name, description)
|
||||
self.callback = callback
|
||||
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _convert(self, key, value):
|
||||
if key in self.callback_parameters:
|
||||
param = self.callback_parameters[key]
|
||||
def _cast_parameter(self, key, value):
|
||||
if key in self._callback_parameters:
|
||||
param = self._callback_parameters[key]
|
||||
if param.annotation == bool:
|
||||
return value == "true"
|
||||
elif param.annotation == int:
|
||||
@@ -154,70 +235,59 @@ class Command(BaseCommand):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
def _create_kwargs(self, *args):
|
||||
"""
|
||||
Try to recreate the requested kwargs from the client response and the default kwargs.
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
all_args = {}
|
||||
for arg in [arg for arg in args if arg is not None]:
|
||||
all_args |= arg
|
||||
|
||||
res = {}
|
||||
for k, v in self._callback_parameters.items():
|
||||
if k in all_args:
|
||||
res[k] = self._cast_parameter(k, all_args[k])
|
||||
return res
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
def binding_result_callback(attr, old, new, results):
|
||||
ret_from_bindings.extend(results)
|
||||
|
||||
for data in self._bindings:
|
||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
new_kwargs = self.kwargs.copy()
|
||||
if client_response:
|
||||
for k, v in client_response.items():
|
||||
if k in self.callback_parameters:
|
||||
new_kwargs[k] = self._convert(k, v)
|
||||
if 'client_response' in self.callback_parameters:
|
||||
new_kwargs['client_response'] = client_response
|
||||
|
||||
ret = self.callback(*self.args, **new_kwargs)
|
||||
|
||||
for data in self._bindings:
|
||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret[1:]:
|
||||
if hasattr(r, 'attrs') and r.get("id", None) is not None:
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
|
||||
if not ret_from_bindings:
|
||||
return ret
|
||||
|
||||
if isinstance(ret, (list, tuple)):
|
||||
return list(ret) + ret_from_bindings
|
||||
else:
|
||||
return [ret] + ret_from_bindings
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
|
||||
class LambdaCommand(Command):
|
||||
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, delegate)
|
||||
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, owner, delegate)
|
||||
self.htmx(target=None)
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
return self.callback(client_response)
|
||||
|
||||
class CommandTemplate:
|
||||
def __init__(self, key, command_type, args: list = None, kwargs: dict = None):
|
||||
self.key = key
|
||||
args = args or []
|
||||
kwargs = kwargs or {}
|
||||
self.command = CommandsManager.get_command_by_key(key) or command_type(*args, **kwargs)
|
||||
|
||||
|
||||
class CommandsManager:
|
||||
commands = {}
|
||||
commands = {} # by_id
|
||||
commands_by_key = {}
|
||||
|
||||
@staticmethod
|
||||
def register(command: BaseCommand):
|
||||
def register(command: Command):
|
||||
CommandsManager.commands[str(command.id)] = command
|
||||
if (key := command.get_key()) is not None:
|
||||
CommandsManager.commands_by_key[key] = command
|
||||
|
||||
@staticmethod
|
||||
def get_command(command_id: str) -> Optional[BaseCommand]:
|
||||
def get_command(command_id: str) -> Optional[Command]:
|
||||
return CommandsManager.commands.get(command_id)
|
||||
|
||||
@staticmethod
|
||||
def get_command_by_key(key):
|
||||
return CommandsManager.commands_by_key.get(key, None)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
return CommandsManager.commands.clear()
|
||||
CommandsManager.commands.clear()
|
||||
CommandsManager.commands_by_key.clear()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from enum import Enum
|
||||
|
||||
DEFAULT_COLUMN_WIDTH = 100
|
||||
NO_DEFAULT_VALUE = object()
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
# Datagrid
|
||||
ROW_INDEX_ID = "__row_index__"
|
||||
DATAGRID_DEFAULT_COLUMN_WIDTH = 100
|
||||
DATAGRID_PAGE_SIZE = 1000
|
||||
FILTER_INPUT_CID = "__filter_input__"
|
||||
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
Bindings = "/bindings"
|
||||
Completions = "/completions"
|
||||
Validations = "/validations"
|
||||
|
||||
|
||||
class ColumnType(Enum):
|
||||
@@ -17,7 +25,8 @@ class ColumnType(Enum):
|
||||
Datetime = "DateTime"
|
||||
Bool = "Boolean"
|
||||
Choice = "Choice"
|
||||
List = "List"
|
||||
Enum = "Enum"
|
||||
Formula = "Formula"
|
||||
|
||||
|
||||
class ViewType(Enum):
|
||||
|
||||
19
src/myfasthtml/core/dbengine_utils.py
Normal file
19
src/myfasthtml/core/dbengine_utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pandas as pd
|
||||
from dbengine.handlers import BaseRefHandler
|
||||
|
||||
|
||||
class DataFrameHandler(BaseRefHandler):
|
||||
def is_eligible_for(self, obj):
|
||||
return isinstance(obj, pd.DataFrame)
|
||||
|
||||
def tag(self):
|
||||
return "DataFrame"
|
||||
|
||||
def serialize_to_bytes(self, df) -> bytes:
|
||||
from io import BytesIO
|
||||
import pickle
|
||||
return pickle.dumps(df)
|
||||
|
||||
def deserialize_from_bytes(self, data: bytes):
|
||||
import pickle
|
||||
return pickle.loads(data)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -6,11 +7,14 @@ from dbengine.dbengine import DbEngine
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
from myfasthtml.core.utils import retrieve_user_info
|
||||
|
||||
logger = logging.getLogger("DbManager")
|
||||
|
||||
|
||||
class DbManager(SingleInstance):
|
||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(parent, auto_register=auto_register)
|
||||
|
||||
if not hasattr(self, "db"): # hack to manage singleton inheritance
|
||||
self.db = DbEngine(root=root)
|
||||
|
||||
def save(self, entry, obj):
|
||||
@@ -37,10 +41,13 @@ class DbObject:
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||
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
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@@ -51,14 +58,15 @@ class DbObject:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._finalize_initialization()
|
||||
self._initializing = old_state
|
||||
self._finalize_initialization()
|
||||
|
||||
def __setattr__(self, name: str, value: str):
|
||||
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
|
||||
if name.startswith("_") or name.startswith("ns_") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
if not name.startswith("ne_"):
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
@@ -67,13 +75,28 @@ class DbObject:
|
||||
self._save_self()
|
||||
|
||||
def _finalize_initialization(self):
|
||||
if getattr(self, "_initializing", False):
|
||||
return # still under initialization
|
||||
|
||||
if self._reload_self():
|
||||
# logger.debug(f"finalize_initialization ({self._name}) : Loaded existing content.")
|
||||
return
|
||||
else:
|
||||
# logger.debug(f"finalize_initialization ({self._name}) : No existing content found, creating new entry {self._save_state=}.")
|
||||
self._save_self()
|
||||
|
||||
def _reload_self(self):
|
||||
if self._db_manager.exists_entry(self._name):
|
||||
props = self._db_manager.load(self._name)
|
||||
self.update(props)
|
||||
else:
|
||||
self._save_self()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _save_self(self):
|
||||
if not self._save_state:
|
||||
return
|
||||
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
|
||||
not k.startswith("_") and not k.startswith("ns")}
|
||||
if props:
|
||||
@@ -92,12 +115,38 @@ class DbObject:
|
||||
return props
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
"""
|
||||
Update instance attributes with the provided arguments or keyword arguments.
|
||||
|
||||
This method allows updating the attributes of an object based on the provided
|
||||
dictionary-like argument or explicit keyword arguments. It ensures that only
|
||||
permitted attributes will be updated, excluding any internal or restricted
|
||||
attributes. If both a dictionary and keyword arguments are provided, the
|
||||
properties from the dictionary will be updated first, followed by the
|
||||
keyword arguments.
|
||||
|
||||
There will be only one update in database.
|
||||
|
||||
:param args: Zero or one positional argument is allowed. If provided, it must be
|
||||
either a dictionary or an instance of SimpleNamespace whose properties
|
||||
are used to update the instance.
|
||||
:param kwargs: Keyword arguments that represent the properties and their new
|
||||
values to be updated on the instance.
|
||||
:return: The updated instance (self).
|
||||
:rtype: object
|
||||
|
||||
:raises ValueError: If more than one positional argument is provided, or if the
|
||||
provided argument is neither a dictionary nor an instance of
|
||||
SimpleNamespace.
|
||||
"""
|
||||
if len(args) > 1:
|
||||
raise ValueError("Only one argument is allowed")
|
||||
|
||||
properties = {}
|
||||
if args:
|
||||
arg = args[0]
|
||||
if arg is None:
|
||||
return self
|
||||
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||
raise ValueError("Only dict or Expando are allowed as argument")
|
||||
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||
@@ -118,3 +167,15 @@ class DbObject:
|
||||
as_dict = self._get_properties().copy()
|
||||
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
|
||||
return SimpleNamespace(**as_dict)
|
||||
|
||||
def save(self):
|
||||
self._save_self()
|
||||
|
||||
def reload(self):
|
||||
self._reload_self()
|
||||
|
||||
def exists(self):
|
||||
return self._db_manager.exists_entry(self._name)
|
||||
|
||||
def get_id(self):
|
||||
return self._name
|
||||
|
||||
0
src/myfasthtml/core/dsl/__init__.py
Normal file
0
src/myfasthtml/core/dsl/__init__.py
Normal file
93
src/myfasthtml/core/dsl/base.py
Normal file
93
src/myfasthtml/core/dsl/base.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
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_simple_mode import 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 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())
|
||||
|
||||
@cached_property
|
||||
def simple_mode_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the CodeMirror 5 Simple Mode configuration for syntax highlighting.
|
||||
|
||||
This is cached after first computation.
|
||||
|
||||
Returns:
|
||||
Dictionary with Simple Mode rules:
|
||||
{
|
||||
"start": [
|
||||
{"regex": "...", "token": "keyword"},
|
||||
{"regex": "...", "token": "string"},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
from myfasthtml.core.dsl.lark_to_simple_mode import lark_to_simple_mode
|
||||
return lark_to_simple_mode(self.get_grammar())
|
||||
|
||||
def get_editor_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the configuration for the DslEditor JavaScript initialization.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- 'simpleModeConfig': The CodeMirror Simple Mode configuration
|
||||
- 'completions': The completion items
|
||||
- 'name': The DSL name
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"simpleModeConfig": self.simple_mode_config,
|
||||
"completions": self.completions,
|
||||
}
|
||||
|
||||
def get_id(self):
|
||||
return make_safe_id(self.name)
|
||||
173
src/myfasthtml/core/dsl/base_completion.py
Normal file
173
src/myfasthtml/core/dsl/base_completion.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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
|
||||
self._id = type(self).__name__
|
||||
|
||||
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 (from the parser)
|
||||
scope: The detected scope (table, column, row, cell...)
|
||||
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 self._id
|
||||
38
src/myfasthtml/core/dsl/base_provider.py
Normal file
38
src/myfasthtml/core/dsl/base_provider.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Base provider protocol for DSL autocompletion.
|
||||
|
||||
Defines the minimal interface that metadata providers must implement
|
||||
to support context-aware autocompletion.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BaseMetadataProvider(Protocol):
|
||||
"""
|
||||
Protocol defining the interface for metadata providers.
|
||||
|
||||
Metadata providers give the autocompletion engine access to
|
||||
context-specific data (e.g., column names, available values).
|
||||
|
||||
This is a minimal interface. Specific DSL implementations
|
||||
can extend this with additional methods.
|
||||
"""
|
||||
|
||||
def list_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 list_format_presets(self) -> list[str]:
|
||||
"""
|
||||
Return the list of available format preset names.
|
||||
|
||||
Returns:
|
||||
List of format preset names (e.g., ["EUR", "USD", "percentage"])
|
||||
"""
|
||||
...
|
||||
50
src/myfasthtml/core/dsl/exceptions.py
Normal file
50
src/myfasthtml/core/dsl/exceptions.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Common DSL exceptions shared across all DSL implementations.
|
||||
"""
|
||||
|
||||
|
||||
class DSLError(Exception):
|
||||
"""Base exception for DSL errors."""
|
||||
pass
|
||||
|
||||
|
||||
class DSLSyntaxError(DSLError):
|
||||
"""
|
||||
Raised when a 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 a DSL is syntactically correct but semantically invalid.
|
||||
"""
|
||||
|
||||
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 ""))
|
||||
324
src/myfasthtml/core/dsl/lark_to_simple_mode.py
Normal file
324
src/myfasthtml/core/dsl/lark_to_simple_mode.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Utilities for converting Lark grammars to CodeMirror 5 Simple Mode format.
|
||||
|
||||
This module provides functions to:
|
||||
1. Extract regex patterns from Lark grammar terminals
|
||||
2. Generate CodeMirror Simple Mode configuration for syntax highlighting
|
||||
3. Extract completion items from Lark grammar (keywords, operators, etc.)
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Any, Set
|
||||
|
||||
|
||||
def lark_to_simple_mode(lark_grammar: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a Lark grammar to CodeMirror 5 Simple Mode configuration.
|
||||
|
||||
Extracts terminal definitions (regex patterns) from the Lark grammar and
|
||||
maps them to CodeMirror token classes for syntax highlighting.
|
||||
|
||||
Args:
|
||||
lark_grammar: The Lark grammar string.
|
||||
|
||||
Returns:
|
||||
Dictionary with Simple Mode configuration:
|
||||
{
|
||||
"start": [
|
||||
{"regex": "...", "token": "keyword"},
|
||||
{"regex": "...", "token": "string"},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Extract keywords from literal strings in grammar rules
|
||||
keywords = _extract_keywords(lark_grammar)
|
||||
|
||||
# Extract terminals (regex patterns)
|
||||
terminals = _extract_terminals(lark_grammar)
|
||||
|
||||
# Build Simple Mode rules
|
||||
rules = []
|
||||
|
||||
# Comments (must come first to have priority)
|
||||
rules.append({
|
||||
"regex": r"#.*",
|
||||
"token": "comment"
|
||||
})
|
||||
|
||||
# Keywords
|
||||
if keywords:
|
||||
keyword_pattern = r"\b(?:" + "|".join(re.escape(k) for k in keywords) + r")\b"
|
||||
rules.append({
|
||||
"regex": keyword_pattern,
|
||||
"token": "keyword"
|
||||
})
|
||||
|
||||
# Terminals mapped to token types
|
||||
terminal_mappings = {
|
||||
"QUOTED_STRING": "string",
|
||||
"SIGNED_NUMBER": "number",
|
||||
"INTEGER": "number",
|
||||
"BOOLEAN": "atom",
|
||||
"CELL_ID": "variable-3",
|
||||
"NAME": "variable",
|
||||
}
|
||||
|
||||
for term_name, pattern in terminals.items():
|
||||
if term_name in terminal_mappings:
|
||||
token_type = terminal_mappings[term_name]
|
||||
js_pattern = _lark_regex_to_js(pattern)
|
||||
if js_pattern:
|
||||
rules.append({
|
||||
"regex": js_pattern,
|
||||
"token": token_type
|
||||
})
|
||||
|
||||
return {"start": rules}
|
||||
|
||||
|
||||
def _extract_keywords(grammar: str) -> List[str]:
|
||||
"""
|
||||
Extract keyword literals from grammar rules.
|
||||
|
||||
Looks for quoted string literals in rules (e.g., "column", "if", "style").
|
||||
|
||||
Args:
|
||||
grammar: The Lark grammar string.
|
||||
|
||||
Returns:
|
||||
List of keyword strings.
|
||||
"""
|
||||
keywords = set()
|
||||
|
||||
# Match quoted literals in rules (not in terminal definitions)
|
||||
# Pattern: "keyword" but not in lines like: TERMINAL: "pattern"
|
||||
lines = grammar.split("\n")
|
||||
for line in lines:
|
||||
# Skip terminal definitions (uppercase name followed by colon)
|
||||
if re.match(r'\s*[A-Z_]+\s*:', line):
|
||||
continue
|
||||
|
||||
# Skip comments
|
||||
if line.strip().startswith("//") or line.strip().startswith("#"):
|
||||
continue
|
||||
|
||||
# Find quoted strings in rules
|
||||
matches = re.findall(r'"([a-z_]+)"', line)
|
||||
for match in matches:
|
||||
# Filter out regex-like patterns, keep only identifiers
|
||||
if re.match(r'^[a-z_]+$', match):
|
||||
keywords.add(match)
|
||||
|
||||
return sorted(keywords)
|
||||
|
||||
|
||||
def _extract_terminals(grammar: str) -> Dict[str, str]:
|
||||
"""
|
||||
Extract terminal definitions from Lark grammar.
|
||||
|
||||
Args:
|
||||
grammar: The Lark grammar string.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping terminal names to their regex patterns.
|
||||
"""
|
||||
terminals = {}
|
||||
lines = grammar.split("\n")
|
||||
|
||||
for line in lines:
|
||||
# Match terminal definitions: NAME: /regex/ or NAME: "literal"
|
||||
match = re.match(r'\s*([A-Z_]+)\s*:\s*/([^/]+)/', line)
|
||||
if match:
|
||||
name, pattern = match.groups()
|
||||
terminals[name] = pattern
|
||||
continue
|
||||
|
||||
# Match literal alternatives: BOOLEAN: "True" | "False"
|
||||
match = re.match(r'\s*([A-Z_]+)\s*:\s*(.+)', line)
|
||||
if match:
|
||||
name, alternatives = match.groups()
|
||||
# Extract quoted literals
|
||||
literals = re.findall(r'"([^"]+)"', alternatives)
|
||||
if literals:
|
||||
# Build regex alternation
|
||||
pattern = "|".join(re.escape(lit) for lit in literals)
|
||||
terminals[name] = pattern
|
||||
|
||||
return terminals
|
||||
|
||||
|
||||
def _lark_regex_to_js(lark_pattern: str) -> str:
|
||||
"""
|
||||
Convert a Lark regex pattern to JavaScript regex.
|
||||
|
||||
This is a simplified converter that handles common patterns.
|
||||
Complex patterns may need manual adjustment.
|
||||
|
||||
Args:
|
||||
lark_pattern: Lark regex pattern.
|
||||
|
||||
Returns:
|
||||
JavaScript regex pattern string, or empty string if conversion fails.
|
||||
"""
|
||||
# Remove Lark-specific flags
|
||||
pattern = lark_pattern.strip()
|
||||
|
||||
# Handle common patterns
|
||||
conversions = [
|
||||
# Escape sequences
|
||||
(r'\[', r'['),
|
||||
(r'\]', r']'),
|
||||
|
||||
# Character classes are mostly compatible
|
||||
# Numbers: [0-9]+ or \d+
|
||||
# Letters: [a-zA-Z]
|
||||
# Whitespace: [ \t]
|
||||
]
|
||||
|
||||
result = pattern
|
||||
for lark_pat, js_pat in conversions:
|
||||
result = result.replace(lark_pat, js_pat)
|
||||
|
||||
# Wrap in word boundaries for identifier-like patterns
|
||||
# Example: [a-zA-Z_][a-zA-Z0-9_]* → \b[a-zA-Z_][a-zA-Z0-9_]*\b
|
||||
if re.match(r'\[[a-zA-Z_]+\]', result):
|
||||
result = r'\b' + result + r'\b'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_formatting_dsl_mode() -> Dict[str, Any]:
|
||||
"""
|
||||
Generate Simple Mode configuration for the Formatting DSL.
|
||||
|
||||
This is a specialized version with hand-tuned rules for better highlighting.
|
||||
|
||||
Returns:
|
||||
Simple Mode configuration dictionary.
|
||||
"""
|
||||
return {
|
||||
"start": [
|
||||
# Comments (highest priority)
|
||||
{"regex": r"#.*", "token": "comment"},
|
||||
|
||||
# Scope keywords
|
||||
{"regex": r"\b(?:column|row|cell|table|tables)\b", "token": "keyword"},
|
||||
|
||||
# Condition keywords
|
||||
{"regex": r"\b(?:if|not|and|or|in|between|case)\b", "token": "keyword"},
|
||||
|
||||
# Built-in functions
|
||||
{"regex": r"\b(?:style|format)\b", "token": "builtin"},
|
||||
|
||||
# Format types
|
||||
{"regex": r"\b(?:number|date|boolean|text|enum)\b", "token": "builtin"},
|
||||
|
||||
# String operators (word-like)
|
||||
{"regex": r"\b(?:contains|startswith|endswith|isempty|isnotempty)\b", "token": "operator"},
|
||||
|
||||
# Comparison operators (symbols)
|
||||
{"regex": r"==|!=|<=|>=|<|>", "token": "operator"},
|
||||
|
||||
# Special references
|
||||
{"regex": r"\b(?:value|col|row|cell)\b", "token": "variable-2"},
|
||||
|
||||
# Booleans
|
||||
{"regex": r"\b(?:True|False|true|false)\b", "token": "atom"},
|
||||
|
||||
# Numbers (integers and floats, with optional sign)
|
||||
{"regex": r"[+-]?\b\d+(?:\.\d+)?\b", "token": "number"},
|
||||
|
||||
# Strings (double or single quoted)
|
||||
{"regex": r'"(?:[^\\"]|\\.)*"', "token": "string"},
|
||||
{"regex": r"'(?:[^\\']|\\.)*'", "token": "string"},
|
||||
|
||||
# Cell IDs
|
||||
{"regex": r"\btcell_[a-zA-Z0-9_-]+\b", "token": "variable-3"},
|
||||
|
||||
# Names (identifiers) - lowest priority
|
||||
{"regex": r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", "token": "variable"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
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": [...],
|
||||
"operators": [...],
|
||||
"functions": [...],
|
||||
"types": [...],
|
||||
"literals": [...]
|
||||
}
|
||||
"""
|
||||
keywords: Set[str] = set()
|
||||
operators: Set[str] = set()
|
||||
functions: Set[str] = set()
|
||||
types: Set[str] = set()
|
||||
literals: Set[str] = set()
|
||||
|
||||
# Find all quoted strings (potential keywords/operators)
|
||||
quoted_strings = re.findall(r'"([^"]+)"', lark_grammar)
|
||||
|
||||
# Also look for terminal definitions with string alternatives (e.g., BOOLEAN: "True" | "False")
|
||||
terminal_literals = re.findall(r'[A-Z_]+:\s*"([^"]+)"(?:\s*\|\s*"([^"]+)")*', lark_grammar)
|
||||
for match in terminal_literals:
|
||||
for literal in match:
|
||||
if literal:
|
||||
quoted_strings.append(literal)
|
||||
|
||||
for s in quoted_strings:
|
||||
s_lower = s.lower()
|
||||
|
||||
# Classify based on pattern
|
||||
if s in ("==", "!=", "<=", "<", ">=", ">", "+", "-", "*", "/"):
|
||||
operators.add(s)
|
||||
elif s_lower in ("contains", "startswith", "endswith", "in", "between", "isempty", "isnotempty"):
|
||||
operators.add(s_lower)
|
||||
elif s_lower in ("if", "not", "and", "or"):
|
||||
keywords.add(s_lower)
|
||||
elif s_lower in ("true", "false"):
|
||||
literals.add(s)
|
||||
elif s_lower in ("style", "format"):
|
||||
functions.add(s_lower)
|
||||
elif s_lower in ("column", "row", "cell", "value", "col"):
|
||||
keywords.add(s_lower)
|
||||
elif s_lower in ("number", "date", "boolean", "text", "enum"):
|
||||
types.add(s_lower)
|
||||
elif s_lower == "case":
|
||||
keywords.add(s_lower)
|
||||
|
||||
# Find function-like patterns: word "("
|
||||
function_patterns = re.findall(r'"(\w+)"\s*"?\("', lark_grammar)
|
||||
for func in function_patterns:
|
||||
if func.lower() not in ("true", "false"):
|
||||
functions.add(func.lower())
|
||||
|
||||
# Find type patterns from format_type rule
|
||||
type_match = re.search(r'format_type\s*:\s*(.+?)(?:\n\n|\Z)', lark_grammar, re.DOTALL)
|
||||
if type_match:
|
||||
type_strings = re.findall(r'"(\w+)"', type_match.group(1))
|
||||
types.update(t.lower() for t in type_strings)
|
||||
|
||||
return {
|
||||
"keywords": sorted(keywords),
|
||||
"operators": sorted(operators),
|
||||
"functions": sorted(functions),
|
||||
"types": sorted(types),
|
||||
"literals": sorted(literals),
|
||||
}
|
||||
103
src/myfasthtml/core/dsl/types.py
Normal file
103
src/myfasthtml/core/dsl/types.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Base types for DSL autocompletion.
|
||||
|
||||
Provides dataclasses for cursor position, suggestions, and completion results
|
||||
compatible with CodeMirror 5.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Position:
|
||||
"""
|
||||
Cursor position in a document.
|
||||
|
||||
Compatible with CodeMirror 5 position format.
|
||||
|
||||
Attributes:
|
||||
line: 0-based line number
|
||||
ch: 0-based character position in the line
|
||||
"""
|
||||
|
||||
line: int
|
||||
ch: int
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
"""Convert to CodeMirror-compatible dictionary."""
|
||||
return {"line": self.line, "ch": self.ch}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Suggestion:
|
||||
"""
|
||||
A single autocompletion suggestion.
|
||||
|
||||
Attributes:
|
||||
label: The text to display and insert
|
||||
detail: Optional description shown next to the label
|
||||
kind: Optional category (e.g., "keyword", "preset", "value")
|
||||
"""
|
||||
|
||||
label: str
|
||||
detail: str = ""
|
||||
kind: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {"label": self.label}
|
||||
if self.detail:
|
||||
result["detail"] = self.detail
|
||||
if self.kind:
|
||||
result["kind"] = self.kind
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompletionResult:
|
||||
"""
|
||||
Result of an autocompletion request.
|
||||
|
||||
Compatible with CodeMirror 5 hint format.
|
||||
|
||||
Attributes:
|
||||
from_pos: Start position of the text to replace
|
||||
to_pos: End position of the text to replace
|
||||
suggestions: List of completion suggestions
|
||||
"""
|
||||
|
||||
from_pos: Position
|
||||
to_pos: Position
|
||||
suggestions: list[Suggestion] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to CodeMirror-compatible dictionary."""
|
||||
return {
|
||||
"from": self.from_pos.to_dict(),
|
||||
"to": self.to_pos.to_dict(),
|
||||
"suggestions": [s.to_dict() for s in self.suggestions],
|
||||
}
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Return True if there are no suggestions."""
|
||||
return len(self.suggestions) == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WordRange:
|
||||
"""
|
||||
Range of a word in a line.
|
||||
|
||||
Used for determining what text to replace when applying a suggestion.
|
||||
|
||||
Attributes:
|
||||
start: Start character position (inclusive)
|
||||
end: End character position (exclusive)
|
||||
text: The word text
|
||||
"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
text: str = ""
|
||||
226
src/myfasthtml/core/dsl/utils.py
Normal file
226
src/myfasthtml/core/dsl/utils.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Shared utilities for DSL autocompletion.
|
||||
|
||||
Provides helper functions for text analysis, word boundary detection,
|
||||
and other common operations used by completion engines.
|
||||
"""
|
||||
|
||||
from .types import Position, WordRange
|
||||
|
||||
# Delimiters used to detect word boundaries
|
||||
DELIMITERS = set('"\' ()[]{}=,:<>!\t\n\r')
|
||||
|
||||
|
||||
def get_line_at(text: str, line_number: int) -> str:
|
||||
"""
|
||||
Get the content of a specific line.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
line_number: 0-based line number
|
||||
|
||||
Returns:
|
||||
The line content, or empty string if line doesn't exist
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
if 0 <= line_number < len(lines):
|
||||
return lines[line_number]
|
||||
return ""
|
||||
|
||||
|
||||
def get_line_up_to_cursor(text: str, cursor: Position) -> str:
|
||||
"""
|
||||
Get the content of the current line up to the cursor position.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
cursor: Cursor position
|
||||
|
||||
Returns:
|
||||
The line content from start to cursor position
|
||||
"""
|
||||
line = get_line_at(text, cursor.line)
|
||||
return line[: cursor.ch]
|
||||
|
||||
|
||||
def get_lines_up_to(text: str, line_number: int) -> list[str]:
|
||||
"""
|
||||
Get all lines from start up to and including the specified line.
|
||||
|
||||
Args:
|
||||
text: The full document text
|
||||
line_number: 0-based line number (inclusive)
|
||||
|
||||
Returns:
|
||||
List of lines from 0 to line_number
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
return lines[: line_number + 1]
|
||||
|
||||
|
||||
def find_word_boundaries(line: str, cursor_ch: int) -> WordRange:
|
||||
"""
|
||||
Find the word boundaries around the cursor position.
|
||||
|
||||
Uses delimiters to detect where a word starts and ends.
|
||||
The cursor can be anywhere within the word.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
WordRange with start, end positions and the word text
|
||||
"""
|
||||
if not line or cursor_ch < 0:
|
||||
return WordRange(start=cursor_ch, end=cursor_ch, text="")
|
||||
|
||||
# Clamp cursor position to line length
|
||||
cursor_ch = min(cursor_ch, len(line))
|
||||
|
||||
# Find start of word (scan backwards from cursor)
|
||||
start = cursor_ch
|
||||
while start > 0 and line[start - 1] not in DELIMITERS:
|
||||
start -= 1
|
||||
|
||||
# Find end of word (scan forwards from cursor)
|
||||
end = cursor_ch
|
||||
while end < len(line) and line[end] not in DELIMITERS:
|
||||
end += 1
|
||||
|
||||
word = line[start:end]
|
||||
return WordRange(start=start, end=end, text=word)
|
||||
|
||||
|
||||
def get_prefix(line: str, cursor_ch: int) -> str:
|
||||
"""
|
||||
Get the word prefix before the cursor.
|
||||
|
||||
This is the text from the start of the current word to the cursor.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
The prefix text
|
||||
"""
|
||||
word_range = find_word_boundaries(line, cursor_ch)
|
||||
# Prefix is from word start to cursor
|
||||
return line[word_range.start: cursor_ch]
|
||||
|
||||
|
||||
def is_in_comment(line: str, cursor_ch: int) -> bool:
|
||||
"""
|
||||
Check if the cursor is inside a comment.
|
||||
|
||||
A comment starts with # and extends to the end of the line.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
True if cursor is after a # character
|
||||
"""
|
||||
# Find first # that's not inside a string
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
for i, char in enumerate(line):
|
||||
if i >= cursor_ch:
|
||||
break
|
||||
|
||||
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
|
||||
if not in_string:
|
||||
in_string = True
|
||||
string_char = char
|
||||
elif char == string_char:
|
||||
in_string = False
|
||||
string_char = None
|
||||
elif char == "#" and not in_string:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_in_string(line: str, cursor_ch: int) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if the cursor is inside a string literal.
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
cursor_ch: Cursor character position in the line
|
||||
|
||||
Returns:
|
||||
Tuple of (is_in_string, quote_char)
|
||||
quote_char is '"' or "'" if inside a string, None otherwise
|
||||
"""
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
for i, char in enumerate(line):
|
||||
if i >= cursor_ch:
|
||||
break
|
||||
|
||||
if char in ('"', "'") and (i == 0 or line[i - 1] != "\\"):
|
||||
if not in_string:
|
||||
in_string = True
|
||||
string_char = char
|
||||
elif char == string_char:
|
||||
in_string = False
|
||||
string_char = None
|
||||
|
||||
return in_string, string_char if in_string else None
|
||||
|
||||
|
||||
def get_indentation(line: str) -> int:
|
||||
"""
|
||||
Get the indentation level of a line.
|
||||
|
||||
Counts leading spaces (tabs are converted to 4 spaces).
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
|
||||
Returns:
|
||||
Number of leading spaces
|
||||
"""
|
||||
count = 0
|
||||
for char in line:
|
||||
if char == " ":
|
||||
count += 1
|
||||
elif char == "\t":
|
||||
count += 4
|
||||
else:
|
||||
break
|
||||
return count
|
||||
|
||||
|
||||
def is_indented(line: str) -> bool:
|
||||
"""
|
||||
Check if a line is indented (has leading whitespace).
|
||||
|
||||
Args:
|
||||
line: The line content
|
||||
|
||||
Returns:
|
||||
True if line starts with whitespace
|
||||
"""
|
||||
return len(line) > 0 and line[0] in (" ", "\t")
|
||||
|
||||
|
||||
def strip_quotes(text: str) -> str:
|
||||
"""
|
||||
Remove surrounding quotes from a string.
|
||||
|
||||
Args:
|
||||
text: Text that may be quoted
|
||||
|
||||
Returns:
|
||||
Text without surrounding quotes
|
||||
"""
|
||||
if len(text) >= 2:
|
||||
if (text[0] == '"' and text[-1] == '"') or (text[0] == "'" and text[-1] == "'"):
|
||||
return text[1:-1]
|
||||
return text
|
||||
31
src/myfasthtml/core/dsls.py
Normal file
31
src/myfasthtml/core/dsls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
|
||||
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
|
||||
|
||||
@dataclass
|
||||
class DslDefinition:
|
||||
completion: BaseCompletionEngine
|
||||
validation: DSLParser # To do, this parser is not generic (specific to the Formatting DSL)
|
||||
|
||||
|
||||
class DslsManager:
|
||||
dsls: dict[str, DslDefinition] = {}
|
||||
|
||||
@staticmethod
|
||||
def register(completion: BaseCompletionEngine, validation: DSLParser):
|
||||
# then engine_id is actually the DSL id
|
||||
DslsManager.dsls[completion.get_id()] = DslDefinition(completion, validation)
|
||||
|
||||
@staticmethod
|
||||
def get_completion_engine(engine_id) -> BaseCompletionEngine:
|
||||
return DslsManager.dsls[engine_id].completion
|
||||
|
||||
@staticmethod
|
||||
def get_validation_parser(engine_id) -> DSLParser:
|
||||
return DslsManager.dsls[engine_id].validation
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
DslsManager.dsls = {}
|
||||
1
src/myfasthtml/core/formatting/__init__.py
Normal file
1
src/myfasthtml/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Formatting module for DataGrid
|
||||
200
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
200
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
@@ -0,0 +1,200 @@
|
||||
from numbers import Number
|
||||
from typing import Any
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import Condition
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""Evaluates conditions against cell values."""
|
||||
|
||||
def evaluate(self, condition: Condition, cell_value: Any, row_data: dict = None) -> bool:
|
||||
"""
|
||||
Evaluate a condition against a cell value.
|
||||
|
||||
Args:
|
||||
condition: The condition to evaluate
|
||||
cell_value: The value of the current cell
|
||||
row_data: Dict of {col_id: value} for column references (optional)
|
||||
|
||||
Returns:
|
||||
True if condition is met, False otherwise
|
||||
"""
|
||||
# If col parameter is set, use that column's value instead of cell_value
|
||||
if condition.col is not None:
|
||||
if row_data is None or condition.col not in row_data:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
cell_value = row_data[condition.col]
|
||||
|
||||
# Handle isempty/isnotempty first (they work with None)
|
||||
if condition.operator == "isempty":
|
||||
result = self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
if condition.operator == "isnotempty":
|
||||
result = not self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
if condition.operator == "isnan":
|
||||
result = self._is_nan(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
# For all other operators, None cell_value returns False
|
||||
if cell_value is None:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Resolve the comparison value (might be a column reference)
|
||||
compare_value = self._resolve_value(condition.value, row_data)
|
||||
|
||||
# If reference resolved to None, return False
|
||||
if compare_value is None and isinstance(condition.value, dict):
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Evaluate based on operator
|
||||
result = self._evaluate_operator(
|
||||
condition.operator,
|
||||
cell_value,
|
||||
compare_value,
|
||||
condition.case_sensitive
|
||||
)
|
||||
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
def _resolve_value(self, value: Any, row_data: dict) -> Any:
|
||||
"""Resolve a value, handling column references."""
|
||||
if isinstance(value, dict) and "col" in value:
|
||||
if row_data is None:
|
||||
return None
|
||||
col_id = value["col"]
|
||||
return row_data.get(col_id)
|
||||
return value
|
||||
|
||||
def _is_empty(self, value: Any) -> bool:
|
||||
"""Check if a value is empty (None or empty string)."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and value == "":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_nan(self, value: Any) -> bool:
|
||||
"""Check if a value is NaN."""
|
||||
return isinstance(value, float) and value != value
|
||||
|
||||
def _apply_negate(self, result: bool, negate: bool) -> bool:
|
||||
"""Apply negation if needed."""
|
||||
return not result if negate else result
|
||||
|
||||
def _evaluate_operator(
|
||||
self,
|
||||
operator: str,
|
||||
cell_value: Any,
|
||||
compare_value: Any,
|
||||
case_sensitive: bool
|
||||
) -> bool:
|
||||
"""Evaluate a specific operator."""
|
||||
try:
|
||||
if operator == "==":
|
||||
return self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "!=":
|
||||
return not self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "<":
|
||||
return self._less_than(cell_value, compare_value)
|
||||
elif operator == "<=":
|
||||
return self._less_than_or_equal(cell_value, compare_value)
|
||||
elif operator == ">":
|
||||
return self._greater_than(cell_value, compare_value)
|
||||
elif operator == ">=":
|
||||
return self._greater_than_or_equal(cell_value, compare_value)
|
||||
elif operator == "contains":
|
||||
return self._contains(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "startswith":
|
||||
return self._startswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "endswith":
|
||||
return self._endswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "in":
|
||||
return self._in_list(cell_value, compare_value)
|
||||
elif operator == "between":
|
||||
return self._between(cell_value, compare_value)
|
||||
else:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# Type mismatch or invalid operation
|
||||
return False
|
||||
|
||||
def _equals(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check equality, with optional case-insensitive string comparison."""
|
||||
if isinstance(cell_value, str) and isinstance(compare_value, str):
|
||||
if case_sensitive:
|
||||
return cell_value == compare_value
|
||||
return cell_value.lower() == compare_value.lower()
|
||||
return cell_value == compare_value
|
||||
|
||||
def _less_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value < compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
# Allow int/float comparison
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value < compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value < compare_value
|
||||
|
||||
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value <= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value <= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value <= compare_value
|
||||
|
||||
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value > compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value > compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value > compare_value
|
||||
|
||||
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value >= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, Number) and isinstance(compare_value, Number):
|
||||
return cell_value >= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value >= compare_value
|
||||
|
||||
def _contains(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value contains compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return compare_str in cell_str
|
||||
return compare_str.lower() in cell_str.lower()
|
||||
|
||||
def _startswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value starts with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.startswith(compare_str)
|
||||
return cell_str.lower().startswith(compare_str.lower())
|
||||
|
||||
def _endswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value ends with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.endswith(compare_str)
|
||||
return cell_str.lower().endswith(compare_str.lower())
|
||||
|
||||
def _in_list(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is in the list."""
|
||||
if not isinstance(compare_value, list):
|
||||
raise TypeError("'in' operator requires a list")
|
||||
return cell_value in compare_value
|
||||
|
||||
def _between(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is between min and max (inclusive)."""
|
||||
if not isinstance(compare_value, list) or len(compare_value) != 2:
|
||||
raise TypeError("'between' operator requires a list of [min, max]")
|
||||
min_val, max_val = compare_value
|
||||
return min_val <= cell_value <= max_val
|
||||
168
src/myfasthtml/core/formatting/dataclasses.py
Normal file
168
src/myfasthtml/core/formatting/dataclasses.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# === Condition ===
|
||||
|
||||
@dataclass
|
||||
class Condition:
|
||||
"""
|
||||
Represents a condition for conditional formatting.
|
||||
|
||||
Attributes:
|
||||
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
|
||||
"contains", "startswith", "endswith", "in", "between",
|
||||
"isempty", "isnotempty")
|
||||
value: Value to compare against (literal, list, or {"col": "..."} for reference)
|
||||
negate: If True, inverts the condition result
|
||||
case_sensitive: If True, string comparisons are case-sensitive (default False)
|
||||
col: Column ID for row-level conditions (evaluate this column instead of current cell)
|
||||
row: Row index for column-level conditions (evaluate this row instead of current cell)
|
||||
"""
|
||||
operator: str
|
||||
value: Any = None
|
||||
negate: bool = False
|
||||
case_sensitive: bool = False
|
||||
col: str = None
|
||||
row: int = None
|
||||
|
||||
|
||||
# === Style ===
|
||||
|
||||
@dataclass
|
||||
class Style:
|
||||
"""
|
||||
Represents style properties for cell formatting.
|
||||
|
||||
Attributes:
|
||||
preset: Name of a style preset ("primary", "success", "error", etc.)
|
||||
background_color: Background color (hex, CSS name, or CSS variable)
|
||||
color: Text color
|
||||
font_weight: "normal" or "bold"
|
||||
font_style: "normal" or "italic"
|
||||
font_size: Font size ("12px", "0.9em")
|
||||
text_decoration: "none", "underline", or "line-through"
|
||||
"""
|
||||
preset: str = None
|
||||
background_color: str = None
|
||||
color: str = None
|
||||
font_weight: str = None
|
||||
font_style: str = None
|
||||
font_size: str = None
|
||||
text_decoration: str = None
|
||||
|
||||
|
||||
# === Formatters ===
|
||||
|
||||
@dataclass
|
||||
class Formatter:
|
||||
"""Base class for all formatters."""
|
||||
preset: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberFormatter(Formatter):
|
||||
"""
|
||||
Formatter for numbers, currencies, and percentages.
|
||||
|
||||
Attributes:
|
||||
prefix: Text before value (e.g., "$")
|
||||
suffix: Text after value (e.g., " EUR")
|
||||
thousands_sep: Thousands separator (e.g., ",", " ")
|
||||
decimal_sep: Decimal separator (e.g., ".", ",")
|
||||
precision: Number of decimal places
|
||||
multiplier: Multiply value before display (e.g., 100 for percentage)
|
||||
"""
|
||||
prefix: str = ""
|
||||
suffix: str = ""
|
||||
thousands_sep: str = ""
|
||||
decimal_sep: str = "."
|
||||
precision: int = 0
|
||||
multiplier: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateFormatter(Formatter):
|
||||
"""
|
||||
Formatter for dates and datetimes.
|
||||
|
||||
Attributes:
|
||||
format: strftime format pattern (default: "%Y-%m-%d")
|
||||
"""
|
||||
format: str = "%Y-%m-%d"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BooleanFormatter(Formatter):
|
||||
"""
|
||||
Formatter for boolean values.
|
||||
|
||||
Attributes:
|
||||
true_value: Display string for True
|
||||
false_value: Display string for False
|
||||
null_value: Display string for None/null
|
||||
"""
|
||||
true_value: str = "true"
|
||||
false_value: str = "false"
|
||||
null_value: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextFormatter(Formatter):
|
||||
"""
|
||||
Formatter for text transformations.
|
||||
|
||||
Attributes:
|
||||
transform: Text transformation ("uppercase", "lowercase", "capitalize")
|
||||
max_length: Maximum length before truncation
|
||||
ellipsis: Suffix when truncated (default: "...")
|
||||
"""
|
||||
transform: str = None
|
||||
max_length: int = None
|
||||
ellipsis: str = "..."
|
||||
|
||||
@dataclass
|
||||
class ConstantFormatter(Formatter):
|
||||
value: str = None
|
||||
|
||||
@dataclass
|
||||
class EnumFormatter(Formatter):
|
||||
"""
|
||||
Formatter for mapping values to display labels.
|
||||
|
||||
Attributes:
|
||||
source: Data source dict with "type" and "value" keys
|
||||
- {"type": "mapping", "value": {"key": "label", ...}}
|
||||
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
|
||||
default: Label for unknown values
|
||||
allow_empty: Show empty option in Select dropdowns
|
||||
empty_label: Label for empty option
|
||||
order_by: Sort order ("source", "display", "value")
|
||||
"""
|
||||
source: dict = field(default_factory=dict)
|
||||
default: str = ""
|
||||
allow_empty: bool = True
|
||||
empty_label: str = "-- Select --"
|
||||
order_by: str = "source"
|
||||
|
||||
|
||||
# === Format Rule ===
|
||||
|
||||
@dataclass
|
||||
class FormatRule:
|
||||
"""
|
||||
A formatting rule combining condition, style, and formatter.
|
||||
|
||||
Rules:
|
||||
- style and formatter can appear alone (unconditional formatting)
|
||||
- condition cannot appear alone - must be paired with style and/or formatter
|
||||
- If condition is present, style/formatter is applied only if condition is met
|
||||
|
||||
Attributes:
|
||||
condition: Optional condition for conditional formatting
|
||||
style: Optional style to apply
|
||||
formatter: Optional formatter to apply
|
||||
"""
|
||||
condition: Condition = None
|
||||
style: Style = None
|
||||
formatter: Formatter = None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user