Compare commits
103 Commits
52b4e6a8b6
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c0381e34 | |||
| b8fd4e5ed1 | |||
| 72d6cce6ff | |||
| f887267362 | |||
| 853bc4abae | |||
| 2fcc225414 | |||
| ef9f269a49 | |||
| 0951680466 | |||
| 0c9c8bc7fa | |||
| feb9da50b2 | |||
| f773fd1611 | |||
| af83f4b6dc | |||
| a4ebd6d61b | |||
| 56fb3cf021 | |||
| 3d1a391cba | |||
| 3105b72ac2 | |||
| e704dad62c | |||
| e01d2cd74b | |||
| 30a77d1171 | |||
| 0a766581ed | |||
| efbc5a59ff | |||
| c07b75ee72 | |||
| b383b1bc8b | |||
| 5dc4fbae25 | |||
| 1add319a6e | |||
| 9fe511c97b | |||
| 2af43f357d | |||
| b5abb59332 | |||
| 3715954222 | |||
| 0686103a8f | |||
| 8b8172231a | |||
| 44691be30f | |||
| 9a25591edf | |||
| d447220eae | |||
| 730f55d65b | |||
| c49f28da26 | |||
| 40a90c7ff5 | |||
| 8f3b2e795e | |||
| 13f292fc9d | |||
| 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 | |||
| 8e5fa7f752 | |||
| 1d20fb8650 | |||
| ce5328fe34 |
560
.claude/commands/developer-control.md
Normal file
560
.claude/commands/developer-control.md
Normal file
@@ -0,0 +1,560 @@
|
||||
# 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.handle_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(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-20: HTMX Ajax Requests
|
||||
|
||||
**Always specify a `target` in HTMX ajax requests.**
|
||||
|
||||
```javascript
|
||||
// ❌ INCORRECT: Without target, HTMX doesn't know where to swap the response
|
||||
htmx.ajax('POST', '/url', {
|
||||
values: {param: value}
|
||||
});
|
||||
|
||||
// ✅ CORRECT: Explicitly specify the target
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#element-id',
|
||||
values: {param: value}
|
||||
});
|
||||
```
|
||||
|
||||
**Exception:** Response contains elements with `hx-swap-oob="true"`.
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-21: HTMX Swap Modes and Event Listeners
|
||||
|
||||
**`hx-on::after-settle` only works when the swapped element replaces the target (`outerHTML`).**
|
||||
|
||||
```javascript
|
||||
// ❌ INCORRECT: innerHTML (default) nests the returned element
|
||||
// The hx-on::after-settle attribute on the returned element is never processed
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element'
|
||||
// swap: 'innerHTML' is the default
|
||||
});
|
||||
|
||||
// ✅ CORRECT: outerHTML replaces the entire element
|
||||
// The hx-on::after-settle attribute on the returned element works
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- `innerHTML`: replaces **content** → `<div id="X"><div id="X" hx-on::...>new</div></div>` (duplicate ID)
|
||||
- `outerHTML`: replaces **element** → `<div id="X" hx-on::...>new</div>` (correct)
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-22: Reinitializing Event Listeners
|
||||
|
||||
**After an HTMX swap, event listeners attached via JavaScript are lost and must be reinitialized.**
|
||||
|
||||
**Recommended pattern:**
|
||||
|
||||
1. Create a reusable initialization function:
|
||||
```javascript
|
||||
function initMyControl(controlId) {
|
||||
const element = document.getElementById(controlId);
|
||||
// Attach event listeners
|
||||
element.addEventListener('click', handleClick);
|
||||
}
|
||||
```
|
||||
|
||||
2. Call this function after swap via `hx-on::after-settle`:
|
||||
```python
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initMyControl('{self._id}');"
|
||||
}
|
||||
element.attrs.update(extra_attr)
|
||||
```
|
||||
|
||||
**Alternative:** Use event delegation on a stable parent element.
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-23: Avoiding Duplicate IDs with HTMX
|
||||
|
||||
**If the element returned by the server has the same ID as the HTMX target, use `swap: 'outerHTML'`.**
|
||||
|
||||
```python
|
||||
# Server returns an element with id="my-element"
|
||||
def render_partial(self):
|
||||
return Div(id="my-element", ...) # Same ID as target
|
||||
|
||||
# JavaScript must use outerHTML
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element',
|
||||
swap: 'outerHTML' # ✅ Replaces the entire element
|
||||
});
|
||||
```
|
||||
|
||||
**Why:** `innerHTML` would create `<div id="X"><div id="X">...</div></div>` (invalid duplicate ID).
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-24: Pattern extra_attr for HTMX
|
||||
|
||||
**Use the `extra_attr` pattern to add post-swap behaviors.**
|
||||
|
||||
```python
|
||||
def render_partial(self, fragment="default"):
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initControl('{self._id}');",
|
||||
# Other HTMX attributes if needed
|
||||
}
|
||||
|
||||
element = self.mk_element()
|
||||
element.attrs.update(extra_attr)
|
||||
return element
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Reinitialize event listeners
|
||||
- Execute animations
|
||||
- Update other DOM elements
|
||||
- Logging or tracking events
|
||||
|
||||
---
|
||||
|
||||
## 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.handle_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 handle_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
|
||||
243
.claude/commands/developer.md
Normal file
243
.claude/commands/developer.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Developer Mode
|
||||
|
||||
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write production-quality code by:
|
||||
|
||||
1. Exploring available options before implementation
|
||||
2. Validating approach with user
|
||||
3. Implementing only after approval
|
||||
4. Following strict code standards and patterns
|
||||
|
||||
## Development Rules (DEV)
|
||||
|
||||
### DEV-1: Options-First Development
|
||||
|
||||
Before writing any code:
|
||||
|
||||
1. **Explain available options first** - Present different approaches to solve the problem
|
||||
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
|
||||
3. **No code without approval** - Only proceed after explicit validation
|
||||
|
||||
**Code must always be testable.**
|
||||
|
||||
### DEV-2: Question-Driven Collaboration
|
||||
|
||||
**Ask questions to clarify understanding or suggest alternative approaches:**
|
||||
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume - always clarify ambiguities
|
||||
|
||||
### DEV-3: Communication Standards
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### DEV-4: Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
|
||||
- Use Google or NumPy docstring format
|
||||
- Document all public functions and classes
|
||||
- Include type hints where applicable
|
||||
|
||||
### DEV-5: Dependency Management
|
||||
|
||||
**When introducing new dependencies:**
|
||||
|
||||
- List all external dependencies explicitly
|
||||
- Propose alternatives using Python standard library when possible
|
||||
- Explain why each dependency is needed
|
||||
|
||||
### DEV-6: Unit Testing with pytest
|
||||
|
||||
**Test naming patterns:**
|
||||
|
||||
- Passing tests: `test_i_can_xxx` - Tests that should succeed
|
||||
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Test structure:**
|
||||
|
||||
- Use **functions**, not classes (unless inheritance is required)
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### DEV-7: File Management
|
||||
|
||||
**Always specify the full file path** when adding or modifying files:
|
||||
|
||||
```
|
||||
✅ Modifying: src/myfasthtml/core/commands.py
|
||||
✅ Creating: tests/core/test_new_feature.py
|
||||
```
|
||||
|
||||
### DEV-8: Command System - HTMX Target-Callback Alignment
|
||||
|
||||
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
|
||||
|
||||
**Two-part requirement:**
|
||||
|
||||
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
|
||||
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
|
||||
|
||||
**Important: FastHTML Auto-Rendering**
|
||||
|
||||
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
|
||||
- FastHTML automatically calls `__ft__()` which returns `render()` for you
|
||||
|
||||
**Binding Commands to Elements**
|
||||
|
||||
Use the `mk` helper from `myfasthtml.controls.helpers`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Generic binding
|
||||
mk.mk(element, cmd)
|
||||
|
||||
# Shortcut for buttons
|
||||
mk.button("Label", command=cmd)
|
||||
|
||||
# Shortcut for icons
|
||||
mk.icon(icon_svg, command=cmd)
|
||||
|
||||
# Shortcut for clickable labels
|
||||
mk.label("Label", command=cmd)
|
||||
|
||||
# Shortcut for dialog buttons
|
||||
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
✅ **Correct - Component with __ft__(), returns self:**
|
||||
|
||||
```python
|
||||
# In Commands class
|
||||
def toggle_node(self, node_id: str):
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner._toggle_node, # Returns self (not self.render()!)
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
# In TreeView class
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
self._state.opened.remove(node_id)
|
||||
else:
|
||||
self._state.opened.append(node_id)
|
||||
return self # FastHTML calls __ft__() automatically
|
||||
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
|
||||
|
||||
# In render method - bind command to element
|
||||
def _render_node(self, node_id: str, level: int = 0):
|
||||
toggle = mk.mk(
|
||||
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
|
||||
command=self.commands.toggle_node(node_id)
|
||||
)
|
||||
```
|
||||
|
||||
✅ **Correct - Using shortcuts:**
|
||||
|
||||
```python
|
||||
# Button with command
|
||||
button = mk.button("Click me", command=self.commands.do_action())
|
||||
|
||||
# Icon with command
|
||||
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
|
||||
|
||||
# Clickable label with command
|
||||
label = mk.label("Select", command=self.commands.select())
|
||||
```
|
||||
|
||||
❌ **Incorrect - Explicitly calling render():**
|
||||
|
||||
```python
|
||||
def _toggle_node(self, node_id: str):
|
||||
# ...
|
||||
return self.render() # ❌ Don't do this if you have __ft__()!
|
||||
```
|
||||
|
||||
❌ **Incorrect - Not binding command to element:**
|
||||
|
||||
```python
|
||||
# ❌ Command created but not bound to any element
|
||||
toggle = Span("▼", cls="toggle") # No mk.mk()!
|
||||
cmd = self.commands.toggle_node(node_id) # Command exists but not used
|
||||
```
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
1. What HTML does the callback return (via `__ft__()` if present)?
|
||||
2. What is the `target` ID in `.htmx()`?
|
||||
3. Do they match?
|
||||
4. Is the command bound to an element using `mk.mk()` or shortcuts?
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
|
||||
- **Partial update**: Callback returns specific element, target is that element's ID
|
||||
- **Multiple updates**: Use swap OOB with multiple elements returned
|
||||
|
||||
### DEV-9: Error Handling Protocol
|
||||
|
||||
**When errors occur:**
|
||||
|
||||
1. **Explain the problem clearly first**
|
||||
2. **Do not propose a fix immediately**
|
||||
3. **Wait for validation** that the diagnosis is correct
|
||||
4. Only then propose solutions
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable DEV-8" (do not apply the HTMX alignment rule)
|
||||
- "Enable DEV-8" (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-control` to switch to control development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
15
.claude/commands/reset.md
Normal file
15
.claude/commands/reset.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Reset to Default Mode
|
||||
|
||||
You are now back to **default Claude Code mode**.
|
||||
|
||||
Follow the standard Claude Code guidelines without any specific persona or specialized behavior.
|
||||
|
||||
Refer to CLAUDE.md for project-specific architecture and patterns.
|
||||
|
||||
## Available Personas
|
||||
|
||||
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
|
||||
342
.claude/commands/technical-writer.md
Normal file
342
.claude/commands/technical-writer.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Technical Writer Mode
|
||||
|
||||
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
|
||||
|
||||
## 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
|
||||
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
- Code examples for end users
|
||||
- API usage documentation (not API reference)
|
||||
|
||||
## What You Don't Handle
|
||||
|
||||
- Docstrings in code (handled by developers)
|
||||
- Internal architecture documentation
|
||||
- Code comments
|
||||
- CLAUDE.md (handled by developers)
|
||||
|
||||
## Technical Writer Rules (TW)
|
||||
|
||||
### TW-1: Standard Documentation Structure
|
||||
|
||||
Every component documentation MUST follow this structure in order:
|
||||
|
||||
| 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 |
|
||||
|
||||
**Introduction template:**
|
||||
```markdown
|
||||
## Introduction
|
||||
|
||||
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
- Use case 3
|
||||
```
|
||||
|
||||
**Quick Start template:**
|
||||
```markdown
|
||||
## Quick Start
|
||||
|
||||
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
|
||||
824
.claude/commands/unit-tester.md
Normal file
824
.claude/commands/unit-tester.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# Unit Tester Mode
|
||||
|
||||
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write comprehensive unit tests for existing code by:
|
||||
1. Analyzing the code to understand its behavior
|
||||
2. Identifying test cases (success paths and edge cases)
|
||||
3. Proposing test plan for validation
|
||||
4. Implementing tests only after approval
|
||||
|
||||
## Unit Test Rules (UTR)
|
||||
|
||||
### UTR-1: Test Analysis Before Implementation
|
||||
|
||||
Before writing any tests:
|
||||
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` → `tests/foo/test_bar.py`)
|
||||
2. **Analyze the code thoroughly** - Read and understand the implementation
|
||||
3. **If tests exist**: Identify what's already covered and what's missing
|
||||
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
|
||||
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
|
||||
6. **Wait for validation** - Only proceed after explicit approval
|
||||
|
||||
### UTR-2: Test Naming Conventions
|
||||
|
||||
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
|
||||
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### UTR-3: Use Functions, Not Classes (Default)
|
||||
|
||||
- Use **functions** for tests by default
|
||||
- Only use classes when inheritance or grouping is required (see UTR-10)
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
### UTR-4: Do NOT Test Python Built-ins
|
||||
|
||||
**Do NOT test Python's built-in functionality.**
|
||||
|
||||
❌ **Bad example - Testing Python list behavior:**
|
||||
```python
|
||||
def test_i_can_add_child_to_node(self):
|
||||
"""Test that we can add a child ID to the children list."""
|
||||
parent_node = TreeNode(label="Parent", type="folder")
|
||||
child_id = "child_123"
|
||||
|
||||
parent_node.children.append(child_id) # Just testing list.append()
|
||||
|
||||
assert child_id in parent_node.children # Just testing list membership
|
||||
```
|
||||
|
||||
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
|
||||
|
||||
✅ **Good example - Testing business logic:**
|
||||
```python
|
||||
def test_i_can_add_child_node(self, root_instance):
|
||||
"""Test adding a child node to a parent."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
|
||||
|
||||
assert child.id in tree_view._state.items # Verify state updated
|
||||
assert child.id in parent.children # Verify relationship established
|
||||
assert child.parent == parent.id # Verify bidirectional link
|
||||
```
|
||||
|
||||
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
|
||||
|
||||
**Other examples of what NOT to test:**
|
||||
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
|
||||
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
|
||||
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
|
||||
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
|
||||
|
||||
### UTR-5: Test Business Logic Only
|
||||
|
||||
**What TO test:**
|
||||
- Your business logic and algorithms
|
||||
- Your validation rules
|
||||
- Your state transformations
|
||||
- Your integration between components
|
||||
- Your error handling for invalid inputs
|
||||
- Your side effects (database updates, command registration, etc.)
|
||||
|
||||
### UTR-6: Test Coverage Requirements
|
||||
|
||||
For each code element, consider testing:
|
||||
|
||||
**Functions/Methods:**
|
||||
- Valid inputs (typical use cases)
|
||||
- Edge cases (empty values, None, boundaries)
|
||||
- Error conditions (invalid inputs, exceptions)
|
||||
- Return values and side effects
|
||||
|
||||
**Classes:**
|
||||
- Initialization (default values, custom values)
|
||||
- State management (attributes, properties)
|
||||
- Methods (all public methods)
|
||||
- Integration (interactions with other classes)
|
||||
|
||||
**Components (Controls):**
|
||||
- Creation and initialization
|
||||
- State changes
|
||||
- Commands and their effects
|
||||
- Rendering (if applicable)
|
||||
- Edge cases and error conditions
|
||||
|
||||
### UTR-7: Ask Questions One at a Time
|
||||
|
||||
**Ask questions to clarify understanding:**
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume behavior - always verify understanding
|
||||
|
||||
### UTR-8: Communication Language
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### UTR-9: Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
- Use Google or NumPy docstring format
|
||||
- Every test should have a clear docstring explaining what it verifies
|
||||
- Include type hints where applicable
|
||||
|
||||
### UTR-10: Test File Organization
|
||||
|
||||
**File paths:**
|
||||
- Always specify the full file path when creating test files
|
||||
- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py`
|
||||
|
||||
**Example:**
|
||||
```
|
||||
✅ Creating: tests/core/test_new_feature.py
|
||||
✅ Modifying: tests/controls/test_treeview.py
|
||||
```
|
||||
|
||||
**Test organization for Controls:**
|
||||
|
||||
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
|
||||
|
||||
```python
|
||||
class TestControlBehaviour:
|
||||
"""Tests for control behavior and logic."""
|
||||
|
||||
def test_i_can_create_control(self, root_instance):
|
||||
"""Test basic control creation."""
|
||||
control = MyControl(root_instance)
|
||||
assert control is not None
|
||||
|
||||
def test_i_can_update_state(self, root_instance):
|
||||
"""Test state management."""
|
||||
# Test state changes, data updates, etc.
|
||||
pass
|
||||
|
||||
class TestControlRender:
|
||||
"""Tests for control HTML rendering."""
|
||||
|
||||
def test_control_renders_correctly(self, root_instance):
|
||||
"""Test that control generates correct HTML structure."""
|
||||
# Test HTML output, attributes, classes, etc.
|
||||
pass
|
||||
|
||||
def test_control_renders_with_custom_config(self, root_instance):
|
||||
"""Test rendering with custom configuration."""
|
||||
# Test different rendering scenarios
|
||||
pass
|
||||
```
|
||||
|
||||
**Why separate behaviour and render tests:**
|
||||
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
|
||||
- **Render tests**: Focus on HTML structure, attributes, and visual representation
|
||||
- **Clarity**: Makes it clear what aspect of the control is being tested
|
||||
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
|
||||
|
||||
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
|
||||
|
||||
### UTR-11: Required Reading for Control Render Tests
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
|
||||
|
||||
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
|
||||
|
||||
**Mandatory reading:** `docs/testing_rendered_components.md`
|
||||
|
||||
**What you must master:**
|
||||
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
|
||||
- **`find(ft, expected)`** - How to search for elements within an HTML tree
|
||||
- **Predicates** - How to test patterns instead of exact values:
|
||||
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
|
||||
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
|
||||
- **Error messages** - How to read `^^^` markers to understand differences
|
||||
- **Key principle** - Test only what matters, ignore the rest
|
||||
|
||||
**Without this reading, you cannot write correct render tests.**
|
||||
|
||||
---
|
||||
|
||||
### **TEST FILE STRUCTURE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
|
||||
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
|
||||
|
||||
**Why:**
|
||||
- Gives immediate overview of the structure
|
||||
- Facilitates understanding for new contributors
|
||||
- Quickly detects major structural changes
|
||||
- Serves as living documentation of HTML architecture
|
||||
|
||||
**Test format:**
|
||||
```python
|
||||
def test_i_can_render_component_with_no_data(self, component):
|
||||
"""Test that Component renders with correct global structure."""
|
||||
html = component.render()
|
||||
expected = Div(
|
||||
Div(id=f"{component.get_id()}-controller"), # controller
|
||||
Div(id=f"{component.get_id()}-header"), # header
|
||||
Div(id=f"{component.get_id()}-content"), # content
|
||||
id=component.get_id(),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simple test with only IDs of main sections
|
||||
- Inline comments to identify each section
|
||||
- No detailed verification of attributes (classes, content, etc.)
|
||||
- This test must be the first in the `TestComponentRender` class
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.2: Break down complex tests into explicit steps**
|
||||
|
||||
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
|
||||
|
||||
**Why:**
|
||||
- Facilitates debugging (you know exactly which step fails)
|
||||
- Improves test readability
|
||||
- Allows validating structure level by level
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
||||
"""Test that content wrapper shows active tab content."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# Step 1: Validate wrapper global structure
|
||||
expected = Div(
|
||||
Div(), # tab content, tested in step 2
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper",
|
||||
cls=Contains("mf-tab-content-wrapper"),
|
||||
)
|
||||
assert matches(wrapper, expected)
|
||||
|
||||
# Step 2: Extract and validate specific content
|
||||
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
expected = Div(
|
||||
Div("My Content"), # <= actual content
|
||||
cls=Contains("mf-tab-content"),
|
||||
)
|
||||
assert matches(tab_content, expected)
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Step 1: Global structure with empty `Div()` + comment for children tested after
|
||||
- Step 2+: Extraction with `find_one()` + detailed validation
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.3: Three-step pattern for simple tests**
|
||||
|
||||
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
|
||||
|
||||
**The three steps:**
|
||||
1. **Extract the element to test** with `find_one()` or `find()` from the global render
|
||||
2. **Define the expected structure** with `expected = ...`
|
||||
3. **Compare** with `assert matches(element, expected)`
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_header_has_two_sides(self, layout):
|
||||
"""Test that there is a left and right header section."""
|
||||
# Step 1: Extract the element to test
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Step 2: Define the expected structure
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(header, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SEARCH FOR ELEMENTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
|
||||
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
|
||||
|
||||
**Why:** More robust, faster, and targeted (an ID is unique).
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - search by ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
|
||||
**Principle:**
|
||||
- `find_one()`: When you search for a unique element and want to test its complete structure
|
||||
- `find()`: When you search for multiple elements or want to count/verify their presence
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ GOOD - find for counting
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SPECIFY EXPECTED STRUCTURE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
|
||||
**Principle:**
|
||||
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
|
||||
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
|
||||
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ GOOD - Contains for style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ AVOID - exact class test
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ AVOID - exact complete style test
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
|
||||
|
||||
**Difference between the two:**
|
||||
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
|
||||
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
|
||||
3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
|
||||
4. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
|
||||
**The `wrapper` parameter:**
|
||||
|
||||
Different `mk` helpers use different wrappers for icons:
|
||||
|
||||
| Helper method | Wrapper element | TestIcon usage |
|
||||
|---------------|-----------------|----------------|
|
||||
| `mk.icon(my_icon)` | `<div>` | `TestIcon("name")` |
|
||||
| `mk.label("Text", icon=my_icon)` | `<span>` | `TestIcon("name", wrapper="span")` |
|
||||
| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
|
||||
|
||||
**The `name` parameter:**
|
||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
||||
- **`name=""`** (empty string): Validates **any icon**
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Example 1: Icon via mk.icon() - wrapper is Div (default)
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><svg .../></div>
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: Icon via mk.label() - wrapper is Span
|
||||
# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
|
||||
# Rendered: <label><span><svg .../></span><span>Back</span></label>
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
|
||||
|
||||
# Example 3: Direct icon (used without helper)
|
||||
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||
# Rendered: <span><svg .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# Example 4: Verify any wrapped icon
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
cls=Contains("icon-wrapper")
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`:
|
||||
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
|
||||
2. Check if there's no wrapper at all → try `TestIconNotStr()`
|
||||
3. The error message will show you the actual structure
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ AVOID - testing all script content
|
||||
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
|
||||
|
||||
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
|
||||
|
||||
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
# ❌ FAILS - Form() has default enctype that may not exist in actual form
|
||||
form = find_one(details, Form()) # AssertionError: Found 0 elements
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
# ✅ WORKS - Remove the default enctype attribute
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
form = find_one(details, expected_form)
|
||||
```
|
||||
|
||||
**Alternative - Search with specific attribute:**
|
||||
```python
|
||||
# ✅ ALSO WORKS - Search by a known attribute
|
||||
form = find_one(details, Form(cls=Contains("my-form-class")))
|
||||
# But still need to delete enctype if Form() is used as pattern
|
||||
```
|
||||
|
||||
**Complete example:**
|
||||
```python
|
||||
def test_column_details_contains_form(self, component):
|
||||
"""Test that column details contains a form with required fields."""
|
||||
details = component.mk_column_details(col_def)
|
||||
|
||||
# Create Form pattern and remove default enctype
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
|
||||
form = find_one(details, expected_form)
|
||||
assert form is not None
|
||||
|
||||
# Now search within the found form
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
```
|
||||
|
||||
**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
|
||||
Why these elements matter:
|
||||
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
|
||||
- _id: Essential for layout identification and resizer initialization
|
||||
- cls="mf-layout": Root CSS class for layout styling
|
||||
"""
|
||||
expected = Div(...)
|
||||
assert matches(layout.render(), expected)
|
||||
|
||||
def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
"""Test that left drawer renders with correct classes when open.
|
||||
|
||||
Why these elements matter:
|
||||
- _id: Required for targeting drawer in HTMX updates
|
||||
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
|
||||
- style Contains width: Drawer width must be applied for sizing
|
||||
"""
|
||||
layout._state.left_drawer_open = True
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
expected = Div(
|
||||
_id=f"{layout._id}_ld",
|
||||
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
|
||||
style=Contains("width: 250px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
|
||||
- No need to follow rigid wording
|
||||
- What matters is the **justification of the choice**, not the format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# ✅ GOOD - explanatory message
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
# ❌ AVOID - no message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **OTHER IMPORTANT RULES**
|
||||
|
||||
---
|
||||
|
||||
**Mandatory render test rules:**
|
||||
|
||||
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
|
||||
|
||||
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||
- First line: Brief description of what is being tested
|
||||
- Blank line
|
||||
- Justification section explaining why tested elements matter (see UTR-11.10)
|
||||
- List of important elements/attributes being tested with explanations (in English)
|
||||
|
||||
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||
|
||||
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
|
||||
|
||||
5. **Test organization for Controls**: Organize tests into thematic classes:
|
||||
- `TestControlBehaviour`: Tests for control behavior and logic
|
||||
- `TestControlRender`: Tests for control HTML rendering
|
||||
|
||||
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
|
||||
```python
|
||||
class TestControlRender:
|
||||
@pytest.fixture
|
||||
def layout(self, root_instance):
|
||||
return Layout(root_instance, app_name="Test App")
|
||||
|
||||
def test_something(self, layout):
|
||||
# layout is injected automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
|
||||
**Test file structure**
|
||||
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
|
||||
- **UTR-11.2**: Break down complex tests into numbered steps
|
||||
- **UTR-11.3**: Three-step pattern for simple tests
|
||||
|
||||
**How to search**
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
|
||||
**How to specify**
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||
|
||||
**How to document**
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
---
|
||||
|
||||
**When proposing render tests:**
|
||||
- Reference specific patterns from the documentation
|
||||
- Explain why you chose to test certain elements and not others
|
||||
- Justify the use of predicates vs exact values
|
||||
- Always include justification documentation (see UTR-11.10)
|
||||
|
||||
---
|
||||
|
||||
### UTR-12: Analyze Execution Flow Before Writing Tests
|
||||
|
||||
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
|
||||
|
||||
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Test: "content_is_cached_after_first_retrieval"
|
||||
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
|
||||
Conclusion: Cache is already filled after create_tab, test would be redundant
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Identify the method being tested
|
||||
2. Trace all method calls it makes
|
||||
3. Identify state changes at each step
|
||||
4. Verify your assumptions about what the test should validate
|
||||
5. Only then write the test
|
||||
|
||||
---
|
||||
|
||||
### UTR-13: Prefer matches() for Content Verification
|
||||
|
||||
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
|
||||
|
||||
**Why:** More robust, clearer error messages, consistent with render test patterns.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ FRAGILE - string matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert "Tab not found" in str(result)
|
||||
|
||||
# ✅ ROBUST - structural matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert matches(result, Div('Tab not found.'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-14: Know FastHTML Attribute Names
|
||||
|
||||
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
|
||||
|
||||
**Key differences:**
|
||||
- Use `attrs.get('class')` not `attrs.get('cls')`
|
||||
- Use `attrs.get('id')` for the ID
|
||||
- Prefer `matches()` with predicates to avoid direct attribute access
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ WRONG - Python parameter name
|
||||
classes = element.attrs.get('cls', '') # Returns None or ''
|
||||
|
||||
# ✅ CORRECT - HTML attribute name
|
||||
classes = element.attrs.get('class', '') # Returns actual classes
|
||||
|
||||
# ✅ BETTER - Use predicates with matches()
|
||||
expected = Div(cls=Contains("active"))
|
||||
assert matches(element, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-15: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
3. **Analyze code** - Read and understand implementation
|
||||
4. **Trace execution flow** - Apply UTR-12 to understand side effects
|
||||
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
6. **Propose test plan** - List new/missing tests with brief explanations
|
||||
7. **Wait for approval** - User validates the test plan
|
||||
8. **Implement tests** - Write all approved tests
|
||||
9. **Verify** - Ensure tests follow naming conventions and structure
|
||||
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
---
|
||||
|
||||
### UTR-16: Propose Parameterized Tests
|
||||
|
||||
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
|
||||
|
||||
**When to parameterize:**
|
||||
- Tests that follow the same pattern with different input values
|
||||
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
|
||||
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
|
||||
- Tests that validate the same method with different valid inputs
|
||||
|
||||
**How to identify candidates:**
|
||||
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
|
||||
2. Look for tests that have identical structure but different parameters
|
||||
3. Look for combinatorial scenarios (side × state combinations)
|
||||
|
||||
**How to propose:**
|
||||
In your test plan, explicitly show:
|
||||
1. The individual tests that would be written without parameterization
|
||||
2. The parameterized version with all test cases
|
||||
3. The reduction in test count
|
||||
|
||||
**Example proposal:**
|
||||
|
||||
```
|
||||
**Without parameterization (4 tests):**
|
||||
- test_i_can_toggle_left_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_left_panel_from_hidden_to_visible
|
||||
- test_i_can_toggle_right_panel_from_visible_to_hidden
|
||||
- test_i_can_toggle_right_panel_from_hidden_to_visible
|
||||
|
||||
**With parameterization (1 test, 4 cases):**
|
||||
@pytest.mark.parametrize("side, initial, expected", [
|
||||
("left", True, False),
|
||||
("left", False, True),
|
||||
("right", True, False),
|
||||
("right", False, True),
|
||||
])
|
||||
def test_i_can_toggle_panel_visibility(...)
|
||||
|
||||
**Result:** 1 test instead of 4, same coverage
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Reduces code duplication
|
||||
- Makes it easier to add new test cases
|
||||
- Improves maintainability
|
||||
- Makes the test matrix explicit
|
||||
|
||||
---
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
|
||||
- "Enable UTR-4" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and testing 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 `/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
569
.claude/skills/developer-control/SKILL.md
Normal file
569
.claude/skills/developer-control/SKILL.md
Normal file
@@ -0,0 +1,569 @@
|
||||
---
|
||||
name: developer-control
|
||||
description: Developer Control Mode - specialized for developing UI controls in the MyFastHtml controls directory. Use when creating or modifying controls (DataGrid, TreeView, Dropdown, etc.).
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
> **Announce immediately:** Start your response with "**[Developer Control Mode activated]**" before doing anything else.
|
||||
|
||||
# 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.handle_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(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-20: HTMX Ajax Requests
|
||||
|
||||
**Always specify a `target` in HTMX ajax requests.**
|
||||
|
||||
```javascript
|
||||
// ❌ INCORRECT: Without target, HTMX doesn't know where to swap the response
|
||||
htmx.ajax('POST', '/url', {
|
||||
values: {param: value}
|
||||
});
|
||||
|
||||
// ✅ CORRECT: Explicitly specify the target
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#element-id',
|
||||
values: {param: value}
|
||||
});
|
||||
```
|
||||
|
||||
**Exception:** Response contains elements with `hx-swap-oob="true"`.
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-21: HTMX Swap Modes and Event Listeners
|
||||
|
||||
**`hx-on::after-settle` only works when the swapped element replaces the target (`outerHTML`).**
|
||||
|
||||
```javascript
|
||||
// ❌ INCORRECT: innerHTML (default) nests the returned element
|
||||
// The hx-on::after-settle attribute on the returned element is never processed
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element'
|
||||
// swap: 'innerHTML' is the default
|
||||
});
|
||||
|
||||
// ✅ CORRECT: outerHTML replaces the entire element
|
||||
// The hx-on::after-settle attribute on the returned element works
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- `innerHTML`: replaces **content** → `<div id="X"><div id="X" hx-on::...>new</div></div>` (duplicate ID)
|
||||
- `outerHTML`: replaces **element** → `<div id="X" hx-on::...>new</div>` (correct)
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-22: Reinitializing Event Listeners
|
||||
|
||||
**After an HTMX swap, event listeners attached via JavaScript are lost and must be reinitialized.**
|
||||
|
||||
**Recommended pattern:**
|
||||
|
||||
1. Create a reusable initialization function:
|
||||
```javascript
|
||||
function initMyControl(controlId) {
|
||||
const element = document.getElementById(controlId);
|
||||
// Attach event listeners
|
||||
element.addEventListener('click', handleClick);
|
||||
}
|
||||
```
|
||||
|
||||
2. Call this function after swap via `hx-on::after-settle`:
|
||||
```python
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initMyControl('{self._id}');"
|
||||
}
|
||||
element.attrs.update(extra_attr)
|
||||
```
|
||||
|
||||
**Alternative:** Use event delegation on a stable parent element.
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-23: Avoiding Duplicate IDs with HTMX
|
||||
|
||||
**If the element returned by the server has the same ID as the HTMX target, use `swap: 'outerHTML'`.**
|
||||
|
||||
```python
|
||||
# Server returns an element with id="my-element"
|
||||
def render_partial(self):
|
||||
return Div(id="my-element", ...) # Same ID as target
|
||||
|
||||
# JavaScript must use outerHTML
|
||||
htmx.ajax('POST', '/url', {
|
||||
target: '#my-element',
|
||||
swap: 'outerHTML' # ✅ Replaces the entire element
|
||||
});
|
||||
```
|
||||
|
||||
**Why:** `innerHTML` would create `<div id="X"><div id="X">...</div></div>` (invalid duplicate ID).
|
||||
|
||||
---
|
||||
|
||||
### DEV-CONTROL-24: Pattern extra_attr for HTMX
|
||||
|
||||
**Use the `extra_attr` pattern to add post-swap behaviors.**
|
||||
|
||||
```python
|
||||
def render_partial(self, fragment="default"):
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initControl('{self._id}');",
|
||||
# Other HTMX attributes if needed
|
||||
}
|
||||
|
||||
element = self.mk_element()
|
||||
element.attrs.update(extra_attr)
|
||||
return element
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Reinitialize event listeners
|
||||
- Execute animations
|
||||
- Update other DOM elements
|
||||
- Logging or tracking events
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
251
.claude/skills/developer/SKILL.md
Normal file
251
.claude/skills/developer/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: developer
|
||||
description: Developer Mode - for writing code, implementing features, fixing bugs in the MyFastHtml project. Use this when developing general features (not UI controls).
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
> **Announce immediately:** Start your response with "**[Developer Mode activated]**" before doing anything else.
|
||||
|
||||
# Developer Mode
|
||||
|
||||
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write production-quality code by:
|
||||
|
||||
1. Exploring available options before implementation
|
||||
2. Validating approach with user
|
||||
3. Implementing only after approval
|
||||
4. Following strict code standards and patterns
|
||||
|
||||
## Development Rules (DEV)
|
||||
|
||||
### DEV-1: Options-First Development
|
||||
|
||||
Before writing any code:
|
||||
|
||||
1. **Explain available options first** - Present different approaches to solve the problem
|
||||
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
|
||||
3. **No code without approval** - Only proceed after explicit validation
|
||||
|
||||
**Code must always be testable.**
|
||||
|
||||
### DEV-2: Question-Driven Collaboration
|
||||
|
||||
**Ask questions to clarify understanding or suggest alternative approaches:**
|
||||
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume - always clarify ambiguities
|
||||
|
||||
### DEV-3: Communication Standards
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### DEV-4: Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
|
||||
- Use Google or NumPy docstring format
|
||||
- Document all public functions and classes
|
||||
- Include type hints where applicable
|
||||
|
||||
### DEV-5: Dependency Management
|
||||
|
||||
**When introducing new dependencies:**
|
||||
|
||||
- List all external dependencies explicitly
|
||||
- Propose alternatives using Python standard library when possible
|
||||
- Explain why each dependency is needed
|
||||
|
||||
### DEV-6: Unit Testing with pytest
|
||||
|
||||
**Test naming patterns:**
|
||||
|
||||
- Passing tests: `test_i_can_xxx` - Tests that should succeed
|
||||
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Test structure:**
|
||||
|
||||
- Use **functions**, not classes (unless inheritance is required)
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### DEV-7: File Management
|
||||
|
||||
**Always specify the full file path** when adding or modifying files:
|
||||
|
||||
```
|
||||
✅ Modifying: src/myfasthtml/core/commands.py
|
||||
✅ Creating: tests/core/test_new_feature.py
|
||||
```
|
||||
|
||||
### DEV-8: Command System - HTMX Target-Callback Alignment
|
||||
|
||||
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
|
||||
|
||||
**Two-part requirement:**
|
||||
|
||||
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
|
||||
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
|
||||
|
||||
**Important: FastHTML Auto-Rendering**
|
||||
|
||||
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
|
||||
- FastHTML automatically calls `__ft__()` which returns `render()` for you
|
||||
|
||||
**Binding Commands to Elements**
|
||||
|
||||
Use the `mk` helper from `myfasthtml.controls.helpers`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Generic binding
|
||||
mk.mk(element, cmd)
|
||||
|
||||
# Shortcut for buttons
|
||||
mk.button("Label", command=cmd)
|
||||
|
||||
# Shortcut for icons
|
||||
mk.icon(icon_svg, command=cmd)
|
||||
|
||||
# Shortcut for clickable labels
|
||||
mk.label("Label", command=cmd)
|
||||
|
||||
# Shortcut for dialog buttons
|
||||
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
✅ **Correct - Component with __ft__(), returns self:**
|
||||
|
||||
```python
|
||||
# In Commands class
|
||||
def toggle_node(self, node_id: str):
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner._toggle_node, # Returns self (not self.render()!)
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
# In TreeView class
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
self._state.opened.remove(node_id)
|
||||
else:
|
||||
self._state.opened.append(node_id)
|
||||
return self # FastHTML calls __ft__() automatically
|
||||
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
|
||||
|
||||
# In render method - bind command to element
|
||||
def _render_node(self, node_id: str, level: int = 0):
|
||||
toggle = mk.mk(
|
||||
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
|
||||
command=self.commands.toggle_node(node_id)
|
||||
)
|
||||
```
|
||||
|
||||
✅ **Correct - Using shortcuts:**
|
||||
|
||||
```python
|
||||
# Button with command
|
||||
button = mk.button("Click me", command=self.commands.do_action())
|
||||
|
||||
# Icon with command
|
||||
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
|
||||
|
||||
# Clickable label with command
|
||||
label = mk.label("Select", command=self.commands.select())
|
||||
```
|
||||
|
||||
❌ **Incorrect - Explicitly calling render():**
|
||||
|
||||
```python
|
||||
def _toggle_node(self, node_id: str):
|
||||
# ...
|
||||
return self.render() # ❌ Don't do this if you have __ft__()!
|
||||
```
|
||||
|
||||
❌ **Incorrect - Not binding command to element:**
|
||||
|
||||
```python
|
||||
# ❌ Command created but not bound to any element
|
||||
toggle = Span("▼", cls="toggle") # No mk.mk()!
|
||||
cmd = self.commands.toggle_node(node_id) # Command exists but not used
|
||||
```
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
1. What HTML does the callback return (via `__ft__()` if present)?
|
||||
2. What is the `target` ID in `.htmx()`?
|
||||
3. Do they match?
|
||||
4. Is the command bound to an element using `mk.mk()` or shortcuts?
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
|
||||
- **Partial update**: Callback returns specific element, target is that element's ID
|
||||
- **Multiple updates**: Use swap OOB with multiple elements returned
|
||||
|
||||
### DEV-9: Error Handling Protocol
|
||||
|
||||
**When errors occur:**
|
||||
|
||||
1. **Explain the problem clearly first**
|
||||
2. **Do not propose a fix immediately**
|
||||
3. **Wait for validation** that the diagnosis is correct
|
||||
4. Only then propose solutions
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable DEV-8" (do not apply the HTMX alignment rule)
|
||||
- "Enable DEV-8" (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-control` to switch to control development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/unit-tester` to switch to unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
21
.claude/skills/reset/SKILL.md
Normal file
21
.claude/skills/reset/SKILL.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: reset
|
||||
description: Reset to Default Mode - return to standard Claude Code behavior without personas
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Reset to Default Mode
|
||||
|
||||
You are now back to **default Claude Code mode**.
|
||||
|
||||
Follow the standard Claude Code guidelines without any specific persona or specialized behavior.
|
||||
|
||||
Refer to CLAUDE.md for project-specific architecture and patterns.
|
||||
|
||||
## Available Personas
|
||||
|
||||
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
|
||||
350
.claude/skills/technical-writer/SKILL.md
Normal file
350
.claude/skills/technical-writer/SKILL.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
name: technical-writer
|
||||
description: Technical Writer Mode - for writing user-facing documentation (README, usage guides, tutorials, examples). Use when documenting components or features for end users.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
> **Announce immediately:** Start your response with "**[Technical Writer Mode activated]**" before doing anything else.
|
||||
|
||||
# Technical Writer Mode
|
||||
|
||||
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
|
||||
|
||||
## 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
|
||||
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
- Code examples for end users
|
||||
- API usage documentation (not API reference)
|
||||
|
||||
## What You Don't Handle
|
||||
|
||||
- Docstrings in code (handled by developers)
|
||||
- Internal architecture documentation
|
||||
- Code comments
|
||||
- CLAUDE.md (handled by developers)
|
||||
|
||||
## Technical Writer Rules (TW)
|
||||
|
||||
### TW-1: Standard Documentation Structure
|
||||
|
||||
Every component documentation MUST follow this structure in order:
|
||||
|
||||
| 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 |
|
||||
|
||||
**Introduction template:**
|
||||
```markdown
|
||||
## Introduction
|
||||
|
||||
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Use case 1
|
||||
- Use case 2
|
||||
- Use case 3
|
||||
```
|
||||
|
||||
**Quick Start template:**
|
||||
```markdown
|
||||
## Quick Start
|
||||
|
||||
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
|
||||
863
.claude/skills/unit-tester/SKILL.md
Normal file
863
.claude/skills/unit-tester/SKILL.md
Normal file
@@ -0,0 +1,863 @@
|
||||
---
|
||||
name: unit-tester
|
||||
description: Unit Tester Mode - for writing unit tests for existing code in the MyFastHtml project. Use when adding or improving test coverage with pytest.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
> **Announce immediately:** Start your response with "**[Unit Tester Mode activated]**" before doing anything else.
|
||||
|
||||
# Unit Tester Mode
|
||||
|
||||
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml
|
||||
project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write comprehensive unit tests for existing code by:
|
||||
|
||||
1. Analyzing the code to understand its behavior
|
||||
2. Identifying test cases (success paths and edge cases)
|
||||
3. Proposing test plan for validation
|
||||
4. Implementing tests only after approval
|
||||
|
||||
## Unit Test Rules (UTR)
|
||||
|
||||
### UTR-1: Communication Language
|
||||
|
||||
- **Conversations**: French or English (match user's language)
|
||||
- **Code, documentation, comments**: English only
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
|
||||
### UTR-2: Test Analysis Before Implementation
|
||||
|
||||
Before writing any tests:
|
||||
|
||||
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` →
|
||||
`tests/foo/test_bar.py`)
|
||||
2. **Analyze the code thoroughly** - Read and understand the implementation
|
||||
3. **If tests exist**: Identify what's already covered and what's missing
|
||||
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
|
||||
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
|
||||
6. **Wait for validation** - Only proceed after explicit approval
|
||||
|
||||
### UTR-3: Ask Questions One at a Time
|
||||
|
||||
**Ask questions to clarify understanding:**
|
||||
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume behavior - always verify understanding
|
||||
|
||||
### UTR-4: Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
|
||||
- Use Google or NumPy docstring format
|
||||
- Every test should have a clear docstring explaining what it verifies
|
||||
- Include type hints where applicable
|
||||
|
||||
### UTR-5: Test Naming Conventions
|
||||
|
||||
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
|
||||
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### UTR-6: Test File Organization
|
||||
|
||||
**File paths:**
|
||||
|
||||
- Always specify the full file path when creating test files
|
||||
- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py`
|
||||
|
||||
### UTR-7: What to choose, functions or classes during tests?
|
||||
|
||||
- Use **functions** when tests are validating the same concern
|
||||
- Use **classes** when grouping is required, for example
|
||||
- behavior tests and rendering tests can be grouped into TestControlBehaviour and TestControlRender classes
|
||||
- CRUD operation can be grouped into TestCreate, TestRead, TestUpdate, TestDelete classes
|
||||
- When code documentation in the class to test explicitly shows the concerns
|
||||
example
|
||||
```python
|
||||
# ------------------------------------------------------------------
|
||||
# Data initialisation
|
||||
# ------------------------------------------------------------------
|
||||
```
|
||||
or
|
||||
```python
|
||||
# ------------------------------------------------------------------
|
||||
# Formula management
|
||||
# ------------------------------------------------------------------
|
||||
```
|
||||
|
||||
- Never mix functions and classes in the same test file
|
||||
|
||||
### UTR-8: Do NOT Test Python Built-ins
|
||||
|
||||
**Do NOT test Python's built-in functionality.**
|
||||
|
||||
❌ **Bad example - Testing Python list behavior:**
|
||||
|
||||
```python
|
||||
def test_i_can_add_child_to_node(self):
|
||||
"""Test that we can add a child ID to the children list."""
|
||||
parent_node = TreeNode(label="Parent", type="folder")
|
||||
child_id = "child_123"
|
||||
|
||||
parent_node.children.append(child_id) # Just testing list.append()
|
||||
|
||||
assert child_id in parent_node.children # Just testing list membership
|
||||
```
|
||||
|
||||
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
|
||||
|
||||
✅ **Good example - Testing business logic:**
|
||||
|
||||
```python
|
||||
def test_i_can_add_child_node(self, root_instance):
|
||||
"""Test adding a child node to a parent."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
|
||||
|
||||
assert child.id in tree_view._state.items # Verify state updated
|
||||
assert child.id in parent.children # Verify relationship established
|
||||
assert child.parent == parent.id # Verify bidirectional link
|
||||
```
|
||||
|
||||
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
|
||||
|
||||
**Other examples of what NOT to test:**
|
||||
|
||||
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
|
||||
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
|
||||
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
|
||||
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
|
||||
|
||||
### UTR-9: Test Business Logic Only
|
||||
|
||||
**What TO test:**
|
||||
|
||||
- Your business logic and algorithms
|
||||
- Your validation rules
|
||||
- Your state transformations
|
||||
- Your integration between components
|
||||
- Your error handling for invalid inputs
|
||||
- Your side effects (database updates, command registration, etc.)
|
||||
|
||||
### UTR-10: Test Coverage Requirements
|
||||
|
||||
For each code element, consider testing:
|
||||
|
||||
**Functions/Methods:**
|
||||
|
||||
- Valid inputs (typical use cases)
|
||||
- Edge cases (empty values, None, boundaries)
|
||||
- Error conditions (invalid inputs, exceptions)
|
||||
- Return values and side effects
|
||||
|
||||
**Classes:**
|
||||
|
||||
- Initialization (default values, custom values)
|
||||
- State management (attributes, properties)
|
||||
- Methods (all public methods)
|
||||
- Integration (interactions with other classes)
|
||||
|
||||
**Components (Controls):**
|
||||
|
||||
- Creation and initialization
|
||||
- State changes
|
||||
- Commands and their effects
|
||||
- Rendering (if applicable)
|
||||
- Edge cases and error conditions
|
||||
|
||||
---
|
||||
|
||||
### UTR-11: IMPORTANT ! Required Reading for Control Render Tests
|
||||
|
||||
**Test organization for Controls:**
|
||||
|
||||
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
|
||||
|
||||
```python
|
||||
class TestControlBehaviour:
|
||||
"""Tests for control behavior and logic."""
|
||||
|
||||
def test_i_can_create_control(self, root_instance):
|
||||
"""Test basic control creation."""
|
||||
control = MyControl(root_instance)
|
||||
assert control is not None
|
||||
|
||||
def test_i_can_update_state(self, root_instance):
|
||||
"""Test state management."""
|
||||
# Test state changes, data updates, etc.
|
||||
pass
|
||||
|
||||
|
||||
class TestControlRender:
|
||||
"""Tests for control HTML rendering."""
|
||||
|
||||
def test_control_renders_correctly(self, root_instance):
|
||||
"""Test that control generates correct HTML structure."""
|
||||
# Test HTML output, attributes, classes, etc.
|
||||
pass
|
||||
|
||||
def test_control_renders_with_custom_config(self, root_instance):
|
||||
"""Test rendering with custom configuration."""
|
||||
# Test different rendering scenarios
|
||||
pass
|
||||
```
|
||||
|
||||
**Why separate behaviour and render tests:**
|
||||
|
||||
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
|
||||
- **Render tests**: Focus on HTML structure, attributes, and visual representation
|
||||
- **Clarity**: Makes it clear what aspect of the control is being tested
|
||||
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
|
||||
|
||||
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (
|
||||
core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
|
||||
|
||||
#### **UTR-11.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
|
||||
- **MyFastHtml test helpers** - `TestObject`, `TestLabel`, `TestIcon`, `TestIconNotStr`, `TestCommand`, `TestScript`
|
||||
|
||||
**Without this reading, you cannot write correct render tests.**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
|
||||
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test
|
||||
that helps readers understand the general architecture.
|
||||
|
||||
**Why:**
|
||||
|
||||
- Gives immediate overview of the structure
|
||||
- Facilitates understanding for new contributors
|
||||
- Quickly detects major structural changes
|
||||
- Serves as living documentation of HTML architecture
|
||||
|
||||
**Test format:**
|
||||
|
||||
```python
|
||||
def test_i_can_render_component_with_no_data(self, component):
|
||||
"""Test that Component renders with correct global structure."""
|
||||
html = component.render()
|
||||
expected = Div(
|
||||
Div(id=f"{component.get_id()}-controller"), # controller
|
||||
Div(id=f"{component.get_id()}-header"), # header
|
||||
Div(id=f"{component.get_id()}-content"), # content
|
||||
id=component.get_id(),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Simple test with only IDs of main sections
|
||||
- Inline comments to identify each section
|
||||
- No detailed verification of attributes (classes, content, etc.)
|
||||
- This test must be the first in the `TestComponentRender` class
|
||||
|
||||
**Naming exception:** This specific test does NOT follow the `test_i_can_xxx` pattern (UTR-5). Use a descriptive name like `test_empty_layout_is_rendered()` instead. All other render tests follow UTR-5 normally.
|
||||
|
||||
**Test order:**
|
||||
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.14)
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.2: Three-step pattern for simple tests**
|
||||
|
||||
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
|
||||
|
||||
**The three steps:**
|
||||
|
||||
1. **Extract the element to test** with `find_one()` or `find()` from the global render
|
||||
2. **Define the expected structure** with `expected = ...`
|
||||
3. **Compare** with `assert matches(element, expected)`
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def test_header_has_two_sides(self, layout):
|
||||
"""Test that there is a left and right header section."""
|
||||
# Step 1: Extract the element to test
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Step 2: Define the expected structure
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(header, expected)
|
||||
```
|
||||
|
||||
#### **UTR-11.3: 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 `Div()` + comment for children tested after
|
||||
- Step 2+: Extraction with `find_one()` + detailed validation
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
|
||||
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
|
||||
|
||||
**Why:** More robust, faster, and targeted (an ID is unique).
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
# ✅ GOOD - search by ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
|
||||
**Principle:**
|
||||
|
||||
- `find_one()`: When you search for a unique element and want to test its complete structure
|
||||
- `find()`: When you search for multiple elements or want to count/verify their presence
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ GOOD - find for counting
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
|
||||
**Principle:**
|
||||
|
||||
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
|
||||
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
|
||||
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ GOOD - Contains for style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ AVOID - exact class test
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ AVOID - exact complete style test
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code. See `docs/testing_rendered_components.md` section 7 for full documentation.
|
||||
|
||||
**How to choose — read the source code first:**
|
||||
|
||||
| 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**
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`:
|
||||
|
||||
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
|
||||
2. Check if there's no wrapper at all → try `TestIconNotStr()`
|
||||
3. The error message will show you the actual structure
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script. See `docs/testing_rendered_components.md` section 9 for full documentation.
|
||||
|
||||
```python
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ AVOID - testing all script content
|
||||
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
|
||||
|
||||
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute.
|
||||
When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
|
||||
|
||||
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
|
||||
|
||||
**Problem:**
|
||||
|
||||
```python
|
||||
# ❌ FAILS - Form() has default enctype that may not exist in actual form
|
||||
form = find_one(details, Form()) # AssertionError: Found 0 elements
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```python
|
||||
# ✅ WORKS - Remove the default enctype attribute
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
form = find_one(details, expected_form)
|
||||
```
|
||||
|
||||
**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.10: Test Documentation - Justify the choice of tested elements**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or
|
||||
attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation
|
||||
of why what is tested is relevant**.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
|
||||
Why these elements matter:
|
||||
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
|
||||
- _id: Essential for layout identification and resizer initialization
|
||||
- cls="mf-layout": Root CSS class for layout styling
|
||||
"""
|
||||
expected = Div(...)
|
||||
assert matches(layout.render(), expected)
|
||||
|
||||
|
||||
def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
"""Test that left drawer renders with correct classes when open.
|
||||
|
||||
Why these elements matter:
|
||||
- _id: Required for targeting drawer in HTMX updates
|
||||
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
|
||||
- style Contains width: Drawer width must be applied for sizing
|
||||
"""
|
||||
layout._state.left_drawer_open = True
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
expected = Div(
|
||||
_id=f"{layout._id}_ld",
|
||||
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
|
||||
style=Contains("width: 250px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
|
||||
- No need to follow rigid wording
|
||||
- What matters is the **justification of the choice**, not the format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is
|
||||
expected.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
# ✅ GOOD - explanatory message
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
# ❌ AVOID - no message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.12: No inline comments in expected structures**
|
||||
|
||||
**Principle:** Do NOT add comments on each line of the expected structure. The only exception is the global structure test (UTR-11.1) where inline comments identify each section.
|
||||
|
||||
```python
|
||||
# ✅ GOOD - no inline noise
|
||||
expected = Div(
|
||||
Div(id=f"{layout._id}-header"),
|
||||
Div(id=f"{layout._id}-content"),
|
||||
Div(id=f"{layout._id}-footer"),
|
||||
)
|
||||
|
||||
# ❌ AVOID - inline comments everywhere
|
||||
expected = Div(
|
||||
Div(id=f"{layout._id}-header"), # header
|
||||
Div(id=f"{layout._id}-content"), # content
|
||||
Div(id=f"{layout._id}-footer"), # footer
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.13: Use `TestObject(ComponentClass)` to test component presence**
|
||||
|
||||
**Principle:** When a component renders another component as a child, use `TestObject(ComponentClass)` to verify its presence without coupling to its internal rendering.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches, find, TestObject
|
||||
from myfasthtml.controls.Search import Search
|
||||
|
||||
# ✅ GOOD - test presence without coupling to internal rendering
|
||||
expected = Div(
|
||||
TestObject(Search),
|
||||
id=f"{toolbar._id}"
|
||||
)
|
||||
assert matches(toolbar.render(), expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.14: Use pytest fixtures in `TestControlRender`**
|
||||
|
||||
**Principle:** In `TestControlRender`, always use a pytest fixture to create the control instance. This avoids duplicating setup code across tests.
|
||||
|
||||
```python
|
||||
class TestControlRender:
|
||||
@pytest.fixture
|
||||
def layout(self, root_instance):
|
||||
return Layout(root_instance, app_name="Test App")
|
||||
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
# layout is injected automatically by pytest
|
||||
assert matches(layout.render(), ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 15 UTR-11 sub-rules**
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
|
||||
**Test file structure**
|
||||
|
||||
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST, naming exception to UTR-5)
|
||||
- **UTR-11.2**: Three-step pattern for simple tests
|
||||
- **UTR-11.3**: Break down complex tests into numbered steps
|
||||
|
||||
**How to search**
|
||||
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
|
||||
**How to specify**
|
||||
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||
- **UTR-11.13**: `TestObject(ComponentClass)` to test component presence
|
||||
|
||||
**How to document**
|
||||
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
**Code style**
|
||||
|
||||
- **UTR-11.12**: No inline comments in expected structures
|
||||
- **UTR-11.14**: Use pytest fixtures in `TestControlRender`
|
||||
|
||||
---
|
||||
|
||||
### 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. **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
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
|
||||
- "Enable UTR-4" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and testing 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 `/technical-writer` to switch to documentation mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
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
|
||||
|
||||
5
.idea/MyFastHtml.iml
generated
5
.idea/MyFastHtml.iml
generated
@@ -4,6 +4,11 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.myFastHtmlDb" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/benchmarks" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/examples" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/images" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/src/.myFastHtmlDb" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12.3 WSL (Ubuntu-24.04): (/home/kodjo/.virtualenvs/MyFastHtml/bin/python)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
466
CLAUDE.md
Normal file
466
CLAUDE.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
MyFastHtml is a Python utility library that simplifies FastHTML application development by providing:
|
||||
- Command management system for client-server interactions
|
||||
- Bidirectional data binding system
|
||||
- Predefined authentication pages and routes
|
||||
- Interactive control helpers
|
||||
- Session-based instance management
|
||||
|
||||
**Tech Stack**: Python 3.12+, FastHTML, HTMX, DaisyUI 5, Tailwind CSS 4
|
||||
|
||||
## Development Workflow and Guidelines
|
||||
|
||||
### Development Process
|
||||
|
||||
**Code must always be testable**. Before writing any code:
|
||||
|
||||
1. **Explain available options first** - Present different approaches to solve the problem
|
||||
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
|
||||
3. **No code without approval** - Only proceed after explicit validation
|
||||
|
||||
### Collaboration Style
|
||||
|
||||
**Ask questions to clarify understanding or suggest alternative approaches:**
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume - always clarify ambiguities
|
||||
|
||||
### Communication
|
||||
|
||||
**Conversations**: French or English
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
- Use Google or NumPy docstring format
|
||||
- Document all public functions and classes
|
||||
- Include type hints where applicable
|
||||
|
||||
### Dependency Management
|
||||
|
||||
**When introducing new dependencies:**
|
||||
- List all external dependencies explicitly
|
||||
- Propose alternatives using Python standard library when possible
|
||||
- Explain why each dependency is needed
|
||||
|
||||
### Unit Testing with pytest
|
||||
|
||||
**Test naming patterns:**
|
||||
- Passing tests: `test_i_can_xxx` - Tests that should succeed
|
||||
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Test structure:**
|
||||
- Use **functions**, not classes (unless inheritance is required)
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### File Management
|
||||
|
||||
**Always specify the full file path** when adding or modifying files:
|
||||
```
|
||||
✅ Modifying: src/myfasthtml/core/commands.py
|
||||
✅ Creating: tests/core/test_new_feature.py
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**When errors occur:**
|
||||
1. **Explain the problem clearly first**
|
||||
2. **Do not propose a fix immediately**
|
||||
3. **Wait for validation** that the diagnosis is correct
|
||||
4. Only then propose solutions
|
||||
|
||||
## Available Personas
|
||||
|
||||
This project includes specialized personas (slash commands) for different types of work:
|
||||
|
||||
### `/developer` - Development Mode (Default)
|
||||
**Use for:** Writing code, implementing features, fixing bugs
|
||||
|
||||
Activates the full development workflow with:
|
||||
- Options-first approach before coding
|
||||
- Step-by-step validation
|
||||
- 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
|
||||
|
||||
Focused on creating:
|
||||
- README sections and examples
|
||||
- Usage guides and tutorials
|
||||
- Getting started documentation
|
||||
- Code examples for end users
|
||||
|
||||
**Does NOT handle:**
|
||||
- Docstrings (developer responsibility)
|
||||
- Internal architecture docs
|
||||
- CLAUDE.md updates
|
||||
|
||||
### `/reset` - Default Claude Code
|
||||
**Use for:** Return to standard Claude Code behavior without personas
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/core/test_bindings.py
|
||||
|
||||
# Run specific test
|
||||
pytest tests/core/test_bindings.py::test_function_name
|
||||
|
||||
# Run tests with verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Cleaning
|
||||
```bash
|
||||
# Clean build artifacts and cache
|
||||
make clean
|
||||
|
||||
# Clean package distribution files
|
||||
make clean-package
|
||||
|
||||
# Clean test artifacts (.sesskey, test databases)
|
||||
make clean-tests
|
||||
|
||||
# Clean everything including source artifacts
|
||||
make clean-all
|
||||
```
|
||||
|
||||
### Package Building
|
||||
```bash
|
||||
# Build distribution
|
||||
python -m build
|
||||
|
||||
# Install in development mode
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core System: Commands
|
||||
|
||||
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||
|
||||
**Key classes:**
|
||||
- `Command`: Base class for all commands with HTMX integration
|
||||
- `Command`: Standard command that executes a Python callable
|
||||
- `LambdaCommand`: Inline command for simple operations
|
||||
- `CommandsManager`: Global registry for command execution
|
||||
|
||||
**How commands work:**
|
||||
1. Create command with action: `cmd = Command("name", "description", callable)`
|
||||
2. Command auto-registers with `CommandsManager`
|
||||
3. `cmd.get_htmx_params()` generates HTMX attributes (`hx-post`, `hx-vals`)
|
||||
4. HTMX posts to `/myfasthtml/commands` route with `c_id`
|
||||
5. `CommandsManager` routes to correct command's `execute()` method
|
||||
|
||||
**Command customization:**
|
||||
```python
|
||||
# Change HTMX target and swap
|
||||
cmd.htmx(target="#result", swap="innerHTML")
|
||||
|
||||
# Bind to observable data (disables swap by default)
|
||||
cmd.bind(data_object)
|
||||
```
|
||||
|
||||
### Core System: Bindings
|
||||
|
||||
Bidirectional data binding system connects UI components with Python data objects. Located in `src/myfasthtml/core/bindings.py`.
|
||||
|
||||
**Key concepts:**
|
||||
- **Observable objects**: Use `make_observable()` from myutils to enable change detection
|
||||
- **Three-phase lifecycle**: Create → Activate (bind_ft) → Deactivate
|
||||
- **Detection modes**: How changes are detected (ValueChange, AttributePresence, SelectValueChange)
|
||||
- **Update modes**: How UI updates (ValueChange, AttributePresence, SelectValueChange)
|
||||
- **Data converters**: Transform data between UI and Python representations
|
||||
|
||||
**Binding flow:**
|
||||
1. User changes input → HTMX posts to `/myfasthtml/bindings`
|
||||
2. `Binding.update()` receives form data, updates observable object
|
||||
3. Observable triggers change event → `Binding.notify()`
|
||||
4. All bound UI elements update via HTMX swap-oob
|
||||
|
||||
**Helper usage:**
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Bind input and label to same data
|
||||
input_elt = Input(name="field")
|
||||
label_elt = Label()
|
||||
|
||||
mk.manage_binding(input_elt, Binding(data, "attr"))
|
||||
mk.manage_binding(label_elt, Binding(data, "attr"))
|
||||
```
|
||||
|
||||
**Important binding notes:**
|
||||
- Elements MUST have a `name` attribute to trigger updates
|
||||
- Multiple elements can bind to same data attribute
|
||||
- First binding call uses `init_binding=True` to set initial value
|
||||
- Bindings route through `/myfasthtml/bindings` endpoint
|
||||
|
||||
### Core System: Instances
|
||||
|
||||
Session-scoped instance management system. Located in `src/myfasthtml/core/instances.py`.
|
||||
|
||||
**Key classes:**
|
||||
- `BaseInstance`: Base for all managed instances
|
||||
- `SingleInstance`: One instance per parent per session
|
||||
- `UniqueInstance`: One instance ever per session (singleton-like)
|
||||
- `RootInstance`: Top-level singleton for application
|
||||
- `InstancesManager`: Global registry with session-based isolation
|
||||
|
||||
**Instance creation pattern:**
|
||||
```python
|
||||
# __new__ checks registry before creating
|
||||
# If instance exists with same (session_id, _id), returns existing
|
||||
instance = MyInstance(parent, session, _id="optional")
|
||||
```
|
||||
|
||||
**Automatic ID generation:**
|
||||
- SingleInstance: `snake_case_class_name`
|
||||
- UniqueInstance: `snake_case_class_name`
|
||||
- Regular BaseInstance: `parent_prefix-uuid`
|
||||
|
||||
### Application Setup
|
||||
|
||||
**Main entry point**: `create_app()` in `src/myfasthtml/myfastapp.py`
|
||||
|
||||
```python
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app(
|
||||
daisyui=True, # Include DaisyUI CSS
|
||||
vis=True, # Include vis-network.js
|
||||
protect_routes=True, # Enable auth beforeware
|
||||
mount_auth_app=False, # Mount auth routes
|
||||
base_url=None # Base URL for auth
|
||||
)
|
||||
```
|
||||
|
||||
**What create_app does:**
|
||||
1. Adds MyFastHtml CSS/JS assets via custom static route
|
||||
2. Optionally adds DaisyUI 5 + Tailwind CSS 4
|
||||
3. Optionally adds vis-network.js
|
||||
4. Mounts `/myfasthtml` app for commands and bindings routes
|
||||
5. Optionally sets up auth routes and beforeware
|
||||
6. Creates AuthProxy instance if auth enabled
|
||||
|
||||
### Authentication System
|
||||
|
||||
Located in `src/myfasthtml/auth/`. Integrates with FastAPI backend (myauth package).
|
||||
|
||||
**Key components:**
|
||||
- `auth/utils.py`: JWT helpers, beforeware for route protection
|
||||
- `auth/routes.py`: Login, register, logout routes
|
||||
- `auth/pages/`: LoginPage, RegisterPage, WelcomePage components
|
||||
|
||||
**How auth works:**
|
||||
1. Beforeware checks `access_token` in session before each route
|
||||
2. Auto-refreshes token if < 5 minutes to expiry
|
||||
3. Redirects to `/login` if token invalid/missing
|
||||
4. Protected routes receive `auth` parameter with user info
|
||||
|
||||
**Session structure:**
|
||||
```python
|
||||
{
|
||||
'access_token': 'jwt_token',
|
||||
'refresh_token': 'refresh_token',
|
||||
'user_info': {
|
||||
'email': 'user@example.com',
|
||||
'username': 'user',
|
||||
'roles': ['admin'],
|
||||
'id': 'uuid',
|
||||
'created_at': 'timestamp',
|
||||
'updated_at': 'timestamp'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/myfasthtml/
|
||||
├── myfastapp.py # Main app factory (create_app)
|
||||
├── core/
|
||||
│ ├── commands.py # Command system
|
||||
│ ├── bindings.py # Binding system
|
||||
│ ├── instances.py # Instance management
|
||||
│ ├── utils.py # Utilities, routes app
|
||||
│ ├── constants.py # Routes, constants
|
||||
│ ├── dbmanager.py # Database helpers
|
||||
│ ├── AuthProxy.py # Auth proxy instance
|
||||
│ └── network_utils.py # Network utilities
|
||||
├── controls/
|
||||
│ ├── helpers.py # mk class with UI helpers
|
||||
│ ├── BaseCommands.py # Base command implementations
|
||||
│ ├── Search.py # Search control
|
||||
│ └── Keyboard.py # Keyboard shortcuts
|
||||
├── auth/
|
||||
│ ├── utils.py # JWT, beforeware
|
||||
│ ├── routes.py # Auth routes
|
||||
│ └── pages/ # Login, Register, Welcome pages
|
||||
├── icons/ # Icon libraries (fluent, material, etc.)
|
||||
├── assets/ # CSS/JS files
|
||||
└── test/ # Test utilities
|
||||
|
||||
tests/
|
||||
├── core/ # Core system tests
|
||||
├── testclient/ # TestClient and TestableElement tests
|
||||
├── auth/ # Authentication tests
|
||||
├── controls/ # Control tests
|
||||
└── html/ # HTML component tests
|
||||
```
|
||||
|
||||
## Testing System
|
||||
|
||||
**Custom test client**: `myfasthtml.test.TestClient` extends FastHTML test client
|
||||
|
||||
**Key features:**
|
||||
- `user.open(path)`: Navigate to route
|
||||
- `user.find_element(selector)`: Find element by CSS selector
|
||||
- `user.should_see(text)`: Assert text in response
|
||||
- Returns `TestableElement` objects with component-specific methods
|
||||
|
||||
**Testable element types:**
|
||||
- `TestableInput`: `.send(value)`
|
||||
- `TestableCheckbox`: `.check()`, `.uncheck()`, `.toggle()`
|
||||
- `TestableTextarea`: `.send(value)`, `.append(text)`, `.clear()`
|
||||
- `TestableSelect`: `.select(value)`, `.select_by_text(text)`, `.deselect(value)`
|
||||
- `TestableRange`: `.set(value)`, `.increase()`, `.decrease()`
|
||||
- `TestableRadio`: `.select()`
|
||||
- `TestableButton`: `.click()`
|
||||
- `TestableDatalist`: `.send(value)`, `.select_suggestion(value)`
|
||||
|
||||
**Test pattern:**
|
||||
```python
|
||||
def test_component(user, rt):
|
||||
@rt("/")
|
||||
def index():
|
||||
data = Data("initial")
|
||||
component = Component(name="field")
|
||||
label = Label()
|
||||
|
||||
mk.manage_binding(component, Binding(data))
|
||||
mk.manage_binding(label, Binding(data))
|
||||
|
||||
return component, label
|
||||
|
||||
user.open("/")
|
||||
user.should_see("initial")
|
||||
|
||||
elem = user.find_element("selector")
|
||||
elem.method("new_value")
|
||||
user.should_see("new_value")
|
||||
```
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Creating interactive buttons with commands
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
def action():
|
||||
return "Result"
|
||||
|
||||
cmd = Command("action", "Description", action)
|
||||
button = mk.button("Click", command=cmd)
|
||||
```
|
||||
|
||||
### Bidirectional binding
|
||||
```python
|
||||
from myutils.observable import make_observable
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
data = make_observable(Data("value"))
|
||||
input_elt = Input(name="field")
|
||||
label_elt = Label()
|
||||
|
||||
mk.manage_binding(input_elt, Binding(data, "value"))
|
||||
mk.manage_binding(label_elt, Binding(data, "value"))
|
||||
```
|
||||
|
||||
### Using helpers (mk class)
|
||||
```python
|
||||
# Button with command
|
||||
mk.button("Text", command=cmd, cls="btn-primary")
|
||||
|
||||
# Icon with command
|
||||
mk.icon(icon_svg, size=20, command=cmd)
|
||||
|
||||
# Label with icon
|
||||
mk.label("Text", icon=icon_svg, size="sm")
|
||||
|
||||
# Generic wrapper
|
||||
mk.mk(element, command=cmd, binding=binding)
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Bindings require `name` attribute**: Without it, form data won't include the field
|
||||
2. **Commands auto-register**: Don't manually register with CommandsManager
|
||||
3. **Instances use __new__ caching**: Same (session, id) returns existing instance
|
||||
4. **First binding needs init**: Use `init_binding=True` to set initial value
|
||||
5. **Observable required for bindings**: Use `make_observable()` from myutils
|
||||
6. **Auth routes need base_url**: Pass `base_url` to `create_app()` for proper auth API calls
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Core:**
|
||||
- python-fasthtml: Web framework
|
||||
- myauth: Authentication backend
|
||||
- mydbengine: Database abstraction
|
||||
- myutils: Observable pattern, utilities
|
||||
|
||||
**UI:**
|
||||
- DaisyUI 5: Component library
|
||||
- Tailwind CSS 4: Styling
|
||||
- vis-network: Network visualization
|
||||
|
||||
**Development:**
|
||||
- pytest: Testing framework
|
||||
- httpx: HTTP client for tests
|
||||
- python-dotenv: Environment variables
|
||||
8
Makefile
8
Makefile
@@ -18,11 +18,15 @@ clean-tests:
|
||||
rm -rf .sesskey
|
||||
rm -rf tests/.sesskey
|
||||
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()
|
||||
1273
docs/DataGrid Formatting - User Guide.md
Normal file
1273
docs/DataGrid Formatting - User Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
1736
docs/DataGrid Formatting System.md
Normal file
1736
docs/DataGrid Formatting System.md
Normal file
File diff suppressed because it is too large
Load Diff
116
docs/DataGrid Refactoring.md
Normal file
116
docs/DataGrid Refactoring.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# DataGrid Refactoring
|
||||
|
||||
## Objective
|
||||
|
||||
Clearly separate data management and rendering responsibilities in the DataGrid system.
|
||||
The current architecture mixes data mutation, formula computation, and rendering in the
|
||||
same `DataGrid` class, which complicates cross-table formula management and code reasoning.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
- `DataService` can exist without rendering. The reverse is not true.
|
||||
- All data mutations go through `DataService`.
|
||||
- Columns have two facets: data semantics (`ColumnDefinition`) and UI presentation (`ColumnUiState`).
|
||||
- No more parent hierarchy where avoidable — access via `InstancesManager.get_by_type()`.
|
||||
- The persistence key is `grid_id` (stable), not `table_name` (can change over time).
|
||||
|
||||
---
|
||||
|
||||
## New Classes (`core/data/`)
|
||||
|
||||
### `DataServicesManager` — SingleInstance
|
||||
|
||||
- Owns the `FormulaEngine` (cross-table formula coordination)
|
||||
- Creates `DataService` instances on demand from `DataGridsManager`
|
||||
- Provides access to `DataService` instances by `grid_id`
|
||||
- Provides the resolver callback for `FormulaEngine`: `grid_id → DataStore`
|
||||
|
||||
### `DataService` — companion to `DataGrid`
|
||||
|
||||
- Owns `DataStore` and `list[ColumnDefinition]`
|
||||
- Holds a reference to `DataServicesManager` for `FormulaEngine` access
|
||||
- Methods: `load_dataframe(df)`, `add_row()`, `add_column()`, `set_data(col_id, row_index, value)`
|
||||
- Mutations call `mark_data_changed()` → set dirty flag
|
||||
- `ensure_ready()` → recalculates formulas if dirty (called by `mk_body_content_page()`)
|
||||
- Can exist without any rendering
|
||||
|
||||
### `DataStore` — renamed from `DatagridStore`
|
||||
|
||||
- Pure persistence: `ne_df`, `ns_fast_access`, `ns_row_data`, `ns_total_rows`
|
||||
- `DbObject` with no business logic
|
||||
|
||||
### `ColumnDefinition`
|
||||
|
||||
- Data semantics: `col_id`, `title`, `type`, `formula`, `col_index`
|
||||
|
||||
---
|
||||
|
||||
## Modified Classes
|
||||
|
||||
### `DataGridsRegistry` — streamlined
|
||||
|
||||
- Persistence only: `put()`, `remove()`, `get_all_entries()`
|
||||
- **Loses**: `get_columns()`, `get_column_type()`, `get_column_values()`, `get_row_count()`
|
||||
|
||||
### `DatagridMetadataProvider` — becomes a concrete SingleInstance
|
||||
|
||||
- No longer abstract / interface (only one concrete implementation exists)
|
||||
- Reads from `DataServicesManager` and `DataGridsRegistry`
|
||||
- Holds: `style_presets`, `formatter_presets`, `all_tables_formats`
|
||||
- Exposes: `list_tables()`, `list_columns()`, `list_column_values()`, `get_column_type()`,
|
||||
`list_style_presets()`, `list_format_presets()`
|
||||
|
||||
### `DataGridsManager` — pure UI
|
||||
|
||||
- **Keeps**: `TreeView`, `TabsManager`, document state, `Commands`
|
||||
- **Loses**: `FormulaEngine`, presets, `DatagridMetadataProvider`, `_resolve_store_for_table()`
|
||||
|
||||
### `DataGrid` — pure rendering
|
||||
|
||||
- **Keeps**: `mk_*`, `render()`, `__ft__()`, `_state`, `_settings`
|
||||
- **Keeps**: `_apply_sort()`, `_apply_filter()`, `_get_filtered_df()`
|
||||
- **Loses**: `add_new_row()`, `add_new_column()`, `init_from_dataframe()`,
|
||||
`_recalculate_formulas()`, `_register_existing_formulas()`, `_df_store`
|
||||
- Accesses its `DataService` via its `grid_id`:
|
||||
`InstancesManager.get_by_type(DataServicesManager).get_service(grid_id)`
|
||||
- `mk_body_content_page()` calls `data_service.ensure_ready()` before rendering
|
||||
|
||||
### `DatagridState`
|
||||
|
||||
- `columns` changes from `list[DataGridColumnState]` → `list[ColumnUiState]`
|
||||
- Everything else remains unchanged
|
||||
|
||||
### `DataGridColumnState` — split into two classes
|
||||
|
||||
| Class | Belongs to | Fields |
|
||||
|---|---|---|
|
||||
| `ColumnDefinition` | `DataService` | `col_id`, `title`, `type`, `formula`, `col_index` |
|
||||
| `ColumnUiState` | `DatagridState` | `col_id`, `width`, `visible`, `format` |
|
||||
|
||||
---
|
||||
|
||||
## Structural Fix
|
||||
|
||||
**Current bug**: `mark_data_changed()` is defined in `FormulaEngine` but is never called
|
||||
by `DataGrid`. Formulas are only recalculated defensively at render time.
|
||||
|
||||
**After refactoring**:
|
||||
- Every mutation in `DataService` calls `mark_data_changed()` → dirty flag set
|
||||
- `mk_body_content_page()` calls `data_service.ensure_ready()` → recalculates if dirty
|
||||
- Multiple mutations before a render = a single recalculation
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
- [x] Create `DataStore` (rename `DatagridStore`)
|
||||
- [x] Create `ColumnDefinition`
|
||||
- [x] Create `DataService`
|
||||
- [x] Create `DataServicesManager`
|
||||
- [x] Refactor `DataGridsRegistry` (streamline)
|
||||
- [x] Refactor `DatagridMetadataProvider` (make concrete)
|
||||
- [x] Refactor `DataGridsManager` (pure UI)
|
||||
- [x] Refactor `DataGrid` (pure rendering, split `DataGridColumnState`)
|
||||
- [x] Update tests
|
||||
- [ ] Remove `init_from_dataframe` from `DataGrid` (kept temporarily for transition)
|
||||
- [ ] Full split of `DataGridColumnState` into `ColumnDefinition` + `ColumnUiState` in `DatagridState`
|
||||
589
docs/DataGrid.md
Normal file
589
docs/DataGrid.md
Normal file
@@ -0,0 +1,589 @@
|
||||
# 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 |
|
||||
|
||||
|
||||
## 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)
|
||||
118
docs/Datagrid Tests.md
Normal file
118
docs/Datagrid Tests.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# DataGrid Tests — Backlog
|
||||
|
||||
Source file: `tests/controls/test_datagrid.py`
|
||||
|
||||
Legend: ✅ Done — ⬜ Pending
|
||||
|
||||
---
|
||||
|
||||
## TestDataGridBehaviour
|
||||
|
||||
### Edition flow
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|---|--------|-----------------------------------------------------------|----------------------------------------------------------|
|
||||
| 1 | ⬜ | `test_i_can_convert_edition_value_for_number` | `"3.14"` → `float`, `"5"` → `int` |
|
||||
| 2 | ⬜ | `test_i_can_convert_edition_value_for_bool` | `"true"`, `"1"`, `"yes"` → `True`; others → `False` |
|
||||
| 3 | ⬜ | `test_i_can_convert_edition_value_for_text` | String value returned unchanged |
|
||||
| 4 | ⬜ | `test_i_can_handle_start_edition` | Sets `edition.under_edition` and returns a cell render |
|
||||
| 5 | ⬜ | `test_i_cannot_handle_start_edition_when_already_editing` | Second call while `under_edition` is set is a no-op |
|
||||
| 6 | ⬜ | `test_i_can_handle_save_edition` | Writes value to data service and clears `under_edition` |
|
||||
| 7 | ⬜ | `test_i_cannot_handle_save_edition_when_not_editing` | Returns partial render without touching the data service |
|
||||
|
||||
### Column management
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------------|----------------------------------------------------------------|
|
||||
| 8 | ⬜ | `test_i_can_add_new_column` | Appends column to `_state.columns` and `_columns` |
|
||||
| 9 | ⬜ | `test_i_can_handle_columns_reorder` | Reorders `_state.columns` according to provided list |
|
||||
| 10 | ⬜ | `test_i_can_handle_columns_reorder_ignores_unknown_ids` | Unknown IDs skipped; known columns not in list appended at end |
|
||||
|
||||
### Mouse selection
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-------------------------------------------------|---------------------------------------------------------------|
|
||||
| 11 | ⬜ | `test_i_can_on_mouse_selection_sets_range` | Sets `extra_selected` with `("range", ...)` from two cell IDs |
|
||||
| 12 | ⬜ | `test_i_cannot_on_mouse_selection_when_outside` | `is_inside=False` leaves `extra_selected` unchanged |
|
||||
|
||||
### Key pressed
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|--------------------------------------------------|----------------------------------------------------------------------------------------------|
|
||||
| 13 | ⬜ | `test_i_can_on_key_pressed_enter_starts_edition` | `enter` on selected cell enters edition when `enable_edition=True` and nothing under edition |
|
||||
|
||||
### Click
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------|-----------------------------------------------------------------|
|
||||
| 14 | ⬜ | `test_i_can_on_click_second_click_enters_edition` | Second click on already-selected cell triggers `_enter_edition` |
|
||||
|
||||
### Filtering / sorting
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|--------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| 15 | ⬜ | `test_i_can_filter_grid` | `filter()` updates `_state.filtered`; filtered DataFrame excludes non-matching rows |
|
||||
| 16 | ⬜ | `test_i_can_apply_sort` | `_apply_sort` returns rows in correct order when a sort definition is present |
|
||||
| 17 | ⬜ | `test_i_can_apply_filter_by_column_values` | Column filter (non-FILTER_INPUT) keeps only matching rows |
|
||||
|
||||
### Format rules priority
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|----------------------------------------------------------------------|------------------------------------------------------------------|
|
||||
| 18 | ⬜ | `test_i_can_get_format_rules_cell_level_takes_priority` | Cell format overrides row, column and table format |
|
||||
| 19 | ⬜ | `test_i_can_get_format_rules_row_level_takes_priority_over_column` | Row format overrides column and table when no cell format |
|
||||
| 20 | ⬜ | `test_i_can_get_format_rules_column_level_takes_priority_over_table` | Column format overrides table when no cell or row format |
|
||||
| 21 | ⬜ | `test_i_can_get_format_rules_falls_back_to_table_format` | Table format returned when no cell, row or column format defined |
|
||||
|
||||
---
|
||||
|
||||
## TestDataGridRender
|
||||
|
||||
### Table structure
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 22 | ✅ | `test_i_can_render_table_wrapper` | ID `tw_{id}`, class `dt2-table-wrapper`, 3 sections: selection manager, table, scrollbars |
|
||||
| 23 | ✅ | `test_i_can_render_table` | ID `t_{id}`, class `dt2-table`, 3 containers: header, body wrapper, footer |
|
||||
| 24 | ✅ | `test_i_can_render_table_has_scrollbars` | Scrollbars overlay contains vertical and horizontal tracks |
|
||||
|
||||
### render_partial fragments
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| 25 | ✅ | `test_i_can_render_partial_body` | Returns `(selection_manager, body_wrapper)` — body wrapper has `hx-on::after-settle` |
|
||||
| 26 | ✅ | `test_i_can_render_partial_table` | Returns `(selection_manager, table)` — table has `hx-on::after-settle` |
|
||||
| 27 | ✅ | `test_i_can_render_partial_header` | Returns header with `hx-on::after-settle` containing `setColumnWidth` |
|
||||
| 28 | ✅ | `test_i_can_render_partial_cell_by_pos` | Returns `(selection_manager, cell)` for a specific `(col, row)` position |
|
||||
| 29 | ✅ | `test_i_can_render_partial_cell_with_no_position` | Returns only `(selection_manager,)` when no `pos` or `cell_id` given |
|
||||
|
||||
### Edition cell
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------|
|
||||
| 30 | ⬜ | `test_i_can_render_body_cell_in_edition_mode` | When `edition.under_edition` matches, `mk_body_cell` returns an input cell with class `dt2-cell-edition` |
|
||||
|
||||
### Cell content — search highlighting
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------------------------------------|-----------------------------------------------------------------|
|
||||
| 31 | ⬜ | `test_i_can_render_body_cell_content_with_search_highlight` | Matching keyword produces a `Span` with class `dt2-highlight-1` |
|
||||
| 32 | ⬜ | `test_i_can_render_body_cell_content_with_no_highlight_when_keyword_absent` | Non-matching keyword produces no `dt2-highlight-1` span |
|
||||
|
||||
### Footer
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| 33 | ⬜ | `test_i_can_render_footers_wrapper` | `mk_footers` renders with ID `tf_{id}` and class `dt2-footer` |
|
||||
| 34 | ⬜ | `test_i_can_render_aggregation_cell_sum` | `mk_aggregation_cell` with `FooterAggregation.Sum` renders the sum value |
|
||||
| 35 | ⬜ | `test_i_cannot_render_aggregation_cell_for_hidden_column` | Hidden column returns `Div(cls="dt2-col-hidden")` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Class | Total | ✅ Done | ⬜ Pending |
|
||||
|-------------------------|--------|--------|-----------|
|
||||
| `TestDataGridBehaviour` | 21 | 0 | 21 |
|
||||
| `TestDataGridRender` | 14 | 8 | 6 |
|
||||
| **Total** | **35** | **8** | **27** |
|
||||
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
|
||||
706
docs/HierarchicalCanvasGraph.md
Normal file
706
docs/HierarchicalCanvasGraph.md
Normal file
@@ -0,0 +1,706 @@
|
||||
# HierarchicalCanvasGraph
|
||||
|
||||
## Introduction
|
||||
|
||||
The HierarchicalCanvasGraph component provides a canvas-based hierarchical graph visualization with interactive features. It displays nodes and edges in a tree layout using the Reingold-Tilford algorithm, with built-in support for expand/collapse, zoom/pan, and filtering.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Canvas-based rendering for smooth performance with large graphs
|
||||
- Expand/collapse nodes with children
|
||||
- Zoom and pan with mouse wheel and drag
|
||||
- Search/filter nodes by text, type, or kind
|
||||
- Click to select nodes
|
||||
- Stable zoom maintained on container resize
|
||||
- Persistent state (collapsed nodes, view transform, filters)
|
||||
- Event handlers for node interactions
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Visualizing class hierarchies and inheritance trees
|
||||
- Displaying dependency graphs
|
||||
- Exploring file/folder structures
|
||||
- Showing organizational charts
|
||||
- Debugging object instance relationships
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a simple hierarchy:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import (
|
||||
HierarchicalCanvasGraph,
|
||||
HierarchicalCanvasGraphConf
|
||||
)
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app()
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
root = RootInstance(session)
|
||||
|
||||
# Define nodes and edges
|
||||
nodes = [
|
||||
{"id": "root", "label": "Root", "description": "Base class", "type": "Class", "kind": "base"},
|
||||
{"id": "child1", "label": "Child 1", "description": "First derived class", "type": "Class", "kind": "derived"},
|
||||
{"id": "child2", "label": "Child 2", "description": "Second derived class", "type": "Class", "kind": "derived"},
|
||||
]
|
||||
|
||||
edges = [
|
||||
{"from": "root", "to": "child1"},
|
||||
{"from": "root", "to": "child2"},
|
||||
]
|
||||
|
||||
# Create graph
|
||||
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
|
||||
graph = HierarchicalCanvasGraph(root, conf)
|
||||
|
||||
return Titled("Graph Example", graph)
|
||||
|
||||
serve()
|
||||
```
|
||||
|
||||
This creates a complete hierarchical graph with:
|
||||
|
||||
- Three nodes in a simple parent-child relationship
|
||||
- Automatic tree layout with expand/collapse controls
|
||||
- Built-in zoom/pan navigation
|
||||
- Search filter bar
|
||||
|
||||
**Note:** The graph automatically saves collapsed state and zoom/pan position across sessions.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Filter instances... [x] │ ← Query filter bar
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Root │ [±] │ ← Node label (bold)
|
||||
│ │ Description │ │ ← Description (gray, smaller)
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────┴─────┐ │
|
||||
│ │ │ │
|
||||
│ ┌─────────┐ ┌─────────┐ │ ← Child nodes
|
||||
│ │Child 1 │ │Child 2 │ │
|
||||
│ │Details │ │Details │ │
|
||||
│ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ • Dot grid background │ ← Canvas area
|
||||
│ • Mouse wheel to zoom │
|
||||
│ • Drag to pan │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Creating the Graph
|
||||
|
||||
**Step 1: Define nodes**
|
||||
|
||||
Each node is a dictionary with:
|
||||
|
||||
```python
|
||||
nodes = [
|
||||
{
|
||||
"id": "unique_id", # Required: unique identifier
|
||||
"label": "Display Name", # Required: shown in the node (main line)
|
||||
"description": "Details...", # Optional: shown below label (smaller, gray)
|
||||
"type": "ClassName", # Optional: shown as badge
|
||||
"kind": "category", # Optional: affects border color
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Note:** Nodes with `description` are taller (54px vs 36px) to accommodate the second line of text.
|
||||
|
||||
**Step 2: Define edges**
|
||||
|
||||
Each edge connects two nodes:
|
||||
|
||||
```python
|
||||
edges = [
|
||||
{"from": "parent_id", "to": "child_id"}
|
||||
]
|
||||
```
|
||||
|
||||
**Step 3: Create configuration**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraphConf
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers=None # Optional: dict of event handlers
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Instantiate component**
|
||||
|
||||
```python
|
||||
graph = HierarchicalCanvasGraph(parent, conf, _id="custom-id")
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|-------------------|-----------------------|--------------------------------------------------|---------|
|
||||
| `nodes` | `list[dict]` | List of node dictionaries | - |
|
||||
| `edges` | `list[dict]` | List of edge dictionaries | - |
|
||||
| `events_handlers` | `Optional[dict]` | Event name to Command object mapping | `None` |
|
||||
|
||||
### Node Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|---------------|-------|----------|------------------------------------------------|
|
||||
| `id` | `str` | Yes | Unique node identifier |
|
||||
| `label` | `str` | Yes | Display text shown in the node (main line) |
|
||||
| `description` | `str` | No | Secondary text shown below label (smaller, gray) |
|
||||
| `type` | `str` | No | Type badge shown on node (clickable filter) |
|
||||
| `kind` | `str` | No | Category determining border color/style |
|
||||
|
||||
### Edge Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|----------|-------|----------|--------------------------------|
|
||||
| `from` | `str` | Yes | Source node ID |
|
||||
| `to` | `str` | Yes | Target node ID |
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Filtering
|
||||
|
||||
The component provides three types of filtering:
|
||||
|
||||
**1. Text search** (via filter bar)
|
||||
|
||||
Searches in node `id`, `label`, `type`, and `kind` fields:
|
||||
|
||||
```python
|
||||
# User types in filter bar
|
||||
# Automatically filters matching nodes
|
||||
# Non-matching nodes are dimmed
|
||||
```
|
||||
|
||||
**2. Type filter** (click node badge)
|
||||
|
||||
Click a node's type badge to filter by that type:
|
||||
|
||||
```python
|
||||
# Clicking "Class" badge shows only nodes with type="Class"
|
||||
# Clicking again clears the filter
|
||||
```
|
||||
|
||||
**3. Kind filter** (click node border)
|
||||
|
||||
Click a node's border to filter by kind:
|
||||
|
||||
```python
|
||||
# Clicking border of kind="base" shows only base nodes
|
||||
# Clicking again clears the filter
|
||||
```
|
||||
|
||||
**Filter behavior:**
|
||||
|
||||
- Only one filter type active at a time
|
||||
- Filters dim non-matching nodes (opacity reduced)
|
||||
- Matching nodes and their ancestors remain fully visible
|
||||
- Clear filter by clicking same badge/border or clearing search text
|
||||
|
||||
### Events
|
||||
|
||||
The component supports two event types via `events_handlers`:
|
||||
|
||||
**select_node**: Fired when clicking a node (not the toggle button)
|
||||
|
||||
```python
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
def on_select(node_id=None):
|
||||
print(f"Selected node: {node_id}")
|
||||
return f"Selected: {node_id}"
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"select_node": Command(
|
||||
"SelectNode",
|
||||
"Handle node selection",
|
||||
on_select
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**toggle_node**: Fired when clicking expand/collapse button
|
||||
|
||||
```python
|
||||
def on_toggle(node_id=None):
|
||||
print(f"Toggled node: {node_id}")
|
||||
return "Toggled"
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"toggle_node": Command(
|
||||
"ToggleNode",
|
||||
"Handle node toggle",
|
||||
on_toggle
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
The graph automatically persists:
|
||||
|
||||
**Collapsed nodes:**
|
||||
```python
|
||||
# Get current collapsed state
|
||||
collapsed = graph.get_state().collapsed # List of node IDs
|
||||
|
||||
# Programmatically set collapsed nodes
|
||||
graph.set_collapsed({"node1", "node2"})
|
||||
|
||||
# Toggle a specific node
|
||||
graph.toggle_node("node1")
|
||||
```
|
||||
|
||||
**View transform (zoom/pan):**
|
||||
```python
|
||||
# Get current transform
|
||||
transform = graph.get_state().transform
|
||||
# {"x": 100, "y": 50, "scale": 1.5}
|
||||
```
|
||||
|
||||
**Layout mode:**
|
||||
```python
|
||||
# Get current layout mode
|
||||
mode = graph.get_state().layout_mode # "horizontal" or "vertical"
|
||||
```
|
||||
|
||||
**Current selection:**
|
||||
```python
|
||||
# Get selected node ID
|
||||
selected = graph.get_selected_id() # Returns node ID or None
|
||||
```
|
||||
|
||||
### Layout Modes
|
||||
|
||||
The graph supports two layout orientations:
|
||||
|
||||
- **horizontal**: Nodes flow left-to-right (default)
|
||||
- **vertical**: Nodes flow top-to-bottom
|
||||
|
||||
Users can toggle between modes using the UI controls. The mode is persisted in state.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Class Hierarchy
|
||||
|
||||
Basic inheritance tree visualization:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import (
|
||||
HierarchicalCanvasGraph,
|
||||
HierarchicalCanvasGraphConf
|
||||
)
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app()
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
root = RootInstance(session)
|
||||
|
||||
nodes = [
|
||||
{"id": "object", "label": "Object", "description": "Base class for all objects", "type": "BaseClass", "kind": "builtin"},
|
||||
{"id": "animal", "label": "Animal", "description": "Abstract animal class", "type": "Class", "kind": "abstract"},
|
||||
{"id": "dog", "label": "Dog", "description": "Canine implementation", "type": "Class", "kind": "concrete"},
|
||||
{"id": "cat", "label": "Cat", "description": "Feline implementation", "type": "Class", "kind": "concrete"},
|
||||
]
|
||||
|
||||
edges = [
|
||||
{"from": "object", "to": "animal"},
|
||||
{"from": "animal", "to": "dog"},
|
||||
{"from": "animal", "to": "cat"},
|
||||
]
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
|
||||
graph = HierarchicalCanvasGraph(root, conf)
|
||||
|
||||
return Titled("Class Hierarchy", graph)
|
||||
|
||||
serve()
|
||||
```
|
||||
|
||||
### Example 2: Graph with Event Handlers
|
||||
|
||||
Handling node selection and expansion:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import (
|
||||
HierarchicalCanvasGraph,
|
||||
HierarchicalCanvasGraphConf
|
||||
)
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app()
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
root = RootInstance(session)
|
||||
|
||||
# Event handlers
|
||||
def on_select(node_id=None):
|
||||
return Div(
|
||||
f"Selected: {node_id}",
|
||||
id="selection-info",
|
||||
cls="alert alert-info"
|
||||
)
|
||||
|
||||
def on_toggle(node_id=None):
|
||||
return Div(
|
||||
f"Toggled: {node_id}",
|
||||
id="toggle-info",
|
||||
cls="alert alert-success"
|
||||
)
|
||||
|
||||
# Create commands
|
||||
select_cmd = Command("SelectNode", "Handle selection", on_select)
|
||||
toggle_cmd = Command("ToggleNode", "Handle toggle", on_toggle)
|
||||
|
||||
nodes = [
|
||||
{"id": "root", "label": "Root Module", "description": "Main package entry point", "type": "Module", "kind": "package"},
|
||||
{"id": "sub1", "label": "Submodule A", "description": "Feature A implementation", "type": "Module", "kind": "module"},
|
||||
{"id": "sub2", "label": "Submodule B", "description": "Feature B implementation", "type": "Module", "kind": "module"},
|
||||
{"id": "class1", "label": "ClassA", "description": "Handler for feature A", "type": "Class", "kind": "class"},
|
||||
{"id": "class2", "label": "ClassB", "description": "Handler for feature B", "type": "Class", "kind": "class"},
|
||||
]
|
||||
|
||||
edges = [
|
||||
{"from": "root", "to": "sub1"},
|
||||
{"from": "root", "to": "sub2"},
|
||||
{"from": "sub1", "to": "class1"},
|
||||
{"from": "sub2", "to": "class2"},
|
||||
]
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"select_node": select_cmd,
|
||||
"toggle_node": toggle_cmd
|
||||
}
|
||||
)
|
||||
|
||||
graph = HierarchicalCanvasGraph(root, conf)
|
||||
|
||||
return Titled("Interactive Graph",
|
||||
graph,
|
||||
Div(id="selection-info"),
|
||||
Div(id="toggle-info")
|
||||
)
|
||||
|
||||
serve()
|
||||
```
|
||||
|
||||
### Example 3: Filtered Graph with Type/Kind Badges
|
||||
|
||||
Graph with multiple node types for filtering:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import (
|
||||
HierarchicalCanvasGraph,
|
||||
HierarchicalCanvasGraphConf
|
||||
)
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app()
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
root = RootInstance(session)
|
||||
|
||||
nodes = [
|
||||
# Controllers
|
||||
{"id": "ctrl1", "label": "UserController", "description": "Handles user requests", "type": "Controller", "kind": "web"},
|
||||
{"id": "ctrl2", "label": "AdminController", "description": "Admin panel endpoints", "type": "Controller", "kind": "web"},
|
||||
|
||||
# Services
|
||||
{"id": "svc1", "label": "AuthService", "description": "Authentication logic", "type": "Service", "kind": "business"},
|
||||
{"id": "svc2", "label": "EmailService", "description": "Email notifications", "type": "Service", "kind": "infrastructure"},
|
||||
|
||||
# Repositories
|
||||
{"id": "repo1", "label": "UserRepo", "description": "User data access", "type": "Repository", "kind": "data"},
|
||||
{"id": "repo2", "label": "LogRepo", "description": "Logging data access", "type": "Repository", "kind": "data"},
|
||||
]
|
||||
|
||||
edges = [
|
||||
{"from": "ctrl1", "to": "svc1"},
|
||||
{"from": "ctrl2", "to": "svc1"},
|
||||
{"from": "svc1", "to": "repo1"},
|
||||
{"from": "svc2", "to": "repo2"},
|
||||
]
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
|
||||
graph = HierarchicalCanvasGraph(root, conf)
|
||||
|
||||
return Titled("Dependency Graph",
|
||||
Div(
|
||||
P("Click on type badges (Controller, Service, Repository) to filter by type"),
|
||||
P("Click on node borders to filter by kind (web, business, infrastructure, data)"),
|
||||
P("Use search bar to filter by text"),
|
||||
cls="mb-4"
|
||||
),
|
||||
graph
|
||||
)
|
||||
|
||||
serve()
|
||||
```
|
||||
|
||||
### Example 4: Programmatic State Control
|
||||
|
||||
Controlling collapsed state and getting selection:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import (
|
||||
HierarchicalCanvasGraph,
|
||||
HierarchicalCanvasGraphConf
|
||||
)
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app()
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
root = RootInstance(session)
|
||||
|
||||
nodes = [
|
||||
{"id": "root", "label": "Project", "description": "Root directory", "type": "Folder", "kind": "root"},
|
||||
{"id": "src", "label": "src/", "description": "Source code", "type": "Folder", "kind": "folder"},
|
||||
{"id": "tests", "label": "tests/", "description": "Test suite", "type": "Folder", "kind": "folder"},
|
||||
{"id": "main", "label": "main.py", "description": "Application entry point", "type": "File", "kind": "python"},
|
||||
{"id": "utils", "label": "utils.py", "description": "Utility functions", "type": "File", "kind": "python"},
|
||||
{"id": "test1", "label": "test_main.py", "description": "Main module tests", "type": "File", "kind": "test"},
|
||||
]
|
||||
|
||||
edges = [
|
||||
{"from": "root", "to": "src"},
|
||||
{"from": "root", "to": "tests"},
|
||||
{"from": "src", "to": "main"},
|
||||
{"from": "src", "to": "utils"},
|
||||
{"from": "tests", "to": "test1"},
|
||||
]
|
||||
|
||||
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
|
||||
graph = HierarchicalCanvasGraph(root, conf, _id="file-tree")
|
||||
|
||||
# Commands to control graph
|
||||
def collapse_all():
|
||||
graph.set_collapsed({"root", "src", "tests"})
|
||||
return graph
|
||||
|
||||
def expand_all():
|
||||
graph.set_collapsed(set())
|
||||
return graph
|
||||
|
||||
def show_selection():
|
||||
selected = graph.get_selected_id()
|
||||
if selected:
|
||||
return Div(f"Selected: {selected}", id="info", cls="alert alert-info")
|
||||
return Div("No selection", id="info", cls="alert alert-warning")
|
||||
|
||||
collapse_cmd = Command("CollapseAll", "Collapse all nodes", collapse_all)
|
||||
expand_cmd = Command("ExpandAll", "Expand all nodes", expand_all)
|
||||
selection_cmd = Command("ShowSelection", "Show selection", show_selection)
|
||||
|
||||
return Titled("File Tree",
|
||||
Div(
|
||||
mk.button("Collapse All", command=collapse_cmd.htmx(target="#file-tree")),
|
||||
mk.button("Expand All", command=expand_cmd.htmx(target="#file-tree")),
|
||||
mk.button("Show Selection", command=selection_cmd.htmx(target="#info")),
|
||||
cls="flex gap-2 mb-4"
|
||||
),
|
||||
Div(id="info"),
|
||||
graph
|
||||
)
|
||||
|
||||
serve()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the HierarchicalCanvasGraph component itself.
|
||||
|
||||
### State
|
||||
|
||||
The component uses `HierarchicalCanvasGraphState` (inherits from `DbObject`) for persistence.
|
||||
|
||||
| Name | Type | Description | Default | Persisted |
|
||||
|-----------------|-------------------|--------------------------------------------------|--------------|-----------|
|
||||
| `collapsed` | `list[str]` | List of collapsed node IDs | `[]` | Yes |
|
||||
| `transform` | `dict` | Zoom/pan transform: `{x, y, scale}` | See below | Yes |
|
||||
| `layout_mode` | `str` | Layout orientation: "horizontal" or "vertical" | `"horizontal"` | Yes |
|
||||
| `filter_text` | `Optional[str]` | Text search filter | `None` | Yes |
|
||||
| `filter_type` | `Optional[str]` | Type filter (from badge click) | `None` | Yes |
|
||||
| `filter_kind` | `Optional[str]` | Kind filter (from border click) | `None` | Yes |
|
||||
| `ns_selected_id` | `Optional[str]` | Currently selected node ID (ephemeral) | `None` | No |
|
||||
|
||||
**Default transform:**
|
||||
```python
|
||||
{"x": 0, "y": 0, "scale": 1}
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
Internal commands (managed by `Commands` class inheriting from `BaseCommands`):
|
||||
|
||||
| Name | Description | Internal |
|
||||
|----------------------|--------------------------------------------------|----------|
|
||||
| `update_view_state()` | Update view transform and layout mode | Yes |
|
||||
| `apply_filter()` | Apply current filter and update graph display | Yes |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|--------------------------------|------------------------------------------|----------------------------|
|
||||
| `get_state()` | Get the persistent state object | `HierarchicalCanvasGraphState` |
|
||||
| `get_selected_id()` | Get currently selected node ID | `Optional[str]` |
|
||||
| `set_collapsed(node_ids: set)` | Set collapsed state of nodes | `None` |
|
||||
| `toggle_node(node_id: str)` | Toggle collapsed state of a node | `self` |
|
||||
| `render()` | Render the complete component | `Div` |
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|------------|-----------------------------------|------------------------------------|---------|
|
||||
| `parent` | Instance | Parent instance (required) | - |
|
||||
| `conf` | `HierarchicalCanvasGraphConf` | Configuration object | - |
|
||||
| `_id` | `Optional[str]` | Custom ID (auto-generated if None) | `None` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-hierarchical-canvas-graph")
|
||||
├── Query(id="{id}-query")
|
||||
│ └── [Filter input and controls]
|
||||
├── Div(id="{id}_container", cls="mf-hcg-container")
|
||||
│ └── [Canvas element - created by JS]
|
||||
└── Script
|
||||
└── initHierarchicalCanvasGraph('{id}_container', options)
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|----------------------|------------------------------------------|
|
||||
| `{id}` | Root container div |
|
||||
| `{id}-query` | Query filter component |
|
||||
| `{id}_container` | Canvas container (sized by JS) |
|
||||
|
||||
**Note:** `{id}` is the instance ID (auto-generated or custom `_id`).
|
||||
|
||||
### CSS Classes
|
||||
|
||||
| Class | Element |
|
||||
|------------------------------------|-----------------------------------|
|
||||
| `mf-hierarchical-canvas-graph` | Root container |
|
||||
| `mf-hcg-container` | Canvas container div |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|-------------------------------------|--------------------------------------------------|-------------------|
|
||||
| `_handle_update_view_state()` | Update view state from client | `str` (empty) |
|
||||
| `_handle_apply_filter()` | Apply filter and re-render graph | `self` |
|
||||
| `_calculate_filtered_nodes()` | Calculate which nodes match current filter | `Optional[list[str]]` |
|
||||
| `_prepare_options()` | Prepare JavaScript options object | `dict` |
|
||||
|
||||
### JavaScript Interface
|
||||
|
||||
The component calls `initHierarchicalCanvasGraph(containerId, options)` where `options` is:
|
||||
|
||||
```javascript
|
||||
{
|
||||
nodes: [...], // Array of node objects {id, label, description?, type?, kind?}
|
||||
edges: [...], // Array of edge objects {from, to}
|
||||
collapsed: [...], // Array of collapsed node IDs
|
||||
transform: {x, y, scale}, // Current view transform
|
||||
layout_mode: "horizontal", // Layout orientation
|
||||
filtered_nodes: [...], // Array of visible node IDs (null if no filter)
|
||||
events: {
|
||||
_internal_update_state: {...}, // HTMX options for state updates
|
||||
_internal_filter_by_type: {...}, // HTMX options for type filter
|
||||
_internal_filter_by_kind: {...}, // HTMX options for kind filter
|
||||
select_node: {...}, // User event handler (optional)
|
||||
toggle_node: {...} // User event handler (optional)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Node object structure:**
|
||||
- `id` (required): Unique identifier
|
||||
- `label` (required): Main display text
|
||||
- `description` (optional): Secondary text shown below label in smaller gray font
|
||||
- `type` (optional): Type badge shown on node
|
||||
- `kind` (optional): Category affecting border color (root|single|unique|multiple)
|
||||
|
||||
### Event Flow
|
||||
|
||||
**Node selection:**
|
||||
1. User clicks node (not toggle button)
|
||||
2. JS calls HTMX with `node_id` parameter
|
||||
3. `select_node` handler executes (if defined)
|
||||
4. Result updates target element
|
||||
|
||||
**Node toggle:**
|
||||
1. User clicks expand/collapse button
|
||||
2. JS updates local collapsed state
|
||||
3. JS calls `_internal_update_state` HTMX endpoint
|
||||
4. State persisted in `HierarchicalCanvasGraphState.collapsed`
|
||||
5. `toggle_node` handler executes (if defined)
|
||||
|
||||
**Filter changes:**
|
||||
1. User types in search / clicks badge / clicks border
|
||||
2. Query component or JS triggers `apply_filter()` command
|
||||
3. `_handle_apply_filter()` updates state filters
|
||||
4. Component re-renders with filtered nodes
|
||||
5. JS dims non-matching nodes
|
||||
|
||||
### Dependencies
|
||||
|
||||
**Python:**
|
||||
- `fasthtml.components.Div`
|
||||
- `fasthtml.xtend.Script`
|
||||
- `myfasthtml.controls.Query` - Filter search bar
|
||||
- `myfasthtml.core.instances.MultipleInstance` - Instance management
|
||||
- `myfasthtml.core.dbmanager.DbObject` - State persistence
|
||||
|
||||
**JavaScript:**
|
||||
- `initHierarchicalCanvasGraph()` - Must be loaded via MyFastHtml assets
|
||||
- Canvas API - For rendering
|
||||
- HTMX - For server communication
|
||||
509
docs/Keyboard Support.md
Normal file
509
docs/Keyboard Support.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# Keyboard Support - Test Instructions
|
||||
|
||||
## ⚠️ Breaking Change
|
||||
|
||||
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
|
||||
|
||||
**Old format (no longer supported)**:
|
||||
```javascript
|
||||
{"A": "/url"}
|
||||
```
|
||||
|
||||
**New format (required)**:
|
||||
```javascript
|
||||
{"A": {"hx-post": "/url"}}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
|
||||
- `test_keyboard_support.html` - Test page to verify functionality
|
||||
|
||||
## Key Features
|
||||
|
||||
### Scope Control with `require_inside`
|
||||
|
||||
Each combination can declare whether it should only trigger when the focus is **inside** the registered element, or fire **globally** regardless of focus.
|
||||
|
||||
| `require_inside` | Behavior |
|
||||
|-----------------|----------|
|
||||
| `true` (default) | Triggers only if focus is inside the element or one of its children |
|
||||
| `false` | Triggers regardless of where the focus is (global shortcut) |
|
||||
|
||||
```javascript
|
||||
// Only fires when focus is inside #tree-panel
|
||||
add_keyboard_support('tree-panel', '{"esc": {"hx-post": "/cancel", "require_inside": true}}');
|
||||
|
||||
// Fires anywhere on the page
|
||||
add_keyboard_support('app', '{"ctrl+n": {"hx-post": "/new", "require_inside": false}}');
|
||||
```
|
||||
|
||||
**Python usage (`Keyboard` component):**
|
||||
|
||||
```python
|
||||
# Default: require_inside=True — fires only when inside the element
|
||||
Keyboard(self, _id="-kb").add("esc", self.commands.cancel())
|
||||
|
||||
# Explicit global shortcut
|
||||
Keyboard(self, _id="-kb").add("ctrl+n", self.commands.new_item(), require_inside=False)
|
||||
```
|
||||
|
||||
### Multiple Simultaneous Triggers
|
||||
|
||||
**IMPORTANT**: If multiple elements listen to the same combination, all of them whose `require_inside` condition is satisfied will be triggered simultaneously:
|
||||
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": {"hx-post": "/close-modal", "require_inside": true}}');
|
||||
add_keyboard_support('editor', '{"esc": {"hx-post": "/cancel-edit", "require_inside": true}}');
|
||||
add_keyboard_support('sidebar', '{"esc": {"hx-post": "/hide-sidebar", "require_inside": false}}');
|
||||
|
||||
// Pressing ESC while focus is inside 'editor':
|
||||
// - 'modal' → skipped (require_inside: true, focus not inside)
|
||||
// - 'editor' → triggered ✓
|
||||
// - 'sidebar' → triggered ✓ (require_inside: false)
|
||||
```
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
The library uses **a single global timeout** based on the sequence state, not on individual elements:
|
||||
|
||||
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
|
||||
|
||||
Examples:
|
||||
|
||||
**Example 1**: Three elements, same combination
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"esc": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"esc": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"esc": "/url3"}');
|
||||
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
|
||||
```
|
||||
|
||||
**Example 2**: Mixed - one has longer sequence
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"A": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
|
||||
// Press A:
|
||||
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
|
||||
// - If B arrives: only elem3 triggers with "A B"
|
||||
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
|
||||
```
|
||||
|
||||
**Example 3**: Different combinations
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A B": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"C D": "/url2"}');
|
||||
// Press A: elem1 waits for B, elem2 not affected
|
||||
// Press C: elem2 waits for D, elem1 not affected
|
||||
```
|
||||
|
||||
The timeout is tied to the **sequence being typed**, not to individual elements.
|
||||
|
||||
### Enabling and Disabling Combinations
|
||||
|
||||
Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered.
|
||||
|
||||
| State | Behavior |
|
||||
|-------|----------|
|
||||
| `enabled=True` (default) | Combination triggers normally |
|
||||
| `enabled=False` | Combination is silently ignored when pressed |
|
||||
|
||||
**Setting the initial state at registration:**
|
||||
|
||||
```python
|
||||
# Enabled by default
|
||||
keyboard.add("ctrl+s", self.commands.save())
|
||||
|
||||
# Disabled at startup
|
||||
keyboard.add("ctrl+d", self.commands.delete(), enabled=False)
|
||||
```
|
||||
|
||||
**Toggling dynamically at runtime:**
|
||||
|
||||
Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload.
|
||||
|
||||
```python
|
||||
# Enable a combination (e.g., once an item is selected)
|
||||
def handle_select(self):
|
||||
item = ...
|
||||
return item.render(), self.keyboard.mk_enable("ctrl+d")
|
||||
|
||||
# Disable a combination (e.g., when nothing is selected)
|
||||
def handle_deselect(self):
|
||||
return self.keyboard.mk_disable("ctrl+d")
|
||||
```
|
||||
|
||||
The enabled state is stored in a hidden control `<div>` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action.
|
||||
|
||||
**State at a glance:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Keyboard control div (hidden) │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ div [data-combination="esc"] │ │
|
||||
│ │ [data-enabled="true"] │ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ div [data-combination="ctrl+d"] │ │
|
||||
│ │ [data-enabled="false"] │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
Keyboard shortcuts are **disabled** when typing in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
|
||||
|
||||
## How to Test
|
||||
|
||||
1. **Download both files** to the same directory
|
||||
2. **Open `test_keyboard_support.html`** in a web browser
|
||||
3. **Try the configured shortcuts**:
|
||||
- `a` - Simple key (waits if "A B" might follow)
|
||||
- `Ctrl+S` - Save combination (immediate)
|
||||
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
|
||||
- `A B` - Sequence (waits because "A B C" exists)
|
||||
- `A B C` - Triple sequence (triggers immediately)
|
||||
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
|
||||
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
|
||||
- `shift shift` - Press Shift twice in sequence
|
||||
- `esc` - Escape key (immediate)
|
||||
|
||||
4. **Test focus behavior**:
|
||||
- Click on the test element to focus it (turns blue)
|
||||
- Try shortcuts with focus
|
||||
- Click outside to remove focus
|
||||
- Try shortcuts without focus
|
||||
- The log shows whether the element had focus when triggered
|
||||
|
||||
5. **Test input protection**:
|
||||
- Try typing in the input field
|
||||
- Use Ctrl+C, Ctrl+A, etc. - should work normally
|
||||
- Shortcuts should NOT trigger while typing in input
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Smart Timeout Examples
|
||||
|
||||
**Scenario 1**: Only "A" is configured (no element has "A B")
|
||||
- Press A → Triggers **immediately**
|
||||
|
||||
**Scenario 2**: At least one element has "A B"
|
||||
- Press A → **ALL elements with "A" wait 500ms**
|
||||
- If B pressed within 500ms → Only elements with "A B" trigger
|
||||
- If timeout expires → ALL elements with "A" trigger
|
||||
|
||||
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
|
||||
- Press A → Waits (because "A B" exists)
|
||||
- Press B → Waits (because "A B C" exists)
|
||||
- Press C → Triggers "A B C" **immediately**
|
||||
|
||||
**Scenario 4**: Multiple elements, ESC on all
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": "/close"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel"}');
|
||||
```
|
||||
- Press ESC → **Both trigger simultaneously** (no longer sequences)
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
### Configuration Format
|
||||
|
||||
The library now uses **HTMX configuration objects** instead of simple URL strings:
|
||||
|
||||
```python
|
||||
# New format with HTMX configuration
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save-url",
|
||||
"hx-target": "#result",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"A B": {
|
||||
"hx-post": "/sequence-url",
|
||||
"hx-vals": {"extra": "data"}
|
||||
},
|
||||
"esc": {
|
||||
"hx-get": "/cancel-url"
|
||||
}
|
||||
}
|
||||
|
||||
# This will generate the JavaScript call
|
||||
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
|
||||
```
|
||||
|
||||
### Supported HTMX Attributes
|
||||
|
||||
You can use any HTMX attribute in the configuration object:
|
||||
|
||||
**HTTP Methods** (one required):
|
||||
- `hx-post` - POST request
|
||||
- `hx-get` - GET request
|
||||
- `hx-put` - PUT request
|
||||
- `hx-delete` - DELETE request
|
||||
- `hx-patch` - PATCH request
|
||||
|
||||
**Common Options**:
|
||||
- `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:
|
||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||
- `has_focus` - Boolean indicating if the element had focus
|
||||
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
|
||||
|
||||
Note: `require_inside` controls **whether** the action fires; `is_inside` is an informational parameter sent **with** the request after it fires.
|
||||
|
||||
Example final request:
|
||||
```javascript
|
||||
htmx.ajax('POST', '/save-url', {
|
||||
target: '#result',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
extra: "data", // from hx-vals
|
||||
combination: "Ctrl+S", // automatic
|
||||
has_focus: true, // automatic
|
||||
is_inside: true // automatic
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Target and Swap
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+D": {
|
||||
"hx-delete": "/item",
|
||||
"hx-target": "#item-list",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Extra Values
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+N": {
|
||||
"hx-post": "/create",
|
||||
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Elements Example
|
||||
|
||||
```python
|
||||
# Modal close
|
||||
modal_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/modal/close",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Editor cancel
|
||||
editor_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/editor/cancel",
|
||||
"hx-target": "#editor",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Both will trigger when ESC is pressed
|
||||
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
||||
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||
```
|
||||
|
||||
### Removing Keyboard Support
|
||||
|
||||
When you no longer need keyboard support for an element:
|
||||
|
||||
```python
|
||||
# Remove keyboard support
|
||||
f"remove_keyboard_support('{element_id}')"
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Removes the element from the keyboard registry
|
||||
- If this was the last element, automatically detaches global event listeners
|
||||
- Cleans up all associated state (timeouts, snapshots, etc.)
|
||||
- Other elements continue to work normally
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
// Add support
|
||||
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
|
||||
|
||||
// Later, remove support
|
||||
remove_keyboard_support('modal');
|
||||
// If no other elements remain, keyboard listeners are completely removed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_keyboard_support(elementId, controlDivId, combinationsJson)
|
||||
|
||||
Adds keyboard support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element to watch for key events
|
||||
- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime
|
||||
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||
|
||||
**Returns**: void
|
||||
|
||||
### Python Component Methods
|
||||
|
||||
These methods are available on the `Keyboard` instance.
|
||||
|
||||
#### add(sequence, command, require_inside=True, enabled=True)
|
||||
|
||||
Registers a key combination.
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|-----------|------|-------------|---------|
|
||||
| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — |
|
||||
| `command` | `Command` | Command to execute when the combination is triggered | — |
|
||||
| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` |
|
||||
| `enabled` | `bool` | Whether the combination is active at render time | `True` |
|
||||
|
||||
**Returns**: `self` (chainable)
|
||||
|
||||
#### mk_enable(sequence)
|
||||
|
||||
Returns an out-of-band HTMX element that enables a combination at runtime.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` |
|
||||
|
||||
**Returns**: `Div` with `hx-swap-oob="true"`
|
||||
|
||||
#### mk_disable(sequence)
|
||||
|
||||
Returns an out-of-band HTMX element that disables a combination at runtime.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` |
|
||||
|
||||
**Returns**: `Div` with `hx-swap-oob="true"`
|
||||
|
||||
---
|
||||
|
||||
### remove_keyboard_support(elementId)
|
||||
|
||||
Removes keyboard support from an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Side effects**:
|
||||
- If last element: detaches global event listeners and cleans up all state
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Trie-based Matching
|
||||
|
||||
The library uses a prefix tree (trie) data structure:
|
||||
- Each node represents a keyboard snapshot (set of pressed keys)
|
||||
- Leaf nodes contain the HTMX configuration object
|
||||
- Intermediate nodes indicate longer sequences exist
|
||||
- Enables efficient O(n) matching where n is sequence length
|
||||
|
||||
### HTMX Integration
|
||||
|
||||
Configuration objects are mapped to htmx.ajax() calls:
|
||||
- `hx-*` attributes are converted to camelCase parameters
|
||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||
- All standard HTMX options are supported
|
||||
|
||||
### Key Normalization
|
||||
|
||||
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
|
||||
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
|
||||
- Simultaneous keys represented as sorted sets
|
||||
|
||||
## Notes
|
||||
|
||||
- The test page mocks `htmx.ajax` to display results in the console
|
||||
- In production, real AJAX calls will be made to your backend
|
||||
- Sequence timeout is 500ms between keys
|
||||
- Maximum 10 snapshots kept in history to prevent memory issues
|
||||
583
docs/Layout.md
Normal file
583
docs/Layout.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# Layout Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
|
||||
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Fixed header and footer that stay visible while scrolling
|
||||
- Collapsible left and right drawers for navigation, tools, or auxiliary content
|
||||
- Resizable drawers with drag handles
|
||||
- Automatic state persistence per session
|
||||
- Single instance per session (singleton pattern)
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Application with navigation sidebar
|
||||
- Dashboard with tools panel
|
||||
- Admin interface with settings drawer
|
||||
- Documentation site with table of contents
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing an application with a navigation sidebar:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create the layout instance
|
||||
layout = Layout(parent=root_instance, app_name="My App")
|
||||
|
||||
# Add navigation items to the left drawer
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Home"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("About"), command=Command(...))
|
||||
)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div("Contact"), command=Command(...))
|
||||
)
|
||||
|
||||
# Set the main content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Welcome"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the layout
|
||||
return layout
|
||||
```
|
||||
|
||||
This creates a complete application layout with:
|
||||
|
||||
- A header displaying the app name and drawer toggle button
|
||||
- A collapsible left drawer with interactive navigation items
|
||||
- A main content area that updates when navigation items are clicked
|
||||
- An empty footer
|
||||
|
||||
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
|
||||
below for details.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a Layout
|
||||
|
||||
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
|
||||
parent instance and an application name:
|
||||
|
||||
```python
|
||||
layout = Layout(parent=root_instance, app_name="My Application")
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Layout provides six content zones where you can add components:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Header │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ header_left │ │ header_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
├─────────┬────────────────────────────────────┬───────────┤
|
||||
│ │ │ │
|
||||
│ left │ │ right │
|
||||
│ drawer │ Main Content │ drawer │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
├─────────┴────────────────────────────────────┴───────────┤
|
||||
│ Footer │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ footer_left │ │ footer_right │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use |
|
||||
|----------------|-----------------------------------------------|
|
||||
| `header_left` | App logo, menu button, breadcrumbs |
|
||||
| `header_right` | User profile, notifications, settings |
|
||||
| `left_drawer` | Navigation menu, tree view, filters |
|
||||
| `right_drawer` | Tools panel, properties inspector, debug info |
|
||||
| `footer_left` | Copyright, legal links, version |
|
||||
| `footer_right` | Status indicators, connection state |
|
||||
|
||||
### Adding Content to Zones
|
||||
|
||||
Use the `.add()` method to add components to any zone:
|
||||
|
||||
```python
|
||||
# Header
|
||||
layout.header_left.add(Div("Logo"))
|
||||
layout.header_right.add(Div("User: Admin"))
|
||||
|
||||
# Drawers
|
||||
layout.left_drawer.add(Div("Navigation"))
|
||||
layout.right_drawer.add(Div("Tools"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 My App"))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
```
|
||||
|
||||
### Setting Main Content
|
||||
|
||||
The main content area displays your page content and can be updated dynamically:
|
||||
|
||||
```python
|
||||
# Set initial content
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("Welcome to your dashboard")
|
||||
)
|
||||
)
|
||||
|
||||
# Update later (typically via commands)
|
||||
layout.set_main(
|
||||
Div(
|
||||
H1("Settings"),
|
||||
P("Configure your preferences")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Controlling Drawers
|
||||
|
||||
By default, both drawers are visible. The drawer state is managed automatically:
|
||||
|
||||
- Users can toggle drawers using the icon buttons in the header
|
||||
- Users can resize drawers by dragging the handle
|
||||
- Drawer state persists within the session
|
||||
|
||||
The initial drawer widths are:
|
||||
|
||||
- Left drawer: 250px
|
||||
- Right drawer: 250px
|
||||
|
||||
These can be adjusted by users and the state is preserved automatically.
|
||||
|
||||
## Content System
|
||||
|
||||
### Understanding Groups
|
||||
|
||||
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
|
||||
separated visually by dividers and can have optional labels.
|
||||
|
||||
### Adding Content to Groups
|
||||
|
||||
When adding content, you can optionally specify a group name:
|
||||
|
||||
```python
|
||||
# Add items to different groups in the left drawer
|
||||
layout.left_drawer.add(Div("Dashboard"), group="main")
|
||||
layout.left_drawer.add(Div("Analytics"), group="main")
|
||||
layout.left_drawer.add(Div("Settings"), group="preferences")
|
||||
layout.left_drawer.add(Div("Profile"), group="preferences")
|
||||
```
|
||||
|
||||
This creates two groups:
|
||||
|
||||
- **main**: Dashboard, Analytics
|
||||
- **preferences**: Settings, Profile
|
||||
|
||||
A visual divider automatically appears between groups.
|
||||
|
||||
### Custom Group Labels
|
||||
|
||||
You can provide a custom FastHTML element to display as the group header:
|
||||
|
||||
```python
|
||||
# Add a styled group header
|
||||
layout.left_drawer.add_group(
|
||||
"Navigation",
|
||||
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
|
||||
)
|
||||
|
||||
# Then add items to this group
|
||||
layout.left_drawer.add(Div("Home"), group="Navigation")
|
||||
layout.left_drawer.add(Div("About"), group="Navigation")
|
||||
```
|
||||
|
||||
### Ungrouped Content
|
||||
|
||||
If you don't specify a group, content is added to the default (`None`) group:
|
||||
|
||||
```python
|
||||
# These items are in the default group
|
||||
layout.left_drawer.add(Div("Quick Action 1"))
|
||||
layout.left_drawer.add(Div("Quick Action 2"))
|
||||
```
|
||||
|
||||
### Preventing Duplicates
|
||||
|
||||
The Content system automatically prevents adding duplicate items based on their `id` attribute:
|
||||
|
||||
```python
|
||||
item = Div("Unique Item", id="my-item")
|
||||
layout.left_drawer.add(item)
|
||||
layout.left_drawer.add(item) # Ignored - already added
|
||||
```
|
||||
|
||||
### Group Rendering Options
|
||||
|
||||
Groups render differently depending on the zone:
|
||||
|
||||
**In drawers** (vertical layout):
|
||||
|
||||
- Groups stack vertically
|
||||
- Dividers are horizontal lines
|
||||
- Group labels appear above their content
|
||||
|
||||
**In header/footer** (horizontal layout):
|
||||
|
||||
- Groups arrange side-by-side
|
||||
- Dividers are vertical lines
|
||||
- Group labels are typically hidden
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Resizable Drawers
|
||||
|
||||
Both drawers can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left drawer: Right edge
|
||||
- Right drawer: Left edge
|
||||
- **Width constraints**: 150px (minimum) to 600px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in the session state
|
||||
|
||||
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
|
||||
|
||||
### Programmatic Drawer Control
|
||||
|
||||
You can control drawers programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle drawer visibility
|
||||
toggle_left = layout.commands.toggle_drawer("left")
|
||||
toggle_right = layout.commands.toggle_drawer("right")
|
||||
|
||||
# Update drawer width
|
||||
update_left_width = layout.commands.update_drawer_width("left", width=300)
|
||||
update_right_width = layout.commands.update_drawer_width("right", width=350)
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
# Add a button to toggle the right drawer
|
||||
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
|
||||
layout.header_right.add(button)
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Layout automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|----------------------|---------------------------------|---------|
|
||||
| `left_drawer_open` | Whether left drawer is visible | `True` |
|
||||
| `right_drawer_open` | Whether right drawer is visible | `True` |
|
||||
| `left_drawer_width` | Left drawer width in pixels | `250` |
|
||||
| `right_drawer_width` | Right drawer width in pixels | `250` |
|
||||
|
||||
State changes (toggle, resize) are automatically saved and restored within the session.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
Content zones can be updated dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial setup
|
||||
layout.left_drawer.add(Div("Item 1"))
|
||||
|
||||
|
||||
# Later, add more items (e.g., in a command handler)
|
||||
def add_dynamic_content():
|
||||
layout.left_drawer.add(Div("New Item"), group="dynamic")
|
||||
return layout.left_drawer # Return updated drawer for HTMX swap
|
||||
```
|
||||
|
||||
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Layout uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|----------------------------------|
|
||||
| `mf-layout` | Root layout container |
|
||||
| `mf-layout-header` | Header section |
|
||||
| `mf-layout-footer` | Footer section |
|
||||
| `mf-layout-main` | Main content area |
|
||||
| `mf-layout-drawer` | Drawer container |
|
||||
| `mf-layout-left-drawer` | Left drawer specifically |
|
||||
| `mf-layout-right-drawer` | Right drawer specifically |
|
||||
| `mf-layout-drawer-content` | Scrollable content within drawer |
|
||||
| `mf-resizer` | Resize handle |
|
||||
| `mf-layout-group` | Content group wrapper |
|
||||
|
||||
You can override these classes in your custom CSS to change colors, spacing, or behavior.
|
||||
|
||||
### User Profile Integration
|
||||
|
||||
The Layout automatically includes a UserProfile component in the header right area. This component handles user
|
||||
authentication display and logout functionality when auth is enabled.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Dashboard with Navigation Sidebar
|
||||
|
||||
A typical dashboard application with a navigation menu in the left drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
|
||||
|
||||
|
||||
# Navigation menu in left drawer
|
||||
def show_dashboard():
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_reports():
|
||||
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
def show_settings():
|
||||
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Add navigation items with groups
|
||||
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
|
||||
group="main")
|
||||
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
|
||||
group="main")
|
||||
|
||||
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
|
||||
group="config")
|
||||
|
||||
# Header content
|
||||
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Analytics Co."))
|
||||
layout.footer_right.add(Div("v1.0.0"))
|
||||
|
||||
# Set initial main content
|
||||
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||
```
|
||||
|
||||
### Example 2: Development Tool with Debug Panel
|
||||
|
||||
An application with development tools in the right drawer:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Dev Tools")
|
||||
|
||||
# Main content: code editor
|
||||
layout.set_main(
|
||||
Div(
|
||||
H2("Code Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
# Right drawer: debug and tools
|
||||
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Div("Console output here..."), group="debug")
|
||||
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
|
||||
|
||||
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
|
||||
layout.right_drawer.add(Button("Run Code"), group="tools")
|
||||
layout.right_drawer.add(Button("Clear Console"), group="tools")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("DevTools IDE"))
|
||||
layout.header_right.add(Button("Save"))
|
||||
```
|
||||
|
||||
### Example 3: Minimal Layout (Main Content Only)
|
||||
|
||||
A simple layout without drawers, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
|
||||
# Create layout
|
||||
layout = Layout(parent=root_instance, app_name="Simple Blog")
|
||||
|
||||
# Header
|
||||
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
|
||||
layout.header_right.add(A("About", href="/about"))
|
||||
|
||||
# Main content
|
||||
layout.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog"),
|
||||
P("This is a simple blog layout without side drawers."),
|
||||
P("The focus is on the content in the center.")
|
||||
)
|
||||
)
|
||||
|
||||
# Footer
|
||||
layout.footer_left.add(Div("© 2024 Blog Author"))
|
||||
layout.footer_right.add(A("RSS", href="/rss"))
|
||||
|
||||
# Note: Drawers are present but can be collapsed by users if not needed
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Content Loading
|
||||
|
||||
Loading content dynamically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
layout = Layout(parent=root_instance, app_name="Dynamic App")
|
||||
|
||||
|
||||
# Function that loads content dynamically
|
||||
def load_page(page_name):
|
||||
# Simulate loading different content
|
||||
content = {
|
||||
"home": Div(H1("Home"), P("Welcome to the home page")),
|
||||
"profile": Div(H1("Profile"), P("User profile information")),
|
||||
"settings": Div(H1("Settings"), P("Application settings")),
|
||||
}
|
||||
layout.set_main(content.get(page_name, Div("Page not found")))
|
||||
return layout._mk_main()
|
||||
|
||||
|
||||
# Create navigation commands
|
||||
pages = ["home", "profile", "settings"]
|
||||
for page in pages:
|
||||
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
|
||||
layout.left_drawer.add(
|
||||
mk.mk(Div(page.capitalize()), command=cmd)
|
||||
)
|
||||
|
||||
# Set initial content
|
||||
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Layout component itself.
|
||||
|
||||
### State
|
||||
|
||||
The Layout component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|----------------------|---------|----------------------------------|---------|
|
||||
| `left_drawer_open` | boolean | True if the left drawer is open | True |
|
||||
| `right_drawer_open` | boolean | True if the right drawer is open | True |
|
||||
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
||||
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
||||
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
|
||||
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------|-----------------------------|
|
||||
| `set_main(content)` | Sets the main content area |
|
||||
| `render()` | Renders the complete layout |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="layout")
|
||||
├── Header
|
||||
│ ├── Div(id="layout_hl")
|
||||
│ │ ├── Icon # Left drawer icon button
|
||||
│ │ └── Div # Left content for the header
|
||||
│ └── Div(id="layout_hr")
|
||||
│ ├── Div # Right content for the header
|
||||
│ └── UserProfile # user profile icon button
|
||||
├── Div # Left Drawer
|
||||
├── Main # Main content
|
||||
├── Div # Right Drawer
|
||||
├── Footer # Footer
|
||||
└── Script # To initialize the resizing
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------|-------------------------------------|
|
||||
| `layout` | Root layout container (singleton) |
|
||||
| `layout_h` | Header section (not currently used) |
|
||||
| `layout_hl` | Header left side |
|
||||
| `layout_hr` | Header right side |
|
||||
| `layout_f` | Footer section (not currently used) |
|
||||
| `layout_fl` | Footer left side |
|
||||
| `layout_fr` | Footer right side |
|
||||
| `layout_ld` | Left drawer |
|
||||
| `layout_rd` | Right drawer |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------|--------------------------------------------------------|
|
||||
| `_mk_header()` | Renders the header component |
|
||||
| `_mk_footer()` | Renders the footer component |
|
||||
| `_mk_main()` | Renders the main content area |
|
||||
| `_mk_left_drawer()` | Renders the left drawer |
|
||||
| `_mk_right_drawer()` | Renders the right drawer |
|
||||
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
|
||||
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
|
||||
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
|
||||
|
||||
### Content Class
|
||||
|
||||
The `Layout.Content` nested class manages content zones:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------|----------------------------------------------------------|
|
||||
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
|
||||
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
|
||||
| `get_content()` | Returns dictionary of groups and their content |
|
||||
| `get_groups()` | Returns list of (group_name, group_ft) tuples |
|
||||
801
docs/Mouse Support.md
Normal file
801
docs/Mouse Support.md
Normal file
@@ -0,0 +1,801 @@
|
||||
# Mouse Support - Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Mouse Actions
|
||||
|
||||
**Basic Actions**:
|
||||
- `click` - Left click
|
||||
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||
|
||||
**Modified Actions**:
|
||||
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||
- `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
|
||||
- `ctrl+click click` - Ctrl+click then normal click
|
||||
- Any sequence of actions
|
||||
|
||||
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||
|
||||
### Smart Timeout Logic
|
||||
|
||||
Same as keyboard support:
|
||||
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||
- Timeout is 500ms between actions
|
||||
- Immediate trigger if no longer sequences exist
|
||||
|
||||
### Multiple Element Support
|
||||
|
||||
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||
|
||||
## Configuration Format
|
||||
|
||||
Uses HTMX configuration objects (same as keyboard support):
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/handle-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/handle-ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"rclick": { // Alias for right_click
|
||||
"hx-post": "/context-menu"
|
||||
},
|
||||
"click rclick": { // Can use rclick in sequences too
|
||||
"hx-post": "/sequence-action",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
|
||||
Adds mouse support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
add_mouse_support('button1', JSON.stringify({
|
||||
"click": {"hx-post": "/click"},
|
||||
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||
}));
|
||||
```
|
||||
|
||||
### remove_mouse_support(elementId)
|
||||
|
||||
Removes mouse support from an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Side effects**:
|
||||
- If last element: detaches global event listeners and cleans up all state
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
remove_mouse_support('button1');
|
||||
```
|
||||
|
||||
## Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every HTMX request:
|
||||
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||
- `is_inside` - Boolean indicating if the click was inside the element
|
||||
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||
|
||||
### Parameter Details
|
||||
|
||||
**`has_focus`**:
|
||||
- `true` if the registered element currently has focus
|
||||
- `false` otherwise
|
||||
- Useful for knowing if the element was the active element
|
||||
|
||||
**`clicked_inside`**:
|
||||
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||
- Useful for "click outside to close" logic
|
||||
|
||||
**Example values sent**:
|
||||
```javascript
|
||||
// User clicks inside a modal
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: true,
|
||||
clicked_inside: true
|
||||
}
|
||||
|
||||
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: false,
|
||||
clicked_inside: false // Perfect for closing the modal!
|
||||
}
|
||||
|
||||
// User right-clicks on an item
|
||||
{
|
||||
combination: "right_click",
|
||||
has_focus: false,
|
||||
clicked_inside: true // Always true for right_click
|
||||
}
|
||||
```
|
||||
|
||||
## Python Integration
|
||||
|
||||
### 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": {"mode": "multi"}
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/item/context-menu",
|
||||
"hx-target": "#context-menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')")
|
||||
```
|
||||
|
||||
## Behavior Details
|
||||
|
||||
### Click vs Right-Click Behavior
|
||||
|
||||
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||
|
||||
**`click` (global detection)**:
|
||||
- Triggers for ALL registered elements, regardless of where you click
|
||||
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||
|
||||
**`right_click` (element-specific detection)**:
|
||||
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||
- This preserves normal browser behavior while adding custom actions on your elements
|
||||
|
||||
**Example use case**:
|
||||
```javascript
|
||||
// Modal that closes when clicking anywhere
|
||||
add_mouse_support('modal', JSON.stringify({
|
||||
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||
}));
|
||||
|
||||
// Context menu that only appears on element
|
||||
add_mouse_support('item', JSON.stringify({
|
||||
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||
}));
|
||||
```
|
||||
|
||||
### Modifier Keys (Cross-Platform)
|
||||
|
||||
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||
- This follows standard web conventions for cross-platform compatibility
|
||||
|
||||
### Input Context Protection
|
||||
|
||||
Mouse actions are **disabled** when clicking in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal text selection and interaction works in forms.
|
||||
|
||||
### Right-Click Menu
|
||||
|
||||
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||
- A `right_click` action is configured for the element
|
||||
- The element is NOT an input/textarea/contenteditable
|
||||
|
||||
### Event Bubbling
|
||||
|
||||
The library checks if the click target OR any parent element is registered:
|
||||
```html
|
||||
<div id="container">
|
||||
<button>Click me</button>
|
||||
</div>
|
||||
```
|
||||
If `container` is registered, clicking the button will trigger the container's actions.
|
||||
|
||||
## Examples
|
||||
|
||||
### Using Aliases
|
||||
|
||||
You can use `rclick` instead of `right_click` anywhere:
|
||||
|
||||
```javascript
|
||||
// These are equivalent
|
||||
const config1 = {
|
||||
"right_click": {"hx-post": "/menu"}
|
||||
};
|
||||
|
||||
const config2 = {
|
||||
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||
};
|
||||
|
||||
// Works in sequences too
|
||||
const config3 = {
|
||||
"click rclick": {"hx-post": "/sequence"}
|
||||
};
|
||||
|
||||
// Works with modifiers
|
||||
const config4 = {
|
||||
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||
};
|
||||
```
|
||||
|
||||
### Context Menu
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"right_click": {
|
||||
"hx-post": "/show-context-menu",
|
||||
"hx-target": "#menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Close Modal/Popup on Click Outside
|
||||
|
||||
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||
|
||||
```javascript
|
||||
// Modal element
|
||||
const modalCombinations = {
|
||||
"click": {
|
||||
"hx-post": "/close-modal",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
};
|
||||
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||
|
||||
// Now clicking ANYWHERE on the page will trigger the handler
|
||||
// Your backend receives:
|
||||
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||
// - clicked_inside: false (if clicked outside) → close it!
|
||||
```
|
||||
|
||||
**Backend example (Python/Flask)**:
|
||||
```python
|
||||
@app.route('/close-modal', methods=['POST'])
|
||||
def close_modal():
|
||||
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||
|
||||
if clicked_inside:
|
||||
# User clicked inside the modal - keep it open
|
||||
return render_template('modal_content.html')
|
||||
else:
|
||||
# User clicked outside - close the modal
|
||||
return '' # Empty response removes the modal
|
||||
```
|
||||
|
||||
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||
|
||||
### Multi-Select List
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "single"}
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "toggle"}
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/select-range",
|
||||
"hx-vals": {"mode": "range"}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Interactive Canvas/Drawing
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/draw-point"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/draw-special"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/confirm-action"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Drag-and-Drop Alternative
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-source"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/set-destination"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
- Verify the element exists and has the correct ID
|
||||
- Check browser console for errors
|
||||
- Ensure HTMX is loaded before mouse_support.js
|
||||
|
||||
### Right-click menu still appears
|
||||
|
||||
- Check if element is in input context (input/textarea)
|
||||
- Verify the combination is configured correctly
|
||||
- Check browser console for configuration errors
|
||||
|
||||
### Sequences not working
|
||||
|
||||
- Ensure clicks happen within 500ms timeout
|
||||
- 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`, `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
|
||||
|
||||
- Single event listener regardless of number of elements
|
||||
- O(n) matching where n is sequence length
|
||||
- Efficient memory usage with automatic cleanup
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- Modern browsers (ES6+ required)
|
||||
- Chrome, Firefox, Safari, Edge
|
||||
- Requires HTMX library
|
||||
|
||||
## Notes
|
||||
|
||||
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||
- Can be used independently or alongside keyboard support
|
||||
- Does not interfere with normal mouse behavior in inputs
|
||||
- Element must exist in DOM when `add_mouse_support()` is called
|
||||
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||
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`
|
||||
355
docs/Profiler.md
Normal file
355
docs/Profiler.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Profiler — Design & Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Performance issues were identified during keyboard navigation in the DataGrid (173ms server-side
|
||||
per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bottleneck is
|
||||
server-side. A persistent, in-application profiling system is needed for continuous analysis
|
||||
across sessions and future investigations.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Phase | Item | Status |
|
||||
|-------|------|--------|
|
||||
| **Phase 1 — Core** | `profiler.py` — data model + probe mechanisms | ✅ Done |
|
||||
| **Phase 1 — Core** | `tests/core/test_profiler.py` — full test suite (7 classes) | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `utils.py` — Level A `command_span` | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `commands.py` — Level B phases | ⏳ Deferred |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — global layout (toolbar + list) | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — detail panel (span tree + pie) | ⏳ Pending |
|
||||
| **Phase 2 — Controls** | CSS `profiler.css` | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `ProfilerPieChart.py` | ⏳ Future |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Data Collection Strategy
|
||||
|
||||
Two complementary levels:
|
||||
|
||||
- **Level A** (route handler): One trace per `/myfasthtml/commands` call. Captures total
|
||||
server-side duration including lookup, execution, and HTMX swap overhead.
|
||||
- **Level B** (granular spans): Decomposition of each trace into named phases. Activated
|
||||
by placing probes in the code.
|
||||
|
||||
Both levels are active simultaneously. Level A gives the global picture; Level B gives the
|
||||
breakdown.
|
||||
|
||||
### Probe Mechanisms
|
||||
|
||||
Four complementary mechanisms, chosen based on the context:
|
||||
|
||||
#### 1. Context manager — partial block instrumentation
|
||||
|
||||
```python
|
||||
with profiler.span("oob_swap"):
|
||||
# only this block is timed
|
||||
result = build_oob_elements(...)
|
||||
```
|
||||
|
||||
Metadata can be attached during execution:
|
||||
|
||||
```python
|
||||
with profiler.span("query") as span:
|
||||
rows = db.query(...)
|
||||
span.set("row_count", len(rows))
|
||||
```
|
||||
|
||||
#### 2. Decorator — full function instrumentation
|
||||
|
||||
```python
|
||||
@profiler.span("callback")
|
||||
def execute_callback(self, client_response):
|
||||
...
|
||||
```
|
||||
|
||||
Function arguments are captured automatically. Metadata can be attached via `current_span()`:
|
||||
|
||||
```python
|
||||
@profiler.span("process")
|
||||
def process(self, rows):
|
||||
result = do_work(rows)
|
||||
profiler.current_span().set("row_count", len(result))
|
||||
return result
|
||||
```
|
||||
|
||||
#### 3. Cumulative span — loop instrumentation
|
||||
|
||||
For loops with many iterations. Aggregates instead of creating one span per iteration.
|
||||
|
||||
```python
|
||||
for row in rows:
|
||||
with profiler.cumulative_span("process_row"):
|
||||
process(row)
|
||||
|
||||
# or as a decorator
|
||||
@profiler.cumulative_span("process_row")
|
||||
def process_row(self, row):
|
||||
...
|
||||
```
|
||||
|
||||
Exposes: `count`, `total`, `min`, `max`, `avg`. Single entry in the trace tree regardless of
|
||||
iteration count.
|
||||
|
||||
#### 4. `trace_all` — class-level static instrumentation
|
||||
|
||||
Wraps all methods of a class at definition time. No runtime overhead beyond the spans themselves.
|
||||
|
||||
```python
|
||||
@profiler.trace_all
|
||||
class DataGrid(MultipleInstance):
|
||||
def navigate_cell(self, ...): # auto-spanned
|
||||
...
|
||||
|
||||
# Exclude specific methods
|
||||
@profiler.trace_all(exclude=["__ft__", "render"])
|
||||
class DataGrid(MultipleInstance):
|
||||
...
|
||||
```
|
||||
|
||||
Implementation: uses `inspect` to iterate over methods and wraps each with `@profiler.span()`.
|
||||
No `sys.settrace()` involved — pure static wrapping.
|
||||
|
||||
#### 5. `trace_calls` — sub-call exploration
|
||||
|
||||
Traces all function calls made within a single function, recursively. Used for exploration
|
||||
when the bottleneck location is unknown.
|
||||
|
||||
```python
|
||||
@profiler.trace_calls
|
||||
def navigate_cell(self, ...):
|
||||
self._update_selection() # auto-traced as child span
|
||||
self._compute_visible() # auto-traced as child span
|
||||
db.query(...) # auto-traced as child span
|
||||
```
|
||||
|
||||
Implementation: uses `sys.setprofile()` scoped to the decorated function's execution only.
|
||||
Overhead is localized to that function's call stack. This is an exploration tool — use it
|
||||
to identify hotspots, then replace with explicit probes.
|
||||
|
||||
### Span Hierarchy
|
||||
|
||||
Hierarchy is determined by code nesting via a `ContextVar` stack (async-safe). No explicit
|
||||
parent references required.
|
||||
|
||||
```python
|
||||
with profiler.span("execute"): # root
|
||||
with profiler.span("callback"): # child of execute
|
||||
result = self.callback(...)
|
||||
with profiler.span("oob_swap"): # sibling of callback
|
||||
...
|
||||
```
|
||||
|
||||
When a command calls another command, the second command's spans automatically become children
|
||||
of the first command's active span.
|
||||
|
||||
`profiler.current_span()` provides access to the active span from anywhere in the call stack.
|
||||
|
||||
### Storage
|
||||
|
||||
- **Scope**: Global (all sessions). Profiling measures server behavior, not per-user state.
|
||||
- **Structure**: `deque` with a configurable maximum size.
|
||||
- **Default size**: 500 traces (constant `PROFILER_MAX_TRACES`).
|
||||
- **Eviction**: Oldest traces are dropped when the buffer is full (FIFO).
|
||||
- **Persistence**: In-memory only. Lost on server restart.
|
||||
|
||||
### Toggle and Clear
|
||||
|
||||
- `profiler.enabled` — boolean flag. When `False`, all probe mechanisms are no-ops (zero overhead).
|
||||
- `profiler.clear()` — empties the trace buffer.
|
||||
- Both are controllable from the UI control.
|
||||
|
||||
### Overhead Measurement
|
||||
|
||||
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
|
||||
Exposes:
|
||||
|
||||
- `overhead_per_span_us` — average cost of one span boundary in microseconds
|
||||
- `total_overhead_ms` — estimated total overhead across all active spans
|
||||
|
||||
Visible in the UI to verify the profiler does not bias measurements significantly.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
ProfilingTrace
|
||||
command_name: str
|
||||
command_id: str
|
||||
kwargs: dict
|
||||
timestamp: datetime
|
||||
total_duration_ms: float
|
||||
root_span: ProfilingSpan
|
||||
|
||||
ProfilingSpan
|
||||
name: str
|
||||
start: float (perf_counter)
|
||||
duration_ms: float
|
||||
data: dict (attached via span.set())
|
||||
children: list[ProfilingSpan | CumulativeSpan]
|
||||
|
||||
CumulativeSpan
|
||||
name: str
|
||||
count: int
|
||||
total_ms: float
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
avg_ms: float
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Hooks
|
||||
|
||||
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
|
||||
|
||||
```python
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
with profiler.command_span(command.name, c_id, client_response or {}):
|
||||
return command.execute(client_response)
|
||||
```
|
||||
|
||||
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
|
||||
|
||||
Planned breakdown inside `Command.execute()`:
|
||||
|
||||
```python
|
||||
def execute(self, client_response=None):
|
||||
with profiler.span("before_commands"):
|
||||
...
|
||||
with profiler.span("callback"):
|
||||
result = self.callback(...)
|
||||
with profiler.span("after_commands"):
|
||||
...
|
||||
with profiler.span("oob_swap"):
|
||||
...
|
||||
```
|
||||
|
||||
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
|
||||
|
||||
---
|
||||
|
||||
## UI Control Design
|
||||
|
||||
### Control name: `Profiler` (SingleInstance)
|
||||
|
||||
Single entry point. Replaces the earlier `ProfilerList` name.
|
||||
|
||||
**Files:**
|
||||
- `src/myfasthtml/controls/Profiler.py`
|
||||
- `src/myfasthtml/assets/core/profiler.css`
|
||||
|
||||
### Layout
|
||||
|
||||
Split view using `Panel`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
|
||||
├──────────────────────┬──────────────────────────────┤
|
||||
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
|
||||
│ ──────────────────────│ ─────────────────────────────│
|
||||
│ NavigateCell 173ms … │ [Metadata card] │
|
||||
│ NavigateCell 168ms … │ [kwargs card] │
|
||||
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
|
||||
│ … │ │
|
||||
└──────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
|
||||
Direct `mk.icon()` calls:
|
||||
|
||||
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
|
||||
- **Clear**: delete icon, always red
|
||||
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
|
||||
|
||||
Overhead metrics displayed as plain text on the right side of the toolbar.
|
||||
|
||||
### Trace list (left panel)
|
||||
|
||||
Three columns: command name / duration (color-coded) / timestamp.
|
||||
Click on a row → update right panel via HTMX.
|
||||
|
||||
**Duration color thresholds:**
|
||||
- Green (`mf-profiler-fast`): < 20 ms
|
||||
- Orange (`mf-profiler-medium`): 20–100 ms
|
||||
- Red (`mf-profiler-slow`): > 100 ms
|
||||
|
||||
### Detail panel (right)
|
||||
|
||||
Two view modes, toggled by icons in the detail panel header:
|
||||
|
||||
1. **Tree view** (default): Properties-style cards (Metadata, kwargs) + span breakdown with
|
||||
proportional bars and indentation. Cumulative spans show `×N · min/avg/max` badge.
|
||||
2. **Pie view**: `ProfilerPieChart` control (future) — distribution of time across spans
|
||||
at the current zoom level.
|
||||
|
||||
The `Properties` control is used as-is for Metadata and kwargs cards.
|
||||
The span breakdown is custom rendering (not a `Properties` instance).
|
||||
|
||||
### Font conventions
|
||||
|
||||
- Labels, headings, command names: `--font-sans` (DaisyUI default)
|
||||
- Values (durations, timestamps, kwargs values): `--font-mono`
|
||||
- Consistent with `properties.css` (`mf-properties-value` uses `--default-mono-font-family`)
|
||||
|
||||
### Visual reference
|
||||
|
||||
Mockups available in `examples/`:
|
||||
- `profiler_mockup.html` — first iteration (monospace font everywhere)
|
||||
- `profiler_mockup_2.html` — **reference** (correct fonts, icon toolbar, tree/pie toggle)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 — Core ✅ Complete
|
||||
|
||||
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
|
||||
2. `ProfilingManager` with all probe mechanisms
|
||||
3. `profiler` singleton
|
||||
4. Hook into `utils.py` (Level A) ✅
|
||||
5. Hook into `commands.py` (Level B) — deferred
|
||||
|
||||
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
|
||||
|
||||
### Phase 2 — Controls
|
||||
|
||||
#### Step 2.1 — Global layout (current) 🔄
|
||||
|
||||
`src/myfasthtml/controls/Profiler.py`:
|
||||
- `SingleInstance` inheriting
|
||||
- Toolbar: `mk.icon()` for enable/disable and clear, overhead text
|
||||
- `Panel` for split layout
|
||||
- Left: trace list table (command / duration / timestamp), click → select_trace command
|
||||
- Right: placeholder (empty until Step 2.2)
|
||||
|
||||
`src/myfasthtml/assets/core/profiler.css`:
|
||||
- All `mf-profiler-*` classes
|
||||
|
||||
#### Step 2.2 — Detail panel ⏳
|
||||
|
||||
Right panel content:
|
||||
- Metadata and kwargs via `Properties`
|
||||
- Span tree: custom `_mk_span_tree()` with bars and cumulative badges
|
||||
- View toggle (tree / pie) in detail header
|
||||
|
||||
#### Step 2.3 — Pie chart ⏳ Future
|
||||
|
||||
`src/myfasthtml/controls/ProfilerPieChart.py`
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Control files: `ProfilerXxx.py`
|
||||
- CSS classes: `mf-profiler-xxx`
|
||||
- Logger: `logging.getLogger("Profiler")`
|
||||
- Constant: `PROFILER_MAX_TRACES = 500` in `src/myfasthtml/core/constants.py`
|
||||
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`.
|
||||
598
docs/TreeView.md
Normal file
598
docs/TreeView.md
Normal file
@@ -0,0 +1,598 @@
|
||||
# TreeView Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Expand/collapse nodes with visual indicators
|
||||
- Add child and sibling nodes dynamically
|
||||
- Inline rename with keyboard support (ESC to cancel)
|
||||
- Delete nodes (only leaf nodes without children)
|
||||
- Node selection tracking
|
||||
- Persistent state per session
|
||||
- Configurable icons per node type
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- File/folder browser
|
||||
- Category/subcategory management
|
||||
- Organizational hierarchy viewer
|
||||
- Navigation menu builder
|
||||
- Document outline editor
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a file system tree:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create TreeView instance
|
||||
tree = TreeView(parent=root_instance, _id="file-tree")
|
||||
|
||||
# Add root folder
|
||||
root = TreeNode(id="root", label="Documents", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add some files
|
||||
file1 = TreeNode(id="file1", label="report.pdf", type="file")
|
||||
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
|
||||
tree.add_node(file1, parent_id="root")
|
||||
tree.add_node(file2, parent_id="root")
|
||||
|
||||
# Expand root to show children
|
||||
tree.expand_all()
|
||||
|
||||
# Render the tree
|
||||
return tree
|
||||
```
|
||||
|
||||
This creates an interactive tree where users can:
|
||||
- Click chevrons to expand/collapse folders
|
||||
- Click labels to select items
|
||||
- Use action buttons (visible on hover) to add, rename, or delete nodes
|
||||
|
||||
**Note:** All interactions use commands and update via HTMX without page reload.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating a TreeView
|
||||
|
||||
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
|
||||
|
||||
```python
|
||||
tree = TreeView(parent=root_instance, _id="my-tree")
|
||||
```
|
||||
|
||||
### TreeNode Structure
|
||||
|
||||
Nodes are represented by the `TreeNode` dataclass:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.TreeView import TreeNode
|
||||
|
||||
node = TreeNode(
|
||||
id="unique-id", # Auto-generated UUID if not provided
|
||||
label="Node Label", # Display text
|
||||
type="default", # Type for icon mapping
|
||||
parent=None, # Parent node ID (None for root)
|
||||
children=[] # List of child node IDs
|
||||
)
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Add nodes using the `add_node()` method:
|
||||
|
||||
```python
|
||||
# Add root node
|
||||
root = TreeNode(id="root", label="Root", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
# Add child node
|
||||
child = TreeNode(label="Child 1", type="item")
|
||||
tree.add_node(child, parent_id="root")
|
||||
|
||||
# Add with specific position
|
||||
sibling = TreeNode(label="Child 2", type="item")
|
||||
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
|
||||
```
|
||||
|
||||
### Visual Structure
|
||||
|
||||
```
|
||||
TreeView
|
||||
├── Root Node 1
|
||||
│ ├── [>] Child 1-1 # Collapsed node with children
|
||||
│ ├── [ ] Child 1-2 # Leaf node (no children)
|
||||
│ └── [v] Child 1-3 # Expanded node
|
||||
│ ├── [ ] Grandchild
|
||||
│ └── [ ] Grandchild
|
||||
└── Root Node 2
|
||||
└── [>] Child 2-1
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
- `[>]` - Collapsed node (has children)
|
||||
- `[v]` - Expanded node (has children)
|
||||
- `[ ]` - Leaf node (no children)
|
||||
|
||||
### Expanding Nodes
|
||||
|
||||
Control node expansion programmatically:
|
||||
|
||||
```python
|
||||
# Expand all nodes with children
|
||||
tree.expand_all()
|
||||
|
||||
# Expand specific nodes by adding to opened list
|
||||
tree._state.opened.append("node-id")
|
||||
```
|
||||
|
||||
**Note:** Users can also toggle nodes by clicking the chevron icon.
|
||||
|
||||
## Interactive Features
|
||||
|
||||
### Node Selection
|
||||
|
||||
Users can select nodes by clicking on labels. The selected node is visually highlighted:
|
||||
|
||||
```python
|
||||
# Programmatically select a node
|
||||
tree._state.selected = "node-id"
|
||||
|
||||
# Check current selection
|
||||
current = tree._state.selected
|
||||
```
|
||||
|
||||
### Adding Nodes
|
||||
|
||||
Users can add nodes via action buttons (visible on hover):
|
||||
|
||||
**Add Child:**
|
||||
- Adds a new node as a child of the target node
|
||||
- Automatically expands the parent
|
||||
- Creates node with same type as parent
|
||||
|
||||
**Add Sibling:**
|
||||
- Adds a new node next to the target node (same parent)
|
||||
- Inserts after the target node
|
||||
- Cannot add sibling to root nodes
|
||||
|
||||
```python
|
||||
# Programmatically add child
|
||||
tree._add_child(parent_id="root", new_label="New Child")
|
||||
|
||||
# Programmatically add sibling
|
||||
tree._add_sibling(node_id="child1", new_label="New Sibling")
|
||||
```
|
||||
|
||||
### Renaming Nodes
|
||||
|
||||
Users can rename nodes via the edit button:
|
||||
|
||||
1. Click the edit icon (visible on hover)
|
||||
2. Input field appears with current label
|
||||
3. Press Enter to save (triggers command)
|
||||
4. Press ESC to cancel (keyboard shortcut)
|
||||
|
||||
```python
|
||||
# Programmatically start rename
|
||||
tree.handle_start_rename("node-id")
|
||||
|
||||
# Save rename
|
||||
tree.handle_save_rename("node-id", "New Label")
|
||||
|
||||
# Cancel rename
|
||||
tree.handle_cancel_rename()
|
||||
```
|
||||
|
||||
### Deleting Nodes
|
||||
|
||||
Users can delete nodes via the delete button:
|
||||
|
||||
**Restrictions:**
|
||||
- Can only delete leaf nodes (no children)
|
||||
- Attempting to delete a node with children raises an error
|
||||
- Deleted node is removed from parent's children list
|
||||
|
||||
```python
|
||||
# Programmatically delete node
|
||||
tree.handle_delete_node("node-id") # Raises ValueError if node has children
|
||||
```
|
||||
|
||||
## Content System
|
||||
|
||||
### Node Types and Icons
|
||||
|
||||
Assign types to nodes for semantic grouping and custom icon display:
|
||||
|
||||
```python
|
||||
# Define node types
|
||||
root = TreeNode(label="Project", type="project")
|
||||
folder = TreeNode(label="src", type="folder")
|
||||
file = TreeNode(label="main.py", type="python-file")
|
||||
|
||||
# Configure icons for types
|
||||
tree.set_icon_config({
|
||||
"project": "fluent.folder_open",
|
||||
"folder": "fluent.folder",
|
||||
"python-file": "fluent.document_python"
|
||||
})
|
||||
```
|
||||
|
||||
**Note:** Icon configuration is stored in state and persists within the session.
|
||||
|
||||
### Hierarchical Organization
|
||||
|
||||
Nodes automatically maintain parent-child relationships:
|
||||
|
||||
```python
|
||||
# Get node's children
|
||||
node = tree._state.items["node-id"]
|
||||
child_ids = node.children
|
||||
|
||||
# Get node's parent
|
||||
parent_id = node.parent
|
||||
|
||||
# Navigate tree programmatically
|
||||
for child_id in node.children:
|
||||
child_node = tree._state.items[child_id]
|
||||
print(child_node.label)
|
||||
```
|
||||
|
||||
### Finding Root Nodes
|
||||
|
||||
Root nodes are nodes without a parent:
|
||||
|
||||
```python
|
||||
root_nodes = [
|
||||
node_id for node_id, node in tree._state.items.items()
|
||||
if node.parent is None
|
||||
]
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
TreeView includes keyboard support for common operations:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `ESC` | Cancel rename operation |
|
||||
|
||||
Additional shortcuts can be added via the Keyboard component:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
|
||||
tree = TreeView(parent=root_instance)
|
||||
# ESC handler is automatically included for cancel rename
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
TreeView maintains persistent state within the session:
|
||||
|
||||
| State Property | Type | Description |
|
||||
|----------------|------|-------------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
|
||||
| `opened` | `list[str]` | IDs of expanded nodes |
|
||||
| `selected` | `str \| None` | Currently selected node ID |
|
||||
| `editing` | `str \| None` | Node being renamed (if any) |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
|
||||
|
||||
### Dynamic Updates
|
||||
|
||||
TreeView updates are handled via commands that return the updated tree:
|
||||
|
||||
```python
|
||||
# Commands automatically target the tree for HTMX swap
|
||||
cmd = tree.commands.toggle_node("node-id")
|
||||
# When executed, returns updated TreeView with new state
|
||||
```
|
||||
|
||||
### CSS Customization
|
||||
|
||||
TreeView uses CSS classes for styling:
|
||||
|
||||
| Class | Element |
|
||||
|-------|---------|
|
||||
| `mf-treeview` | Root container |
|
||||
| `mf-treenode-container` | Container for node and its children |
|
||||
| `mf-treenode` | Individual node row |
|
||||
| `mf-treenode.selected` | Selected node highlight |
|
||||
| `mf-treenode-label` | Node label text |
|
||||
| `mf-treenode-input` | Input field during rename |
|
||||
| `mf-treenode-actions` | Action buttons container (hover) |
|
||||
|
||||
You can override these classes to customize appearance:
|
||||
|
||||
```css
|
||||
.mf-treenode.selected {
|
||||
background-color: #e0f2fe;
|
||||
border-left: 3px solid #0284c7;
|
||||
}
|
||||
|
||||
.mf-treenode-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: File System Browser
|
||||
|
||||
A file/folder browser with different node types:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
# Create tree
|
||||
tree = TreeView(parent=root_instance, _id="file-browser")
|
||||
|
||||
# Configure icons
|
||||
tree.set_icon_config({
|
||||
"folder": "fluent.folder",
|
||||
"python": "fluent.document_python",
|
||||
"text": "fluent.document_text"
|
||||
})
|
||||
|
||||
# Build file structure
|
||||
root = TreeNode(id="root", label="my-project", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
src = TreeNode(id="src", label="src", type="folder")
|
||||
tree.add_node(src, parent_id="root")
|
||||
|
||||
main = TreeNode(label="main.py", type="python")
|
||||
utils = TreeNode(label="utils.py", type="python")
|
||||
tree.add_node(main, parent_id="src")
|
||||
tree.add_node(utils, parent_id="src")
|
||||
|
||||
readme = TreeNode(label="README.md", type="text")
|
||||
tree.add_node(readme, parent_id="root")
|
||||
|
||||
# Expand to show structure
|
||||
tree.expand_all()
|
||||
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 2: Category Management
|
||||
|
||||
Managing product categories with inline editing:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="categories")
|
||||
|
||||
# Root categories
|
||||
electronics = TreeNode(id="elec", label="Electronics", type="category")
|
||||
tree.add_node(electronics)
|
||||
|
||||
# Subcategories
|
||||
computers = TreeNode(label="Computers", type="subcategory")
|
||||
phones = TreeNode(label="Phones", type="subcategory")
|
||||
tree.add_node(computers, parent_id="elec")
|
||||
tree.add_node(phones, parent_id="elec")
|
||||
|
||||
# Products (leaf nodes)
|
||||
laptop = TreeNode(label="Laptops", type="product")
|
||||
desktop = TreeNode(label="Desktops", type="product")
|
||||
tree.add_node(laptop, parent_id=computers.id)
|
||||
tree.add_node(desktop, parent_id=computers.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 3: Document Outline Editor
|
||||
|
||||
Building a document outline with headings:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="outline")
|
||||
|
||||
# Document structure
|
||||
doc = TreeNode(id="doc", label="My Document", type="document")
|
||||
tree.add_node(doc)
|
||||
|
||||
# Chapters
|
||||
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
|
||||
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
|
||||
tree.add_node(ch1, parent_id="doc")
|
||||
tree.add_node(ch2, parent_id="doc")
|
||||
|
||||
# Sections
|
||||
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
|
||||
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
|
||||
tree.add_node(sec1_1, parent_id="ch1")
|
||||
tree.add_node(sec1_2, parent_id="ch1")
|
||||
|
||||
# Subsections
|
||||
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
|
||||
tree.add_node(subsec, parent_id=sec1_1.id)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Tree with Event Handling
|
||||
|
||||
Responding to tree events with custom logic:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||
|
||||
# Initial structure
|
||||
root = TreeNode(id="root", label="Tasks", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
|
||||
# Function to handle selection
|
||||
def on_node_selected(node_id):
|
||||
# Custom logic when node is selected
|
||||
node = tree._state.items[node_id]
|
||||
tree.handle_select_node(node_id)
|
||||
|
||||
# Update a detail panel elsewhere in the UI
|
||||
return Div(
|
||||
H3(f"Selected: {node.label}"),
|
||||
P(f"Type: {node.type}"),
|
||||
P(f"Children: {len(node.children)}")
|
||||
)
|
||||
|
||||
|
||||
# Override select command with custom handler
|
||||
# (In practice, you'd extend the Commands class or use event callbacks)
|
||||
|
||||
tree.expand_all()
|
||||
return tree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TreeView component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TreeView component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|------|------|-------------|---------|
|
||||
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
|
||||
| `opened` | `list[str]` | Expanded node IDs | `[]` |
|
||||
| `selected` | `str \| None` | Selected node ID | `None` |
|
||||
| `editing` | `str \| None` | Node being renamed | `None` |
|
||||
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `add_child(parent_id)` | Add child node to parent |
|
||||
| `add_sibling(node_id)` | Add sibling node after target |
|
||||
| `start_rename(node_id)` | Enter rename mode for node |
|
||||
| `save_rename(node_id)` | Save renamed node label |
|
||||
| `cancel_rename()` | Cancel rename operation |
|
||||
| `delete_node(node_id)` | Delete node (if no children) |
|
||||
| `select_node(node_id)` | Select a node |
|
||||
|
||||
All commands automatically target the TreeView component for HTMX updates.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
|
||||
| `expand_all()` | Expand all nodes with children |
|
||||
| `set_icon_config(config)` | Configure icons for node types |
|
||||
| `render()` | Render the complete TreeView |
|
||||
|
||||
### TreeNode Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
id: str # Unique identifier (auto-generated UUID)
|
||||
label: str = "" # Display text
|
||||
type: str = "default" # Node type for icon mapping
|
||||
parent: Optional[str] = None # Parent node ID
|
||||
children: list[str] = [] # Child node IDs
|
||||
```
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="treeview", cls="mf-treeview")
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root1")
|
||||
│ ├── Div(cls="mf-treenode")
|
||||
│ │ ├── Icon # Toggle chevron
|
||||
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
|
||||
│ │ └── Div(cls="mf-treenode-actions")
|
||||
│ │ ├── Icon # Add child
|
||||
│ │ ├── Icon # Rename
|
||||
│ │ └── Icon # Delete
|
||||
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
|
||||
│ └── ...
|
||||
├── Div(cls="mf-treenode-container", data-node-id="root2")
|
||||
│ └── ...
|
||||
└── Keyboard # ESC handler
|
||||
```
|
||||
|
||||
### Element IDs and Attributes
|
||||
|
||||
| Attribute | Element | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `id` | Root Div | TreeView component ID |
|
||||
| `data-node-id` | Node container | Node's unique ID |
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering and state management:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `_toggle_node(node_id)` | Toggle expand/collapse state |
|
||||
| `_add_child(parent_id, new_label)` | Add child node implementation |
|
||||
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
|
||||
| `_start_rename(node_id)` | Enter rename mode |
|
||||
| `_save_rename(node_id, node_label)` | Save renamed node |
|
||||
| `_cancel_rename()` | Cancel rename operation |
|
||||
| `_delete_node(node_id)` | Delete node if no children |
|
||||
| `_select_node(node_id)` | Select a node |
|
||||
| `_render_action_buttons(node_id)` | Render hover action buttons |
|
||||
| `_render_node(node_id, level)` | Recursively render node and children |
|
||||
|
||||
### Commands Class
|
||||
|
||||
The `Commands` nested class provides command factory methods:
|
||||
|
||||
| Method | Returns |
|
||||
|--------|---------|
|
||||
| `toggle_node(node_id)` | Command to toggle node |
|
||||
| `add_child(parent_id)` | Command to add child |
|
||||
| `add_sibling(node_id)` | Command to add sibling |
|
||||
| `start_rename(node_id)` | Command to start rename |
|
||||
| `save_rename(node_id)` | Command to save rename |
|
||||
| `cancel_rename()` | Command to cancel rename |
|
||||
| `delete_node(node_id)` | Command to delete node |
|
||||
| `select_node(node_id)` | Command to select node |
|
||||
|
||||
All commands are automatically configured with HTMX targeting.
|
||||
|
||||
### Integration with Keyboard Component
|
||||
|
||||
TreeView includes a Keyboard component for ESC key handling:
|
||||
|
||||
```python
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
|
||||
```
|
||||
|
||||
This enables users to press ESC to cancel rename operations without clicking.
|
||||
1142
docs/testing_rendered_components.md
Normal file
1142
docs/testing_rendered_components.md
Normal file
File diff suppressed because it is too large
Load Diff
806
examples/canvas_graph_prototype.html
Normal file
806
examples/canvas_graph_prototype.html
Normal file
@@ -0,0 +1,806 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>InstancesDebugger — Canvas Prototype v2</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--surface2: #1c2128;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #7d8590;
|
||||
--accent: #388bfd;
|
||||
--selected: #f0883e;
|
||||
--match: #e3b341;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────── */
|
||||
#toolbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.app-icon { font-size: 16px; color: var(--accent); margin-right: 2px; }
|
||||
|
||||
#title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: color .12s, background .12s, border-color .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-btn:hover { color: var(--text); background: var(--surface2); border-color: var(--border); }
|
||||
|
||||
.tb-btn svg { flex-shrink: 0; }
|
||||
|
||||
#search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 240px;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 9px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
#search {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px 5px 30px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
#search:focus { border-color: var(--accent); }
|
||||
#search::placeholder { color: var(--muted); }
|
||||
|
||||
#match-count {
|
||||
position: absolute;
|
||||
right: 9px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#zoom-display {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────── */
|
||||
#main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
#canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
#canvas-container.panning { cursor: grabbing; }
|
||||
#canvas-container.hovering { cursor: pointer; }
|
||||
|
||||
#graph-canvas { position: absolute; top: 0; left: 0; display: block; }
|
||||
|
||||
/* ── Legend ──────────────────────────────────────── */
|
||||
#legend {
|
||||
position: absolute;
|
||||
bottom: 14px; left: 14px;
|
||||
background: rgba(22, 27, 34, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 13px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.leg { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
|
||||
.leg:last-child { margin-bottom: 0; }
|
||||
.leg-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
/* ── Properties panel ────────────────────────────── */
|
||||
#props-panel {
|
||||
width: 270px;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
transition: width .2s;
|
||||
}
|
||||
#props-panel.empty { align-items: center; justify-content: center; }
|
||||
#props-empty { color: var(--muted); font-size: 13px; }
|
||||
|
||||
#props-scroll { overflow-y: auto; flex: 1; }
|
||||
|
||||
.ph {
|
||||
padding: 11px 14px 8px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ph-label { font-size: 13px; font-weight: 600; font-family: monospace; }
|
||||
.ph-kind {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
font-size: 10px; font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ps { /* props section */
|
||||
padding: 4px 14px;
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--accent);
|
||||
background: rgba(56, 139, 253, 0.07);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pr { /* props row */
|
||||
display: flex;
|
||||
padding: 4px 14px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.6);
|
||||
font-size: 12px; font-family: monospace;
|
||||
}
|
||||
.pk { color: var(--muted); flex: 0 0 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 6px; }
|
||||
.pv { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="toolbar">
|
||||
<span class="app-icon">⬡</span>
|
||||
<span id="title">InstancesDebugger</span>
|
||||
<div class="tb-sep"></div>
|
||||
|
||||
<!-- Expand all -->
|
||||
<button class="tb-btn" id="expand-all-btn" title="Expand all nodes">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 4l5 5 5-5"/>
|
||||
<path d="M2 8l5 5 5-5"/>
|
||||
</svg>
|
||||
Expand
|
||||
</button>
|
||||
|
||||
<!-- Collapse all -->
|
||||
<button class="tb-btn" id="collapse-all-btn" title="Collapse all nodes">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 6l5-5 5 5"/>
|
||||
<path d="M2 10l5-5 5 5"/>
|
||||
</svg>
|
||||
Collapse
|
||||
</button>
|
||||
|
||||
<div class="tb-sep"></div>
|
||||
|
||||
<div id="search-wrapper">
|
||||
<svg class="search-icon" width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="7" cy="7" r="5"/><path d="m11 11 3 3"/>
|
||||
</svg>
|
||||
<input id="search" type="text" placeholder="Filter instances…" autocomplete="off" spellcheck="false">
|
||||
<span id="match-count"></span>
|
||||
</div>
|
||||
|
||||
<div class="tb-sep"></div>
|
||||
<button class="tb-btn" id="fit-btn" title="Fit all nodes">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M1 5V1h4M15 5V1h-4M1 11v4h4M15 11v4h-4"/>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1"/>
|
||||
</svg>
|
||||
Fit
|
||||
</button>
|
||||
<span id="zoom-display">100%</span>
|
||||
<span id="hint">Wheel · Drag · Click</span>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="canvas-container">
|
||||
<canvas id="graph-canvas"></canvas>
|
||||
<div id="legend">
|
||||
<div class="leg"><div class="leg-dot" style="background:#2563eb"></div>RootInstance</div>
|
||||
<div class="leg"><div class="leg-dot" style="background:#7c3aed"></div>SingleInstance</div>
|
||||
<div class="leg"><div class="leg-dot" style="background:#047857"></div>MultipleInstance</div>
|
||||
<div class="leg"><div class="leg-dot" style="background:#b45309"></div>UniqueInstance</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="props-panel" class="empty">
|
||||
<span id="props-empty">Click a node to inspect</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODES = [
|
||||
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' },
|
||||
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout', description: 'Main layout with 2 panels' },
|
||||
{ id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
|
||||
{ id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' },
|
||||
{ id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger', description: 'Debug tool for instances' },
|
||||
{ id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
|
||||
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph', description: 'Canvas-based graph view' },
|
||||
{ id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager', description: 'Manages all data grids' },
|
||||
{ id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid', description: '42 rows × 5 columns' },
|
||||
{ id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
|
||||
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
|
||||
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' },
|
||||
];
|
||||
|
||||
const EDGES = [
|
||||
{ from: 'app', to: 'layout' },
|
||||
{ from: 'app', to: 'instances_debugger' },
|
||||
{ from: 'app', to: 'data_grid_manager' },
|
||||
{ from: 'app', to: 'auth_proxy' },
|
||||
{ from: 'layout', to: 'left_panel' },
|
||||
{ from: 'layout', to: 'right_panel' },
|
||||
{ from: 'instances_debugger', to: 'dbg_panel' },
|
||||
{ from: 'dbg_panel', to: 'canvas_graph' },
|
||||
{ from: 'data_grid_manager', to: 'my_grid' },
|
||||
{ from: 'my_grid', to: 'grid_toolbar' },
|
||||
{ from: 'my_grid', to: 'grid_search' },
|
||||
];
|
||||
|
||||
const DETAILS = {
|
||||
app: { Main: { Id: 'app', 'Parent Id': '—' }, State: { _name: 'AppState' } },
|
||||
layout: { Main: { Id: 'layout', 'Parent Id': 'app' }, State: { _name: 'LayoutState', left_open: 'true', right_open: 'true' } },
|
||||
left_panel: { Main: { Id: 'left_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '240px' } },
|
||||
right_panel: { Main: { Id: 'right_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '320px' } },
|
||||
instances_debugger: { Main: { Id: 'instances_debugger', 'Parent Id': 'app' }, State: { _name: 'InstancesDebuggerState' }, Commands: { ShowInstance: 'on_show_node'} },
|
||||
dbg_panel: { Main: { Id: 'dbg#panel', 'Parent Id': 'instances_debugger' }, State: { _name: 'PanelState', width: '280px' } },
|
||||
canvas_graph: { Main: { Id: 'dbg#canvas_graph', 'Parent Id': 'dbg#panel' }, State: { _name: 'CanvasGraphState', nodes: '12', edges: '11' } },
|
||||
data_grid_manager: { Main: { Id: 'data_grid_manager', 'Parent Id': 'app' }, State: { _name: 'DataGridsManagerState', grids: '1' } },
|
||||
my_grid: { Main: { Id: 'my_grid', 'Parent Id': 'data_grid_manager' }, State: { _name: 'DataGridState', rows: '42', cols: '5', page: '0' }, Commands: { DeleteRow: 'on_delete', AddRow: 'on_add' } },
|
||||
grid_toolbar: { Main: { Id: 'my_grid#toolbar', 'Parent Id': 'my_grid' }, State: { _name: 'ToolbarState' } },
|
||||
grid_search: { Main: { Id: 'my_grid#search', 'Parent Id': 'my_grid' }, State: { _name: 'SearchState', query: '' } },
|
||||
auth_proxy: { Main: { Id: 'auth_proxy', 'Parent Id': 'app' }, State: { _name: 'AuthProxyState', user: 'admin@example.com', roles: 'admin' } },
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODE_W = 178;
|
||||
const NODE_H_SMALL = 36; // Without description
|
||||
const NODE_H_LARGE = 54; // With description
|
||||
const LEVEL_H = 96; // Vertical distance between levels
|
||||
const LEAF_GAP = 22; // Horizontal gap between leaf slots
|
||||
const CHEV_ZONE = 26; // Rightmost px = toggle hit zone
|
||||
|
||||
function getNodeHeight(node) {
|
||||
return node.description ? NODE_H_LARGE : NODE_H_SMALL;
|
||||
}
|
||||
|
||||
const TYPE_COLOR = {
|
||||
root: '#2563eb',
|
||||
single: '#7c3aed',
|
||||
multiple: '#047857',
|
||||
unique: '#b45309',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Graph structure
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const childMap = {};
|
||||
const hasParentSet = new Set();
|
||||
|
||||
for (const n of NODES) childMap[n.id] = [];
|
||||
for (const e of EDGES) {
|
||||
(childMap[e.from] = childMap[e.from] || []).push(e.to);
|
||||
hasParentSet.add(e.to);
|
||||
}
|
||||
|
||||
function hasChildren(id) { return (childMap[id] || []).length > 0; }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Collapse state
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const collapsed = new Set();
|
||||
|
||||
function getHiddenSet() {
|
||||
const hidden = new Set();
|
||||
function addDesc(id) {
|
||||
for (const c of childMap[id] || []) {
|
||||
if (!hidden.has(c)) { hidden.add(c); addDesc(c); }
|
||||
}
|
||||
}
|
||||
for (const id of collapsed) addDesc(id);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function visNodes() {
|
||||
const h = getHiddenSet();
|
||||
return NODES.filter(n => !h.has(n.id));
|
||||
}
|
||||
|
||||
function visEdges(vn) {
|
||||
const vi = new Set(vn.map(n => n.id));
|
||||
return EDGES.filter(e => vi.has(e.from) && vi.has(e.to));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Layout engine (Reingold-Tilford simplified)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function computeLayout(nodes, edges) {
|
||||
const cm = {};
|
||||
const hp = new Set();
|
||||
for (const n of nodes) cm[n.id] = [];
|
||||
for (const e of edges) { (cm[e.from] = cm[e.from] || []).push(e.to); hp.add(e.to); }
|
||||
|
||||
const roots = nodes.filter(n => !hp.has(n.id)).map(n => n.id);
|
||||
|
||||
const depth = {};
|
||||
const q = roots.map(r => [r, 0]);
|
||||
while (q.length) {
|
||||
const [id, d] = q.shift();
|
||||
if (depth[id] !== undefined) continue;
|
||||
depth[id] = d;
|
||||
for (const c of cm[id] || []) q.push([c, d + 1]);
|
||||
}
|
||||
|
||||
const pos = {};
|
||||
for (const n of nodes) pos[n.id] = { x: 0, y: (depth[n.id] || 0) * LEVEL_H };
|
||||
|
||||
let slot = 0;
|
||||
function dfs(id) {
|
||||
const children = cm[id] || [];
|
||||
if (children.length === 0) { pos[id].x = slot++ * (NODE_W + LEAF_GAP); return; }
|
||||
for (const c of children) dfs(c);
|
||||
const xs = children.map(c => pos[c].x);
|
||||
pos[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
}
|
||||
for (const r of roots) dfs(r);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
let pos = {};
|
||||
function recomputeLayout() {
|
||||
const vn = visNodes();
|
||||
pos = computeLayout(vn, visEdges(vn));
|
||||
}
|
||||
recomputeLayout();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Canvas
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const canvas = document.getElementById('graph-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const container = document.getElementById('canvas-container');
|
||||
const zoomEl = document.getElementById('zoom-display');
|
||||
|
||||
let transform = { x: 0, y: 0, scale: 1 };
|
||||
let selectedId = null;
|
||||
let filterQuery = '';
|
||||
|
||||
// ── Dot grid background (Figma-style, fixed screen-space dots) ──
|
||||
function drawDotGrid() {
|
||||
const spacing = 24;
|
||||
const ox = ((transform.x % spacing) + spacing) % spacing;
|
||||
const oy = ((transform.y % spacing) + spacing) % spacing;
|
||||
ctx.fillStyle = 'rgba(125,133,144,0.12)';
|
||||
for (let x = ox - spacing; x < canvas.width + spacing; x += spacing)
|
||||
for (let y = oy - spacing; y < canvas.height + spacing; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main draw ────────────────────────────────────────────
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawDotGrid();
|
||||
|
||||
const q = filterQuery.trim().toLowerCase();
|
||||
const matchIds = q
|
||||
? new Set(NODES.filter(n =>
|
||||
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
|
||||
).map(n => n.id))
|
||||
: null;
|
||||
|
||||
// Update match count display
|
||||
const matchCountEl = document.getElementById('match-count');
|
||||
matchCountEl.textContent = matchIds ? `${matchIds.size}` : '';
|
||||
|
||||
const vn = visNodes();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(transform.x, transform.y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
|
||||
// Edges
|
||||
for (const edge of EDGES) {
|
||||
const p1 = pos[edge.from], p2 = pos[edge.to];
|
||||
if (!p1 || !p2) continue;
|
||||
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
||||
const node1 = NODES.find(n => n.id === edge.from);
|
||||
const node2 = NODES.find(n => n.id === edge.to);
|
||||
const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
|
||||
const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
|
||||
const x1 = p1.x, y1 = p1.y + h1 / 2;
|
||||
const x2 = p2.x, y2 = p2.y - h2 / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(x1, cy, x2, cy, x2, y2);
|
||||
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const node of vn) {
|
||||
const p = pos[node.id];
|
||||
if (!p) continue;
|
||||
const isSel = node.id === selectedId;
|
||||
const isMatch = matchIds !== null && matchIds.has(node.id);
|
||||
const isDim = matchIds !== null && !matchIds.has(node.id);
|
||||
drawNode(node, p.x, p.y, isSel, isMatch, isDim);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Update zoom indicator
|
||||
zoomEl.textContent = `${Math.round(transform.scale * 100)}%`;
|
||||
}
|
||||
|
||||
// ── Node renderer ────────────────────────────────────────
|
||||
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
||||
const nodeH = getNodeHeight(node);
|
||||
const hw = NODE_W / 2, hh = nodeH / 2, r = 6;
|
||||
const x = cx - hw, y = cy - hh;
|
||||
const color = TYPE_COLOR[node.type] || '#334155';
|
||||
|
||||
ctx.globalAlpha = isDim ? 0.15 : 1;
|
||||
|
||||
// Glow for selected
|
||||
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; }
|
||||
|
||||
// Background — dark card
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Left color strip (clipped)
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, 4, nodeH);
|
||||
ctx.restore();
|
||||
|
||||
// Border
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
if (isSel) {
|
||||
ctx.strokeStyle = '#f0883e';
|
||||
ctx.lineWidth = 1.5;
|
||||
} else if (isMatch) {
|
||||
ctx.strokeStyle = '#e3b341';
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
ctx.strokeStyle = `${color}44`;
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Kind badge (top-right, small pill)
|
||||
const kindText = node.kind;
|
||||
ctx.font = '9px system-ui';
|
||||
const rawW = ctx.measureText(kindText).width;
|
||||
const badgeW = Math.min(rawW + 8, 66);
|
||||
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
|
||||
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
|
||||
const badgeY = y + (nodeH - 14) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
|
||||
ctx.fillStyle = `${color}22`;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// Truncate kind if needed
|
||||
let kLabel = kindText;
|
||||
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6) kLabel = kLabel.slice(0, -1);
|
||||
if (kLabel !== kindText) kLabel += '…';
|
||||
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
|
||||
|
||||
// Label (centered if no description, top if description)
|
||||
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
|
||||
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const labelX = x + 12;
|
||||
const labelMaxW = badgeX - labelX - 6;
|
||||
let label = node.label;
|
||||
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
|
||||
if (label !== node.label) label += '…';
|
||||
const labelY = node.description ? cy - 9 : cy;
|
||||
ctx.fillText(label, labelX, labelY);
|
||||
|
||||
// Description (bottom line, only if present)
|
||||
if (node.description) {
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
|
||||
let desc = node.description;
|
||||
while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
|
||||
if (desc !== node.description) desc += '…';
|
||||
ctx.fillText(desc, labelX, cy + 8);
|
||||
}
|
||||
|
||||
// Chevron toggle (if has children)
|
||||
if (hasChildren(node.id)) {
|
||||
const chevX = x + NODE_W - CHEV_ZONE / 2 - 1;
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
drawChevron(ctx, chevX, cy, !isCollapsed, color);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawChevron(ctx, cx, cy, pointDown, color) {
|
||||
const s = 4;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
if (pointDown) {
|
||||
ctx.moveTo(cx - s, cy - s * 0.5);
|
||||
ctx.lineTo(cx, cy + s * 0.5);
|
||||
ctx.lineTo(cx + s, cy - s * 0.5);
|
||||
} else {
|
||||
ctx.moveTo(cx - s * 0.5, cy - s);
|
||||
ctx.lineTo(cx + s * 0.5, cy);
|
||||
ctx.lineTo(cx - s * 0.5, cy + s);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Fit all
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function fitAll() {
|
||||
const vn = visNodes();
|
||||
if (vn.length === 0) return;
|
||||
const pad = 48;
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const n of vn) {
|
||||
const p = pos[n.id];
|
||||
if (!p) continue;
|
||||
const h = getNodeHeight(n);
|
||||
minX = Math.min(minX, p.x - NODE_W / 2);
|
||||
maxX = Math.max(maxX, p.x + NODE_W / 2);
|
||||
minY = Math.min(minY, p.y - h / 2);
|
||||
maxY = Math.max(maxY, p.y + h / 2);
|
||||
}
|
||||
minX -= pad; maxX += pad; minY -= pad; maxY += pad;
|
||||
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
|
||||
transform.scale = scale;
|
||||
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
|
||||
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Hit testing — returns { node, isToggle }
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function hitTest(sx, sy) {
|
||||
const wx = (sx - transform.x) / transform.scale;
|
||||
const wy = (sy - transform.y) / transform.scale;
|
||||
const vn = visNodes();
|
||||
for (let i = vn.length - 1; i >= 0; i--) {
|
||||
const n = vn[i];
|
||||
const p = pos[n.id];
|
||||
if (!p) continue;
|
||||
const nodeH = getNodeHeight(n);
|
||||
if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= nodeH / 2) {
|
||||
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
|
||||
return { node: n, isToggle };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Properties panel
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const propsPanel = document.getElementById('props-panel');
|
||||
|
||||
function showProperties(node) {
|
||||
const details = DETAILS[node.id] || {};
|
||||
const color = TYPE_COLOR[node.type] || '#334155';
|
||||
let html = `
|
||||
<div class="ph">
|
||||
<div class="ph-label">${node.label}</div>
|
||||
<span class="ph-kind" style="background:${color}">${node.kind}</span>
|
||||
</div>
|
||||
<div id="props-scroll">`;
|
||||
for (const [section, rows] of Object.entries(details)) {
|
||||
html += `<div class="ps">${section}</div>`;
|
||||
for (const [k, v] of Object.entries(rows))
|
||||
html += `<div class="pr"><span class="pk" title="${k}">${k}</span><span class="pv" title="${v}">${v}</span></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
propsPanel.innerHTML = html;
|
||||
propsPanel.classList.remove('empty');
|
||||
}
|
||||
|
||||
function clearProperties() {
|
||||
propsPanel.innerHTML = '<span id="props-empty">Click a node to inspect</span>';
|
||||
propsPanel.classList.add('empty');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Interaction
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let isPanning = false;
|
||||
let panOrigin = { x: 0, y: 0 };
|
||||
let tfAtStart = null;
|
||||
let didMove = false;
|
||||
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
isPanning = true; didMove = false;
|
||||
panOrigin = { x: e.clientX, y: e.clientY };
|
||||
tfAtStart = { ...transform };
|
||||
container.classList.add('panning');
|
||||
container.classList.remove('hovering');
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (isPanning) {
|
||||
const dx = e.clientX - panOrigin.x;
|
||||
const dy = e.clientY - panOrigin.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
|
||||
transform.x = tfAtStart.x + dx;
|
||||
transform.y = tfAtStart.y + dy;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
// Hover cursor
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
container.classList.toggle('hovering', !!hit);
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (!isPanning) return;
|
||||
isPanning = false;
|
||||
container.classList.remove('panning');
|
||||
|
||||
if (!didMove) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (hit) {
|
||||
if (hit.isToggle) {
|
||||
// Toggle collapse
|
||||
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
|
||||
else collapsed.add(hit.node.id);
|
||||
recomputeLayout();
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
||||
selectedId = null; clearProperties();
|
||||
}
|
||||
} else {
|
||||
selectedId = hit.node.id;
|
||||
showProperties(hit.node);
|
||||
}
|
||||
} else {
|
||||
selectedId = null;
|
||||
clearProperties();
|
||||
}
|
||||
draw();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const f = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = Math.max(0.12, Math.min(3.5, transform.scale * f));
|
||||
transform.x = mx - (mx - transform.x) * (ns / transform.scale);
|
||||
transform.y = my - (my - transform.y) * (ns / transform.scale);
|
||||
transform.scale = ns;
|
||||
draw();
|
||||
}, { passive: false });
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────
|
||||
document.getElementById('search').addEventListener('input', e => {
|
||||
filterQuery = e.target.value; draw();
|
||||
});
|
||||
|
||||
// ─── Fit ─────────────────────────────────────────────────
|
||||
document.getElementById('fit-btn').addEventListener('click', fitAll);
|
||||
|
||||
// ─── Expand / Collapse all ───────────────────────────────
|
||||
document.getElementById('expand-all-btn').addEventListener('click', () => {
|
||||
collapsed.clear(); recomputeLayout(); draw();
|
||||
});
|
||||
|
||||
document.getElementById('collapse-all-btn').addEventListener('click', () => {
|
||||
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
||||
selectedId = null; clearProperties();
|
||||
}
|
||||
recomputeLayout(); draw();
|
||||
});
|
||||
|
||||
// ─── Resize (zoom stable) ───────────────────────────────
|
||||
new ResizeObserver(() => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
draw();
|
||||
}).observe(container);
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
setTimeout(fitAll, 30);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
395
examples/formatting_manager_mockup.html
Normal file
395
examples/formatting_manager_mockup.html
Normal file
@@ -0,0 +1,395 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DataGridFormattingManager — Visual Mockup</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
|
||||
/* ---- Manager layout ---- */
|
||||
.manager-root {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 520px;
|
||||
border: 1px solid oklch(var(--b3));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Menu bar (Menu control pattern) ---- */
|
||||
.mf-menu {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: oklch(var(--b2));
|
||||
border-bottom: 1px solid oklch(var(--b3));
|
||||
}
|
||||
|
||||
/* Icon button — mirrors mk.icon() output */
|
||||
.mf-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
color: oklch(var(--bc) / 0.7);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
.mf-icon-btn:hover { background: oklch(var(--b3)); color: oklch(var(--bc)); }
|
||||
.mf-icon-btn.danger:hover { background: color-mix(in oklab, oklch(var(--er)) 15%, transparent); color: oklch(var(--er)); }
|
||||
|
||||
/* Tooltip — mirrors mk.icon(tooltip=...) */
|
||||
.mf-icon-btn::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: oklch(var(--n));
|
||||
color: oklch(var(--nc));
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
.mf-icon-btn:hover::after { opacity: 1; }
|
||||
|
||||
.menu-separator { width: 1px; height: 1.2rem; background: oklch(var(--b3)); margin: 0 0.15rem; }
|
||||
|
||||
/* ---- Preset list ---- */
|
||||
.preset-list {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid oklch(var(--b3));
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid oklch(var(--b2));
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.preset-item:hover { background: oklch(var(--b2)); }
|
||||
.preset-item.active {
|
||||
background: color-mix(in oklab, oklch(var(--p)) 10%, transparent);
|
||||
border-left: 3px solid oklch(var(--p));
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
}
|
||||
|
||||
.preset-name { font-size: 0.875rem; font-weight: 600; }
|
||||
.preset-desc { font-size: 0.7rem; color: oklch(var(--bc) / 0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.preset-badges { display: flex; gap: 0.25rem; margin-top: 0.2rem; }
|
||||
|
||||
/* ---- Editor panel ---- */
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: oklch(var(--b1));
|
||||
}
|
||||
|
||||
.editor-meta {
|
||||
padding: 0.6rem 1rem 0.5rem;
|
||||
border-bottom: 1px solid oklch(var(--b3));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dsl-area {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.dsl-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: oklch(var(--bc) / 0.4);
|
||||
}
|
||||
|
||||
.dsl-editor {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid oklch(var(--b3));
|
||||
border-radius: 0.5rem;
|
||||
background: oklch(var(--b2));
|
||||
color: oklch(var(--bc));
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dsl-editor:focus { border-color: oklch(var(--p)); }
|
||||
|
||||
/* ---- Code block (Python def) ---- */
|
||||
.token-keyword { color: oklch(var(--p)); font-weight: 700; }
|
||||
.token-builtin { color: oklch(var(--s)); font-weight: 600; }
|
||||
.token-string { color: oklch(var(--su)); }
|
||||
.token-number { color: oklch(var(--a)); }
|
||||
.token-comment { color: oklch(var(--bc) / 0.4); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-base-200 p-6">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-bold">DataGridFormattingManager</h1>
|
||||
<p class="text-sm text-base-content/50 mt-0.5">
|
||||
Named rule presets combining formatters, styles and conditions.
|
||||
Reference them with <code class="bg-base-300 px-1 rounded text-xs">format("preset_name")</code>
|
||||
or <code class="bg-base-300 px-1 rounded text-xs">style("preset_name")</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Manager control -->
|
||||
<div class="manager-root shadow-md">
|
||||
|
||||
<!-- Menu bar — Menu(conf=MenuConf(fixed_items=["New","Save","Rename","Delete"]), save_state=False) -->
|
||||
<div class="mf-menu">
|
||||
<!-- fixed_items rendered as mk.icon(command, tooltip=command.description) -->
|
||||
<button class="mf-icon-btn" data-tip="New preset" onclick="newPreset()">
|
||||
<!-- Fluent: Add -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="mf-icon-btn" data-tip="Save preset" onclick="savePreset()">
|
||||
<!-- Fluent: Save -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
</button>
|
||||
<button class="mf-icon-btn" data-tip="Rename preset" onclick="renamePreset()">
|
||||
<!-- Fluent: Rename -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<div class="menu-separator"></div>
|
||||
<button class="mf-icon-btn danger" data-tip="Delete preset" onclick="deletePreset()">
|
||||
<!-- Fluent: Delete -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Left: preset list -->
|
||||
<div class="preset-list" id="preset-list"></div>
|
||||
|
||||
<!-- Right: editor -->
|
||||
<div class="editor-panel">
|
||||
<div class="editor-meta">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold" id="editor-title">—</div>
|
||||
<div class="text-xs text-base-content/45 mt-0.5" id="editor-desc">Select a preset to edit</div>
|
||||
</div>
|
||||
<div class="flex gap-1" id="editor-badges"></div>
|
||||
</div>
|
||||
|
||||
<div class="dsl-area">
|
||||
<div class="dsl-label">Rules — DSL</div>
|
||||
<textarea class="dsl-editor" id="dsl-editor" spellcheck="false"
|
||||
placeholder="No preset selected."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- DEFAULT_RULE_PRESETS example -->
|
||||
<div class="mt-6 rounded-xl border border-base-300 bg-base-100 overflow-hidden shadow">
|
||||
<div class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs font-bold uppercase tracking-wide text-base-content/50">
|
||||
DEFAULT_RULE_PRESETS — core/formatting/presets.py
|
||||
</div>
|
||||
<pre class="p-4 text-xs font-mono overflow-x-auto leading-relaxed text-base-content"><span class="token-comment"># name + description + list of complete FormatRule descriptors.
|
||||
# Appears in format() suggestions if at least one rule has a formatter.
|
||||
# Appears in style() suggestions if at least one rule has a style.</span>
|
||||
|
||||
DEFAULT_RULE_PRESETS = {
|
||||
|
||||
<span class="token-string">"accounting"</span>: {
|
||||
<span class="token-string">"description"</span>: <span class="token-string">"Negatives in parentheses (red), positives plain"</span>,
|
||||
<span class="token-string">"rules"</span>: [
|
||||
{
|
||||
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
|
||||
<span class="token-string">"prefix"</span>: <span class="token-string">"("</span>, <span class="token-string">"suffix"</span>: <span class="token-string">")"</span>,
|
||||
<span class="token-string">"absolute"</span>: <span class="token-keyword">True</span>, <span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
|
||||
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
|
||||
},
|
||||
{
|
||||
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
|
||||
<span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
<span class="token-string">"traffic_light"</span>: {
|
||||
<span class="token-string">"description"</span>: <span class="token-string">"Red / yellow / green style based on sign"</span>,
|
||||
<span class="token-string">"rules"</span>: [
|
||||
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>}},
|
||||
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"=="</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>}},
|
||||
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"success"</span>}},
|
||||
],
|
||||
},
|
||||
|
||||
<span class="token-string">"budget_variance"</span>: {
|
||||
<span class="token-string">"description"</span>: <span class="token-string">"% variance: negative=error, over 10%=warning, else plain"</span>,
|
||||
<span class="token-string">"rules"</span>: [
|
||||
{
|
||||
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
|
||||
},
|
||||
{
|
||||
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0.1</span>},
|
||||
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>},
|
||||
},
|
||||
{
|
||||
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const presets = [
|
||||
{
|
||||
id: "accounting", name: "accounting",
|
||||
description: "Negatives in parentheses (red), positives plain",
|
||||
hasFormatter: true, hasStyle: true,
|
||||
dsl:
|
||||
`format.number(precision=0, prefix="(", suffix=")", absolute=True, thousands_sep=" ") style("error") if value < 0
|
||||
format.number(precision=0, thousands_sep=" ") if value > 0`,
|
||||
},
|
||||
{
|
||||
id: "traffic_light", name: "traffic_light",
|
||||
description: "Red / yellow / green style based on sign",
|
||||
hasFormatter: false, hasStyle: true,
|
||||
dsl:
|
||||
`style("error") if value < 0
|
||||
style("warning") if value == 0
|
||||
style("success") if value > 0`,
|
||||
},
|
||||
{
|
||||
id: "budget_variance", name: "budget_variance",
|
||||
description: "% variance: negative=error, over 10%=warning, else plain",
|
||||
hasFormatter: true, hasStyle: true,
|
||||
dsl:
|
||||
`format.number(precision=1, suffix="%", multiplier=100) style("error") if value < 0
|
||||
format.number(precision=1, suffix="%", multiplier=100) style("warning") if value > 0.1
|
||||
format.number(precision=1, suffix="%", multiplier=100)`,
|
||||
},
|
||||
];
|
||||
|
||||
let activeId = null;
|
||||
|
||||
function renderList() {
|
||||
const list = document.getElementById("preset-list");
|
||||
list.innerHTML = "";
|
||||
presets.forEach(p => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "preset-item" + (p.id === activeId ? " active" : "");
|
||||
item.onclick = () => selectPreset(p.id);
|
||||
const badges = [];
|
||||
if (p.hasFormatter) badges.push(`<span class="badge badge-xs badge-secondary">Format</span>`);
|
||||
if (p.hasStyle) badges.push(`<span class="badge badge-xs badge-primary">Style</span>`);
|
||||
item.innerHTML = `
|
||||
<div class="preset-name">${p.name}</div>
|
||||
<div class="preset-desc">${p.description}</div>
|
||||
<div class="preset-badges">${badges.join("")}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function selectPreset(id) {
|
||||
activeId = id;
|
||||
const p = presets.find(x => x.id === id);
|
||||
document.getElementById("editor-title").textContent = p.name;
|
||||
document.getElementById("editor-desc").textContent = p.description;
|
||||
document.getElementById("dsl-editor").value = p.dsl;
|
||||
const badges = [];
|
||||
if (p.hasFormatter) badges.push(`<span class="badge badge-secondary">format()</span>`);
|
||||
if (p.hasStyle) badges.push(`<span class="badge badge-primary">style()</span>`);
|
||||
document.getElementById("editor-badges").innerHTML = badges.join("");
|
||||
renderList();
|
||||
}
|
||||
|
||||
function savePreset() {
|
||||
if (!activeId) return;
|
||||
const p = presets.find(x => x.id === activeId);
|
||||
p.dsl = document.getElementById("dsl-editor").value;
|
||||
p.hasFormatter = p.dsl.includes("format");
|
||||
p.hasStyle = p.dsl.includes("style");
|
||||
renderList();
|
||||
showToast("Preset saved.");
|
||||
}
|
||||
|
||||
function newPreset() {
|
||||
const name = prompt("Preset name:");
|
||||
if (!name?.trim()) return;
|
||||
const id = name.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
if (presets.find(x => x.id === id)) { alert("Name already exists."); return; }
|
||||
presets.push({ id, name: id, description: "New rule preset", hasFormatter: false, hasStyle: false, dsl: "" });
|
||||
renderList();
|
||||
selectPreset(id);
|
||||
}
|
||||
|
||||
function renamePreset() {
|
||||
if (!activeId) return;
|
||||
const p = presets.find(x => x.id === activeId);
|
||||
const name = prompt("New name:", p.name);
|
||||
if (!name?.trim()) return;
|
||||
p.name = name.trim();
|
||||
p.id = p.name.toLowerCase().replace(/\s+/g, "_");
|
||||
activeId = p.id;
|
||||
renderList();
|
||||
document.getElementById("editor-title").textContent = p.name;
|
||||
}
|
||||
|
||||
function deletePreset() {
|
||||
if (!activeId) return;
|
||||
if (!confirm(`Delete "${activeId}"?`)) return;
|
||||
presets.splice(presets.findIndex(x => x.id === activeId), 1);
|
||||
activeId = null;
|
||||
document.getElementById("editor-title").textContent = "—";
|
||||
document.getElementById("editor-desc").textContent = "Select a preset to edit";
|
||||
document.getElementById("dsl-editor").value = "";
|
||||
document.getElementById("editor-badges").innerHTML = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.createElement("div");
|
||||
t.className = "toast toast-end toast-bottom z-50";
|
||||
t.innerHTML = `<div class="alert alert-success text-sm py-2 px-4">${msg}</div>`;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 2000);
|
||||
}
|
||||
|
||||
renderList();
|
||||
selectPreset("accounting");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,281 +1,12 @@
|
||||
/**
|
||||
* Layout Drawer Resizer
|
||||
*
|
||||
* Handles resizing of left and right drawers with drag functionality.
|
||||
* Communicates with server via HTMX to persist width changes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize drawer resizer functionality for a specific layout instance
|
||||
*
|
||||
* @param {string} layoutId - The ID of the layout instance to initialize
|
||||
*/
|
||||
function initLayoutResizer(layoutId) {
|
||||
'use strict';
|
||||
|
||||
const MIN_WIDTH = 150;
|
||||
const MAX_WIDTH = 600;
|
||||
|
||||
let isResizing = false;
|
||||
let currentResizer = null;
|
||||
let currentDrawer = null;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let side = null;
|
||||
|
||||
const layoutElement = document.getElementById(layoutId);
|
||||
|
||||
if (!layoutElement) {
|
||||
console.error(`Layout element with ID "${layoutId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for this layout instance
|
||||
*/
|
||||
function initResizers() {
|
||||
const resizers = layoutElement.querySelectorAll('.mf-layout-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;
|
||||
currentDrawer = currentResizer.closest('.mf-layout-drawer');
|
||||
|
||||
if (!currentDrawer) {
|
||||
console.error('Could not find drawer element');
|
||||
return;
|
||||
}
|
||||
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = currentDrawer.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-layout-resizing');
|
||||
currentDrawer.classList.add('mf-layout-drawer-resizing');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 drawer width visually
|
||||
currentDrawer.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-layout-resizing');
|
||||
currentDrawer.classList.remove('mf-layout-drawer-resizing');
|
||||
|
||||
// Get final width
|
||||
const finalWidth = currentDrawer.offsetWidth;
|
||||
const commandId = currentResizer.dataset.commandId;
|
||||
|
||||
if (!commandId) {
|
||||
console.error('No command ID found on resizer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send width update to server
|
||||
saveDrawerWidth(commandId, finalWidth);
|
||||
|
||||
// Reset state
|
||||
currentResizer = null;
|
||||
currentDrawer = null;
|
||||
side = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save drawer width to server via HTMX
|
||||
*/
|
||||
function saveDrawerWidth(commandId, width) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
swap: "outerHTML",
|
||||
target: `#${currentDrawer.id}`,
|
||||
values: {
|
||||
c_id: commandId,
|
||||
width: width
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize resizers
|
||||
initResizers();
|
||||
|
||||
// Re-initialize after HTMX swaps within this layout
|
||||
layoutElement.addEventListener('htmx:afterSwap', function (event) {
|
||||
initResizers();
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create keyboard bindings
|
||||
*/
|
||||
(function() {
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store keyboard shortcuts for multiple elements
|
||||
*/
|
||||
const KeyboardRegistry = {
|
||||
elements: new Map(), // elementId -> { trie, element }
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
currentKeys: new Set(),
|
||||
snapshotHistory: [],
|
||||
@@ -339,10 +70,10 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new trie node
|
||||
* @returns {Object} - New trie node
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTrieNode() {
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
@@ -351,12 +82,12 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a trie from combinations
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root trie node
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTrie(combinations) {
|
||||
const root = createTrieNode();
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
@@ -366,7 +97,7 @@ function updateTabs(controllerId) {
|
||||
const key = setToKey(keySet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTrieNode());
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
@@ -381,13 +112,13 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the trie with the current snapshot history
|
||||
* @param {Object} trieRoot - Root of the trie
|
||||
* 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 traverseTrie(trieRoot, snapshotHistory) {
|
||||
let currentNode = trieRoot;
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
@@ -430,8 +161,9 @@ function updateTabs(controllerId) {
|
||||
* @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 is inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr) {
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
@@ -475,13 +207,14 @@ function updateTabs(controllerId) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination and has_focus
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
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.)
|
||||
@@ -506,6 +239,7 @@ function updateTabs(controllerId) {
|
||||
|
||||
// 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);
|
||||
@@ -530,13 +264,17 @@ function updateTabs(controllerId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
const trieRoot = data.trie;
|
||||
// Check if focus is inside this element (element itself or any child)
|
||||
const isInside = element.contains(document.activeElement);
|
||||
|
||||
// Traverse the trie with current snapshot history
|
||||
const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this trie, continue to next element
|
||||
// No match in this tree, continue to next element
|
||||
console.debug("No match in tree for event", key);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -559,7 +297,8 @@ function updateTabs(controllerId) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -574,7 +313,7 @@ function updateTabs(controllerId) {
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
@@ -589,7 +328,7 @@ function updateTabs(controllerId) {
|
||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of KeyboardRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
@@ -640,28 +379,71 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build trie for this element
|
||||
const trie = buildTrie(combinations);
|
||||
// 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, {
|
||||
trie: trie,
|
||||
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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
634
examples/mouse_support.js
Normal file
634
examples/mouse_support.js
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Create mouse bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store mouse shortcuts for multiple elements
|
||||
*/
|
||||
const MouseRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
contextmenuHandler: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize mouse action names
|
||||
* @param {string} action - The action to normalize
|
||||
* @returns {string} - Normalized action name
|
||||
*/
|
||||
function normalizeAction(action) {
|
||||
const normalized = action.toLowerCase().trim();
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of actions for Map indexing
|
||||
* @param {Set} actionSet - Set of normalized actions
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(actionSet) {
|
||||
return Array.from(actionSet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a simple click or click with modifiers)
|
||||
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||
* @returns {Set} - Set of normalized actions
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Click with modifiers
|
||||
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||
}
|
||||
// Simple click
|
||||
return new Set([normalizeAction(element)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||
* @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 click or click with modifiers)
|
||||
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);
|
||||
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||
let currentNode = root;
|
||||
|
||||
for (const actionSet of sequence) {
|
||||
const key = setToKey(actionSet);
|
||||
|
||||
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 mouse actions
|
||||
* @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 clicking 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element that was actually clicked (from registered elements)
|
||||
* @param {Element} target - The clicked element
|
||||
* @returns {string|null} - Element ID if found, null otherwise
|
||||
*/
|
||||
function findRegisteredElement(target) {
|
||||
// Check if target itself is registered
|
||||
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||
return target.id;
|
||||
}
|
||||
|
||||
// Check if any parent is registered
|
||||
let current = target.parentElement;
|
||||
while (current) {
|
||||
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||
return current.id;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot from mouse event
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
* @returns {Set} - Set of actions representing this click
|
||||
*/
|
||||
function createSnapshot(event, baseAction) {
|
||||
const actions = new Set([baseAction]);
|
||||
|
||||
// Add modifiers if present
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
actions.add('ctrl');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
actions.add('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
actions.add('alt');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an action for a matched combination
|
||||
* @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 click was inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
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 = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
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
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse events and trigger matching combinations
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
*/
|
||||
function handleMouseEvent(event, baseAction) {
|
||||
// Different behavior for click vs right_click
|
||||
if (baseAction === 'click') {
|
||||
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||
handleGlobalClick(event);
|
||||
} else if (baseAction === 'right_click') {
|
||||
// Right-click: trigger ONLY if clicked on a registered element
|
||||
handleElementRightClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global click events (triggers for all registered elements)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleGlobalClick(event) {
|
||||
console.debug("Global click detected");
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for ALL registered elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if click was inside this element
|
||||
const isInside = element.contains(event.target);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
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 longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.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
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle right-click events (triggers only for clicked element)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleElementRightClick(event) {
|
||||
// Find which registered element was clicked
|
||||
const elementId = findRegisteredElement(event.target);
|
||||
|
||||
if (!elementId) {
|
||||
// Right-click wasn't on a registered element - don't prevent default
|
||||
// This allows browser context menu to appear
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("Right-click on registered element", elementId);
|
||||
|
||||
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||
const clickedInside = true;
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'right_click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for this element
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
const data = MouseRegistry.elements.get(elementId);
|
||||
if (!data) return;
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
console.debug("No match in tree for right-click");
|
||||
// Clear history for invalid sequences
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: true // Right-click only triggers when clicking on element
|
||||
});
|
||||
}
|
||||
|
||||
// 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 longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.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
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global mouse event listeners if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global mouse event listeners
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
|
||||
// Clean up all state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mouse support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_mouse_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
|
||||
MouseRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove mouse support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_mouse_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!MouseRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
MouseRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (MouseRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
640
examples/profiler_mockup.html
Normal file
640
examples/profiler_mockup.html
Normal file
@@ -0,0 +1,640 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Profiler — UI Mockup</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base — mirrors DaisyUI dark theme CSS variables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--hcg-bg-main: #0d1117;
|
||||
--hcg-bg-button: rgba(22, 27, 34, 0.92);
|
||||
--hcg-border: #30363d;
|
||||
--hcg-text-muted: rgba(230, 237, 243, 0.5);
|
||||
--hcg-text-primary: #e6edf3;
|
||||
--hcg-node-bg: #1c2128;
|
||||
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
|
||||
|
||||
--profiler-danger: #f85149;
|
||||
--profiler-warn: #e3b341;
|
||||
--profiler-ok: #3fb950;
|
||||
--profiler-accent: #58a6ff;
|
||||
--profiler-muted: rgba(230, 237, 243, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--hcg-bg-main);
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--hcg-node-bg);
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-toolbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--hcg-text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.mf-profiler-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-bg-button);
|
||||
color: var(--hcg-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.mf-profiler-btn:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 80%, var(--profiler-accent) 20%);
|
||||
border-color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.active {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 40%);
|
||||
border-color: var(--profiler-ok);
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.danger {
|
||||
border-color: var(--profiler-danger);
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.danger:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 30%);
|
||||
}
|
||||
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mf-profiler-overhead span b {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Split layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trace list (left) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 110px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 110px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 5%);
|
||||
}
|
||||
|
||||
.mf-profiler-row.selected {
|
||||
background: var(--hcg-node-bg-selected);
|
||||
border-left: 2px solid #f0883e;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-duration {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mf-profiler-duration.fast {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-ts {
|
||||
text-align: right;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Detail panel (right) — Properties-style */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header b {
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Properties-style cards */
|
||||
.mf-properties-group-card {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-properties-group-header {
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
}
|
||||
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-key {
|
||||
padding: 5px 10px;
|
||||
color: var(--hcg-text-muted);
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-properties-value {
|
||||
padding: 5px 10px;
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Span tree */
|
||||
.mf-profiler-span-tree {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-tree-header {
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 140px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(48, 54, 61, 0.6);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--profiler-accent);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.slow {
|
||||
background: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.medium {
|
||||
background: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: var(--hcg-text-muted);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Cumulative span badge */
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
border: 1px solid rgba(88, 166, 255, 0.3);
|
||||
color: var(--profiler-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--hcg-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Toolbar -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-toolbar">
|
||||
<span class="mf-profiler-toolbar-title">Profiler</span>
|
||||
|
||||
<button class="mf-profiler-btn active" onclick="toggleEnabled(this)">● Enabled</button>
|
||||
<button class="mf-profiler-btn danger" onclick="clearTraces()">Clear</button>
|
||||
|
||||
<div class="mf-profiler-overhead">
|
||||
<span>Overhead/span: <b>1.2 µs</b></span>
|
||||
<span>Total overhead: <b>0.04 ms</b></span>
|
||||
<span>Traces: <b id="trace-count">8</b> / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Body: list + detail -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-body">
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Trace list -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-list">
|
||||
<div class="mf-profiler-list-header">
|
||||
<span>Command</span>
|
||||
<span style="text-align:right">Duration</span>
|
||||
<span style="text-align:right">Time</span>
|
||||
</div>
|
||||
<div class="mf-profiler-list-body" id="trace-list">
|
||||
|
||||
<div class="mf-profiler-row selected" onclick="selectRow(this, 0)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">173.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.881</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 1)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">168.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.712</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 2)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration medium">42.7 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:06.501</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 3)">
|
||||
<span class="mf-profiler-cmd">FilterChanged</span>
|
||||
<span class="mf-profiler-duration medium">38.2 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:05.334</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 4)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">12.0 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:04.102</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 5)">
|
||||
<span class="mf-profiler-cmd">SortColumn</span>
|
||||
<span class="mf-profiler-duration fast">8.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:03.770</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 6)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration fast">5.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:02.441</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 7)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">4.8 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:01.003</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Detail panel -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-detail">
|
||||
<div class="mf-profiler-detail-header">
|
||||
Trace detail — <b>NavigateCell</b>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-detail-body">
|
||||
|
||||
<!-- Metadata (Properties-style) -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">Metadata</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">command</div>
|
||||
<div class="mf-properties-value">NavigateCell</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">total_duration_ms</div>
|
||||
<div class="mf-properties-value" style="color:var(--profiler-danger)">173.4</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">timestamp</div>
|
||||
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kwargs (Properties-style) -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">kwargs</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">row</div>
|
||||
<div class="mf-properties-value">12</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">col</div>
|
||||
<div class="mf-properties-value">3</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">direction</div>
|
||||
<div class="mf-properties-value">down</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Span tree -->
|
||||
<div class="mf-profiler-span-tree">
|
||||
<div class="mf-profiler-span-tree-header">Span breakdown</div>
|
||||
|
||||
<!-- Root span -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:0"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name" style="color:var(--hcg-text-primary);font-weight:600">NavigateCell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">173.4 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- before_commands -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">before_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:1%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">0.8 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- callback -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">callback</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">152.6 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigate_cell (child of callback) -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:32px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">navigate_cell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">149.0 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- process_row (cumulative, child of navigate_cell) -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:48px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">process_row</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-warn)">138.5 ms</span>
|
||||
</div>
|
||||
<span class="mf-profiler-cumulative-badge">×1000 · min 0.1 · avg 0.14 · max 0.4 ms</span>
|
||||
</div>
|
||||
|
||||
<!-- after_commands -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">after_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">10.3 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- oob_swap -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">oob_swap</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:5%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">9.7 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.mf-profiler-span-tree -->
|
||||
|
||||
</div><!-- /.mf-profiler-detail-body -->
|
||||
</div><!-- /.mf-profiler-detail -->
|
||||
|
||||
</div><!-- /.mf-profiler-body -->
|
||||
|
||||
<script>
|
||||
function selectRow(el, index) {
|
||||
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
|
||||
function toggleEnabled(btn) {
|
||||
const enabled = btn.classList.toggle('active');
|
||||
btn.textContent = enabled ? '● Enabled' : '○ Disabled';
|
||||
}
|
||||
|
||||
function clearTraces() {
|
||||
document.getElementById('trace-list').innerHTML =
|
||||
'<div class="mf-profiler-empty">No traces recorded.</div>';
|
||||
document.getElementById('trace-count').textContent = '0';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
920
examples/profiler_mockup_2.html
Normal file
920
examples/profiler_mockup_2.html
Normal file
@@ -0,0 +1,920 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Profiler — UI Mockup 2</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base — mirrors DaisyUI CSS variables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--hcg-bg-main: #0d1117;
|
||||
--hcg-bg-button: rgba(22, 27, 34, 0.92);
|
||||
--hcg-border: #30363d;
|
||||
--hcg-text-muted: rgba(230, 237, 243, 0.45);
|
||||
--hcg-text-primary: #e6edf3;
|
||||
--hcg-node-bg: #1c2128;
|
||||
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
|
||||
|
||||
--profiler-danger: #f85149;
|
||||
--profiler-warn: #e3b341;
|
||||
--profiler-ok: #3fb950;
|
||||
--profiler-accent: #58a6ff;
|
||||
|
||||
/* Fonts — mirrors myfasthtml.css */
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.8125rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--hcg-bg-main);
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbar — icon-only, no Menu control */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--hcg-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Icon button — matches mk.icon() style */
|
||||
.mf-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--hcg-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mf-icon-btn:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 15%);
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-icon-btn.active {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-icon-btn.active:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 20%);
|
||||
}
|
||||
|
||||
.mf-icon-btn.danger {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-icon-btn.danger:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 20%);
|
||||
}
|
||||
|
||||
.mf-icon-btn.view-active {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 25%);
|
||||
color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.mf-icon-btn[data-tip]:hover::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2d333b;
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
color: var(--hcg-text-primary);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mf-profiler-overhead span b {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--profiler-warn);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Split layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trace list (left) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 76px 100px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 76px 100px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 50%, var(--profiler-accent) 5%);
|
||||
}
|
||||
|
||||
.mf-profiler-row.selected {
|
||||
background: var(--hcg-node-bg-selected);
|
||||
border-left: 2px solid #f0883e;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.mf-profiler-duration {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.fast {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-ts {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--hcg-text-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Detail panel (right) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--hcg-text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title span {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
/* View toggle in detail header */
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Properties-style cards (reuses properties.css variables) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-properties-group-card {
|
||||
background: var(--hcg-node-bg);
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-properties-group-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
|
||||
}
|
||||
|
||||
.mf-properties-key {
|
||||
padding: 4px 10px;
|
||||
color: var(--hcg-text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-properties-value {
|
||||
padding: 4px 10px;
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: var(--font-mono); /* monospace for values */
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mf-properties-value.danger {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Span tree view */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-tree-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid rgba(48, 54, 61, 0.6);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 130px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name.root {
|
||||
font-weight: 600;
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: rgba(48, 54, 61, 0.7);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--profiler-accent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.slow {
|
||||
background: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.medium {
|
||||
background: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--hcg-text-muted);
|
||||
min-width: 58px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border: 1px solid rgba(88, 166, 255, 0.25);
|
||||
color: var(--profiler-accent);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pie chart view (placeholder) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-pie-view {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-view.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-view-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-placeholder {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* SVG pie slices — static mockup */
|
||||
.mf-profiler-pie-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-pct {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--hcg-text-muted);
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--hcg-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Toolbar — icon-only, no Menu -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-toolbar">
|
||||
|
||||
<!-- Enable / Disable toggle -->
|
||||
<button class="mf-icon-btn active" data-tip="Disable profiler" onclick="toggleEnabled(this)">
|
||||
<!-- Fluent: record_stop (enabled state) -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<circle cx="10" cy="10" r="5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Clear traces -->
|
||||
<button class="mf-icon-btn danger" data-tip="Clear traces" onclick="clearTraces()">
|
||||
<!-- Fluent: delete -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M8.5 4h3a.5.5 0 0 0-1 0h-1a.5.5 0 0 0-1 0Zm-1 0a1.5 1.5 0 0 1 3 0h3a.5.5 0 0 1 0 1h-.554l-.853 8.533A1.5 1.5 0 0 1 10.606 15H9.394a1.5 1.5 0 0 1-1.487-1.467L7.054 5H6.5a.5.5 0 0 1 0-1h1Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mf-profiler-toolbar-sep"></div>
|
||||
|
||||
<!-- Overhead metrics -->
|
||||
<div class="mf-profiler-overhead">
|
||||
<span>Overhead/span: <b>1.2 µs</b></span>
|
||||
<span>Total overhead: <b>0.04 ms</b></span>
|
||||
<span>Traces: <b id="trace-count">8</b> / 500</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Body: list + detail -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-body">
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Trace list -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-list">
|
||||
<div class="mf-profiler-list-header">
|
||||
<span>Command</span>
|
||||
<span style="text-align:right">Duration</span>
|
||||
<span style="text-align:right">Time</span>
|
||||
</div>
|
||||
<div class="mf-profiler-list-body" id="trace-list">
|
||||
|
||||
<div class="mf-profiler-row selected" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">173.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.881</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">168.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.712</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration medium">42.7 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:06.501</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">FilterChanged</span>
|
||||
<span class="mf-profiler-duration medium">38.2 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:05.334</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">12.0 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:04.102</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SortColumn</span>
|
||||
<span class="mf-profiler-duration fast">8.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:03.770</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration fast">5.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:02.441</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">4.8 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:01.003</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Detail panel -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-detail">
|
||||
|
||||
<!-- Header with tree/pie toggle -->
|
||||
<div class="mf-profiler-detail-header">
|
||||
<span class="mf-profiler-detail-title">
|
||||
<span>NavigateCell</span> — 173.4 ms
|
||||
</span>
|
||||
<div class="mf-profiler-view-toggle">
|
||||
<!-- Tree view -->
|
||||
<button class="mf-icon-btn view-active" id="btn-tree" data-tip="Span tree"
|
||||
onclick="switchView('tree')">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h11A1.5 1.5 0 0 1 17 4.5v1A1.5 1.5 0 0 1 15.5 7h-11A1.5 1.5 0 0 1 3 5.5v-1ZM3 10a1.5 1.5 0 0 1 1.5-1.5h6A1.5 1.5 0 0 1 12 10v1a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 3 11v-1Zm0 5.5A1.5 1.5 0 0 1 4.5 14h4a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 18h-4A1.5 1.5 0 0 1 3 16.5v-1Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Pie view -->
|
||||
<button class="mf-icon-btn" id="btn-pie" data-tip="Pie chart"
|
||||
onclick="switchView('pie')">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 2a8 8 0 1 1 0 16A8 8 0 0 1 10 2Zm0 1.5A6.5 6.5 0 1 0 16.5 10H10a.5.5 0 0 1-.5-.5V3.5Zm1 .07V9h5.43A6.51 6.51 0 0 0 11 3.57Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-detail-body">
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">Metadata</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">command</div>
|
||||
<div class="mf-properties-value">NavigateCell</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">total_duration_ms</div>
|
||||
<div class="mf-properties-value danger">173.4</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">timestamp</div>
|
||||
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kwargs -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">kwargs</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">row</div>
|
||||
<div class="mf-properties-value">12</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">col</div>
|
||||
<div class="mf-properties-value">3</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">direction</div>
|
||||
<div class="mf-properties-value">down</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Span tree view -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="mf-profiler-span-tree" id="view-tree">
|
||||
<div class="mf-profiler-span-tree-header">Span breakdown</div>
|
||||
|
||||
<!-- Root -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name root">NavigateCell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">173.4 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- before_commands — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">before_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:0.5%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">0.8 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- callback — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">callback</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">152.6 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigate_cell — depth 2 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">navigate_cell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">149.0 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- process_row cumulative — depth 3 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">process_row</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms medium">138.5 ms</span>
|
||||
</div>
|
||||
<span class="mf-profiler-cumulative-badge">×1000 · min 0.10 · avg 0.14 · max 0.40 ms</span>
|
||||
</div>
|
||||
|
||||
<!-- after_commands — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">after_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">10.3 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- oob_swap — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">oob_swap</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:5.6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">9.7 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#view-tree -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Pie chart view (placeholder for ProfilerPieChart) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="mf-profiler-pie-view" id="view-pie">
|
||||
<div class="mf-profiler-pie-view-header">Distribution</div>
|
||||
<div class="mf-profiler-pie-placeholder">
|
||||
|
||||
<!-- Static SVG pie mockup -->
|
||||
<svg width="160" height="160" viewBox="0 0 32 32">
|
||||
<!-- process_row: 80% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#e3b341" stroke-width="32"
|
||||
stroke-dasharray="80 100"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- callback overhead: 8% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#58a6ff" stroke-width="32"
|
||||
stroke-dasharray="8 100"
|
||||
stroke-dashoffset="-80"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- after_commands: 6% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#3fb950" stroke-width="32"
|
||||
stroke-dasharray="6 100"
|
||||
stroke-dashoffset="-88"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- oob_swap: 5.6% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#8b949e" stroke-width="32"
|
||||
stroke-dasharray="5.6 100"
|
||||
stroke-dashoffset="-94"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- before_commands: ~0.4% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#6e7681" stroke-width="32"
|
||||
stroke-dasharray="0.4 100"
|
||||
stroke-dashoffset="-99.6"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
</svg>
|
||||
|
||||
<div class="mf-profiler-pie-legend">
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#e3b341"></div>
|
||||
<span>process_row</span>
|
||||
<span class="mf-profiler-pie-legend-pct">80.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#58a6ff"></div>
|
||||
<span>callback</span>
|
||||
<span class="mf-profiler-pie-legend-pct">8.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#3fb950"></div>
|
||||
<span>after_commands</span>
|
||||
<span class="mf-profiler-pie-legend-pct">6.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#8b949e"></div>
|
||||
<span>oob_swap</span>
|
||||
<span class="mf-profiler-pie-legend-pct">5.6%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#6e7681"></div>
|
||||
<span>before_commands</span>
|
||||
<span class="mf-profiler-pie-legend-pct">0.4%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /#view-pie -->
|
||||
|
||||
</div><!-- /.mf-profiler-detail-body -->
|
||||
</div><!-- /.mf-profiler-detail -->
|
||||
|
||||
</div><!-- /.mf-profiler-body -->
|
||||
|
||||
<script>
|
||||
function selectRow(el) {
|
||||
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
|
||||
function toggleEnabled(btn) {
|
||||
const isEnabled = btn.classList.toggle('active');
|
||||
btn.setAttribute('data-tip', isEnabled ? 'Disable profiler' : 'Enable profiler');
|
||||
// Icon swap: filled circle = recording, ring = stopped
|
||||
btn.querySelector('svg').innerHTML = isEnabled
|
||||
? '<circle cx="10" cy="10" r="5"/>'
|
||||
: '<circle cx="10" cy="10" r="5" fill="none" stroke="currentColor" stroke-width="2"/>';
|
||||
}
|
||||
|
||||
function clearTraces() {
|
||||
document.getElementById('trace-list').innerHTML =
|
||||
'<div class="mf-profiler-empty">No traces recorded.</div>';
|
||||
document.getElementById('trace-count').textContent = '0';
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
const treeEl = document.getElementById('view-tree');
|
||||
const pieEl = document.getElementById('view-pie');
|
||||
const btnTree = document.getElementById('btn-tree');
|
||||
const btnPie = document.getElementById('btn-pie');
|
||||
|
||||
if (view === 'tree') {
|
||||
treeEl.style.display = '';
|
||||
pieEl.classList.remove('visible');
|
||||
btnTree.classList.add('view-active');
|
||||
btnPie.classList.remove('view-active');
|
||||
} else {
|
||||
treeEl.style.display = 'none';
|
||||
pieEl.classList.add('visible');
|
||||
btnPie.classList.add('view-active');
|
||||
btnTree.classList.remove('view-active');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -137,6 +137,12 @@
|
||||
<div class="test-container">
|
||||
<h2>Test Input (typing should work normally here)</h2>
|
||||
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
||||
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
|
||||
<strong>Parameters Explained:</strong><br>
|
||||
• <code>has_focus</code>: Whether the registered element itself has focus<br>
|
||||
• <code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
|
||||
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
@@ -151,6 +157,9 @@
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element also responds to ESC and Shift Shift
|
||||
</div>
|
||||
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||
Remove Element 2 Keyboard Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
@@ -172,12 +181,14 @@
|
||||
window.htmx.ajax = function(method, url, config) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const hasFocus = config.values.has_focus;
|
||||
const isInside = config.values.is_inside;
|
||||
const combination = config.values.combination;
|
||||
|
||||
// Build details string with all config options
|
||||
const details = [
|
||||
`Combination: "${combination}"`,
|
||||
`Element has focus: ${hasFocus}`
|
||||
`Element has focus: ${hasFocus}`,
|
||||
`Focus inside element: ${isInside}`
|
||||
];
|
||||
|
||||
if (config.target) {
|
||||
@@ -223,6 +234,16 @@
|
||||
function clearLog() {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
}
|
||||
|
||||
function removeElement2() {
|
||||
remove_keyboard_support('test-element-2');
|
||||
logEvent('Element 2 keyboard support removed',
|
||||
'ESC and Shift Shift no longer trigger for Element 2',
|
||||
'Element 1 still active', false);
|
||||
// Disable the button
|
||||
event.target.disabled = true;
|
||||
event.target.textContent = 'Keyboard Support Removed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Include keyboard support script -->
|
||||
410
examples/test_mouse_support.html
Normal file
410
examples/test_mouse_support.html
Normal file
@@ -0,0 +1,410 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mouse Support Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
border: 2px solid #333;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.test-element {
|
||||
background-color: #f0f0f0;
|
||||
border: 2px solid #999;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 10px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.test-element:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.test-element:focus {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196F3;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.log-entry.focus {
|
||||
border-left-color: #2196F3;
|
||||
}
|
||||
|
||||
.log-entry.no-focus {
|
||||
border-left-color: #FF9800;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.actions-list h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.actions-list ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.actions-list code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-color: #FF5722;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-color: #E64A19;
|
||||
}
|
||||
|
||||
.remove-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196F3;
|
||||
padding: 10px 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Mouse Support Test Page</h1>
|
||||
|
||||
<div class="actions-list">
|
||||
<h3>🖱️ Configured Mouse Actions</h3>
|
||||
<p><strong>Element 1 - All Actions:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple left click</li>
|
||||
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||
<li><code>shift+click</code> - Shift + Click</li>
|
||||
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||
<li><code>click click</code> - Click twice in sequence</li>
|
||||
</ul>
|
||||
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple click</li>
|
||||
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||
</ul>
|
||||
<p><strong>Element 3 - Mousedown>mouseup actions:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple click (coexists with mousedown>mouseup)</li>
|
||||
<li><code>mousedown>mouseup</code> - Left press and release (with JS values)</li>
|
||||
<li><code>ctrl+mousedown>mouseup</code> - Ctrl + press and release</li>
|
||||
<li><code>rmousedown>mouseup</code> - Right press and release</li>
|
||||
<li><code>click mousedown>mouseup</code> - Click then press-and-release sequence</li>
|
||||
</ul>
|
||||
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 1 (All Actions)</h2>
|
||||
<div id="test-element-1" class="test-element" tabindex="0">
|
||||
Try different mouse actions here!<br>
|
||||
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element uses "rclick" alias for right-click<br>
|
||||
Also has a "click rclick" sequence
|
||||
</div>
|
||||
<button class="remove-button" onclick="removeElement2()">
|
||||
Remove Element 2 Mouse Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 3 (Mousedown>mouseup actions)</h2>
|
||||
<div id="test-element-3" class="test-element" tabindex="0">
|
||||
Try mousedown>mouseup actions here!<br>
|
||||
Press and hold, then release. Also try Ctrl+drag, right-drag, and click then drag.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Input (normal clicking should work here)</h2>
|
||||
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div class="test-container" style="background-color: #f9f9f9;">
|
||||
<h2>🎯 Click Outside Test Area</h2>
|
||||
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Event Log</h2>
|
||||
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||
<div id="log" class="log-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include htmx -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Mock htmx.ajax for testing -->
|
||||
<script>
|
||||
// Store original htmx.ajax if it exists
|
||||
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||
|
||||
// Override htmx.ajax for testing purposes
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax = function(method, url, config) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const hasFocus = config.values.has_focus;
|
||||
const isInside = config.values.is_inside;
|
||||
const combination = config.values.combination;
|
||||
|
||||
// Build details string with all config options
|
||||
const details = [
|
||||
`Combination: "${combination}"`,
|
||||
`Element has focus: ${hasFocus}`,
|
||||
`Click inside element: ${isInside}`
|
||||
];
|
||||
|
||||
if (config.target) {
|
||||
details.push(`Target: ${config.target}`);
|
||||
}
|
||||
if (config.swap) {
|
||||
details.push(`Swap: ${config.swap}`);
|
||||
}
|
||||
if (config.values) {
|
||||
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||
if (extraVals.length > 0) {
|
||||
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||
}
|
||||
}
|
||||
|
||||
logEvent(
|
||||
`[${timestamp}] ${method} ${url}`,
|
||||
...details,
|
||||
hasFocus
|
||||
);
|
||||
|
||||
// Uncomment below to use real htmx.ajax if you have a backend
|
||||
// if (originalHtmxAjax) {
|
||||
// originalHtmxAjax.call(this, method, url, config);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
function logEvent(title, ...details) {
|
||||
const log = document.getElementById('log');
|
||||
const hasFocus = details[details.length - 1];
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||
entry.innerHTML = `
|
||||
<strong>${title}</strong><br>
|
||||
${details.slice(0, -1).join('<br>')}
|
||||
`;
|
||||
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
}
|
||||
|
||||
function removeElement2() {
|
||||
remove_mouse_support('test-element-2');
|
||||
logEvent('Element 2 mouse support removed',
|
||||
'Click and right-click no longer trigger for Element 2',
|
||||
'Element 1 still active', false);
|
||||
// Disable the button
|
||||
event.target.disabled = true;
|
||||
event.target.textContent = 'Mouse Support Removed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Include mouse support script -->
|
||||
<script src="mouse_support.js"></script>
|
||||
|
||||
<!-- Initialize mouse support -->
|
||||
<script>
|
||||
// Element 1 - Full configuration
|
||||
const combinations1 = {
|
||||
"click": {
|
||||
"hx-post": "/test/click"
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/test/right-click"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/test/ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/test/shift-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+shift+click": {
|
||||
"hx-post": "/test/ctrl-shift-click",
|
||||
"hx-vals": {"modifier": "both"}
|
||||
},
|
||||
"click right_click": {
|
||||
"hx-post": "/test/click-then-right-click",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/test/double-click-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||
|
||||
// Element 2 - Using rclick alias
|
||||
const combinations2 = {
|
||||
"click": {
|
||||
"hx-post": "/test/element2-click"
|
||||
},
|
||||
"rclick": { // Using rclick alias instead of right_click
|
||||
"hx-post": "/test/element2-rclick"
|
||||
},
|
||||
"click rclick": { // Sequence using rclick alias
|
||||
"hx-post": "/test/element2-click-rclick-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||
|
||||
// JS function for dynamic mousedown>mouseup values
|
||||
// Returns cell-like data for testing
|
||||
window.getCellId = function(event, element, combination) {
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
return {
|
||||
cell_id: `cell-${Math.floor(x / 50)}-${Math.floor(y / 50)}`,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
};
|
||||
|
||||
// Element 3 - Mousedown>mouseup actions
|
||||
const combinations3 = {
|
||||
"click": {
|
||||
"hx-post": "/test/element3-click"
|
||||
},
|
||||
"mousedown>mouseup": {
|
||||
"hx-post": "/test/element3-mousedown-mouseup",
|
||||
"hx-vals-extra": {"js": "getCellId"}
|
||||
},
|
||||
"ctrl+mousedown>mouseup": {
|
||||
"hx-post": "/test/element3-ctrl-mousedown-mouseup",
|
||||
"hx-vals-extra": {"js": "getCellId"}
|
||||
},
|
||||
"rmousedown>mouseup": {
|
||||
"hx-post": "/test/element3-rmousedown-mouseup",
|
||||
"hx-vals-extra": {"js": "getCellId"}
|
||||
},
|
||||
"click mousedown>mouseup": {
|
||||
"hx-post": "/test/element3-click-then-mousedown-mouseup",
|
||||
"hx-vals-extra": {"js": "getCellId"}
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-3', JSON.stringify(combinations3));
|
||||
|
||||
// Log initial state
|
||||
logEvent('Mouse support initialized',
|
||||
'Element 1: All mouse actions configured',
|
||||
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||
'Element 3: Mousedown>mouseup actions (with JS getCellId function)',
|
||||
'Smart timeout: 500ms for sequences', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.4.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
|
||||
|
||||
64
src/app.py
64
src/app.py
@@ -1,19 +1,26 @@
|
||||
import logging.config
|
||||
|
||||
import yaml
|
||||
from dbengine.handlers import handlers
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||
from myfasthtml.controls.DataGridFormattingManager import DataGridFormattingManager
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.Profiler import Profiler
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
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_p3 import folder_open20_regular
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular, timer20_regular
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
with open('logging.yaml', 'r') as f:
|
||||
@@ -33,11 +40,14 @@ app, rt = create_app(protect_routes=True,
|
||||
|
||||
@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.set_footer("Goodbye World")
|
||||
layout.footer_left.add("Goodbye World")
|
||||
|
||||
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||
add_tab = tabs_manager.commands.add_tab
|
||||
btn_show_right_drawer = mk.button("show",
|
||||
command=layout.commands.toggle_drawer("right"),
|
||||
id="btn_show_right_drawer_id")
|
||||
@@ -45,35 +55,59 @@ def index(session):
|
||||
instances_debugger = InstancesDebugger(layout)
|
||||
btn_show_instances_debugger = mk.label("Instances",
|
||||
icon=volume_object_storage,
|
||||
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
|
||||
id=instances_debugger.get_id())
|
||||
command=add_tab("Instances", instances_debugger),
|
||||
id=f"l_{instances_debugger.get_id()}")
|
||||
|
||||
commands_debugger = CommandsDebugger(layout)
|
||||
btn_show_commands_debugger = mk.label("Commands",
|
||||
icon=None,
|
||||
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
|
||||
id=commands_debugger.get_id())
|
||||
icon=key_command16_regular,
|
||||
command=add_tab("Commands", commands_debugger),
|
||||
id=f"l_{commands_debugger.get_id()}")
|
||||
|
||||
profiler = Profiler(layout)
|
||||
btn_show_profiler = mk.label("Profiler",
|
||||
icon=timer20_regular,
|
||||
command=add_tab("Profiler", profiler),
|
||||
id=f"l_{profiler.get_id()}")
|
||||
|
||||
btn_file_upload = mk.label("Upload",
|
||||
icon=folder_open20_regular,
|
||||
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||
id="file_upload_id")
|
||||
|
||||
btn_popup = mk.label("Popup",
|
||||
command=tabs_manager.commands.add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-popup")))
|
||||
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
|
||||
|
||||
layout.header_left.add(tabs_manager.add_tab_btn())
|
||||
layout.header_right.add(btn_show_right_drawer)
|
||||
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_show_profiler, "Debugger")
|
||||
|
||||
# Parameters
|
||||
formatting_manager = DataGridFormattingManager(layout)
|
||||
btn_show_formatting_manager = mk.label("Formatting",
|
||||
icon=text_edit_style20_regular,
|
||||
command=add_tab("Formatting", formatting_manager),
|
||||
id=f"l_{formatting_manager.get_id()}")
|
||||
layout.left_drawer.add(btn_show_formatting_manager, "Parameters")
|
||||
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
layout.left_drawer.add(btn_popup, "Test")
|
||||
|
||||
# data grids
|
||||
dgs_manager = DataGridsManager(session_instance, save_state=True)
|
||||
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",
|
||||
tabs_manager.commands.add_tab("File Open",
|
||||
FileUpload(layout,
|
||||
_id="-file_upload")))
|
||||
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
return layout, keyboard
|
||||
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
# Keyboard Support - Test Instructions
|
||||
|
||||
## ⚠️ Breaking Change
|
||||
|
||||
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
|
||||
|
||||
**Old format (no longer supported)**:
|
||||
```javascript
|
||||
{"A": "/url"}
|
||||
```
|
||||
|
||||
**New format (required)**:
|
||||
```javascript
|
||||
{"A": {"hx-post": "/url"}}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
|
||||
- `test_keyboard_support.html` - Test page to verify functionality
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multiple Simultaneous Triggers
|
||||
|
||||
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
|
||||
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": "/close-modal"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
|
||||
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
|
||||
|
||||
// Pressing ESC will trigger all 3 URLs simultaneously
|
||||
```
|
||||
|
||||
This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.).
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
The library uses **a single global timeout** based on the sequence state, not on individual elements:
|
||||
|
||||
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
|
||||
|
||||
Examples:
|
||||
|
||||
**Example 1**: Three elements, same combination
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"esc": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"esc": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"esc": "/url3"}');
|
||||
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
|
||||
```
|
||||
|
||||
**Example 2**: Mixed - one has longer sequence
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"A": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
|
||||
// Press A:
|
||||
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
|
||||
// - If B arrives: only elem3 triggers with "A B"
|
||||
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
|
||||
```
|
||||
|
||||
**Example 3**: Different combinations
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A B": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"C D": "/url2"}');
|
||||
// Press A: elem1 waits for B, elem2 not affected
|
||||
// Press C: elem2 waits for D, elem1 not affected
|
||||
```
|
||||
|
||||
The timeout is tied to the **sequence being typed**, not to individual elements.
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
Keyboard shortcuts are **disabled** when typing in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
|
||||
|
||||
## How to Test
|
||||
|
||||
1. **Download both files** to the same directory
|
||||
2. **Open `test_keyboard_support.html`** in a web browser
|
||||
3. **Try the configured shortcuts**:
|
||||
- `a` - Simple key (waits if "A B" might follow)
|
||||
- `Ctrl+S` - Save combination (immediate)
|
||||
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
|
||||
- `A B` - Sequence (waits because "A B C" exists)
|
||||
- `A B C` - Triple sequence (triggers immediately)
|
||||
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
|
||||
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
|
||||
- `shift shift` - Press Shift twice in sequence
|
||||
- `esc` - Escape key (immediate)
|
||||
|
||||
4. **Test focus behavior**:
|
||||
- Click on the test element to focus it (turns blue)
|
||||
- Try shortcuts with focus
|
||||
- Click outside to remove focus
|
||||
- Try shortcuts without focus
|
||||
- The log shows whether the element had focus when triggered
|
||||
|
||||
5. **Test input protection**:
|
||||
- Try typing in the input field
|
||||
- Use Ctrl+C, Ctrl+A, etc. - should work normally
|
||||
- Shortcuts should NOT trigger while typing in input
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Smart Timeout Examples
|
||||
|
||||
**Scenario 1**: Only "A" is configured (no element has "A B")
|
||||
- Press A → Triggers **immediately**
|
||||
|
||||
**Scenario 2**: At least one element has "A B"
|
||||
- Press A → **ALL elements with "A" wait 500ms**
|
||||
- If B pressed within 500ms → Only elements with "A B" trigger
|
||||
- If timeout expires → ALL elements with "A" trigger
|
||||
|
||||
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
|
||||
- Press A → Waits (because "A B" exists)
|
||||
- Press B → Waits (because "A B C" exists)
|
||||
- Press C → Triggers "A B C" **immediately**
|
||||
|
||||
**Scenario 4**: Multiple elements, ESC on all
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": "/close"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel"}');
|
||||
```
|
||||
- Press ESC → **Both trigger simultaneously** (no longer sequences)
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
### Configuration Format
|
||||
|
||||
The library now uses **HTMX configuration objects** instead of simple URL strings:
|
||||
|
||||
```python
|
||||
# New format with HTMX configuration
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save-url",
|
||||
"hx-target": "#result",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"A B": {
|
||||
"hx-post": "/sequence-url",
|
||||
"hx-vals": {"extra": "data"}
|
||||
},
|
||||
"esc": {
|
||||
"hx-get": "/cancel-url"
|
||||
}
|
||||
}
|
||||
|
||||
# This will generate the JavaScript call
|
||||
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
|
||||
```
|
||||
|
||||
### Supported HTMX Attributes
|
||||
|
||||
You can use any HTMX attribute in the configuration object:
|
||||
|
||||
**HTTP Methods** (one required):
|
||||
- `hx-post` - POST request
|
||||
- `hx-get` - GET request
|
||||
- `hx-put` - PUT request
|
||||
- `hx-delete` - DELETE request
|
||||
- `hx-patch` - PATCH request
|
||||
|
||||
**Common Options**:
|
||||
- `hx-target` - Target element selector
|
||||
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
|
||||
- `hx-vals` - Additional values to send (object)
|
||||
- `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.
|
||||
|
||||
### Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every request:
|
||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||
- `has_focus` - Boolean indicating if the element had focus
|
||||
|
||||
Example final request:
|
||||
```javascript
|
||||
htmx.ajax('POST', '/save-url', {
|
||||
target: '#result',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
extra: "data", // from hx-vals
|
||||
combination: "Ctrl+S", // automatic
|
||||
has_focus: true // automatic
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Target and Swap
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+D": {
|
||||
"hx-delete": "/item",
|
||||
"hx-target": "#item-list",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Extra Values
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+N": {
|
||||
"hx-post": "/create",
|
||||
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Elements Example
|
||||
|
||||
```python
|
||||
# Modal close
|
||||
modal_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/modal/close",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Editor cancel
|
||||
editor_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/editor/cancel",
|
||||
"hx-target": "#editor",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Both will trigger when ESC is pressed
|
||||
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
||||
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Trie-based Matching
|
||||
|
||||
The library uses a prefix tree (trie) data structure:
|
||||
- Each node represents a keyboard snapshot (set of pressed keys)
|
||||
- Leaf nodes contain the HTMX configuration object
|
||||
- Intermediate nodes indicate longer sequences exist
|
||||
- Enables efficient O(n) matching where n is sequence length
|
||||
|
||||
### HTMX Integration
|
||||
|
||||
Configuration objects are mapped to htmx.ajax() calls:
|
||||
- `hx-*` attributes are converted to camelCase parameters
|
||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||
- `combination` and `has_focus` are automatically added to values
|
||||
- All standard HTMX options are supported
|
||||
|
||||
### Key Normalization
|
||||
|
||||
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
|
||||
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
|
||||
- Simultaneous keys represented as sorted sets
|
||||
|
||||
## Notes
|
||||
|
||||
- The test page mocks `htmx.ajax` to display results in the console
|
||||
- In production, real AJAX calls will be made to your backend
|
||||
- Sequence timeout is 500ms between keys
|
||||
- Maximum 10 snapshots kept in history to prevent memory issues
|
||||
18
src/myfasthtml/assets/Readme.md
Normal file
18
src/myfasthtml/assets/Readme.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Commands used
|
||||
|
||||
```
|
||||
# Url to get codemirror resources : https://cdnjs.com/libraries/codemirror
|
||||
cd src/myfasthtml/assets/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
|
||||
|
||||
# Url for SortableJS : https://cdnjs.com/libraries/Sortable
|
||||
cd src/myfasthtml/assets/sortablejs/
|
||||
wget https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.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"}`);
|
||||
}
|
||||
85
src/myfasthtml/assets/core/formatting_manager.css
Normal file
85
src/myfasthtml/assets/core/formatting_manager.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/* ============================================= */
|
||||
/* ======== DataGridFormattingManager ========== */
|
||||
/* ============================================= */
|
||||
|
||||
.mf-formatting-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Preset list items ---- */
|
||||
|
||||
.mf-fmgr-preset-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.mf-fmgr-preset-item:hover {
|
||||
background-color: var(--color-button-hover);
|
||||
}
|
||||
|
||||
.mf-fmgr-preset-item-active {
|
||||
background-color: color-mix(in oklab, var(--color-primary) 15%, #0000);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||
}
|
||||
|
||||
|
||||
.mf-fmgr-preset-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mf-fmgr-preset-desc {
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-fmgr-preset-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Editor panel ---- */
|
||||
|
||||
.mf-fmgr-editor-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-fmgr-editor-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-fmgr-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---- New / Rename form ---- */
|
||||
|
||||
.mf-fmgr-form {
|
||||
max-width: 480px;
|
||||
}
|
||||
128
src/myfasthtml/assets/core/hierarchical_canvas_graph.css
Normal file
128
src/myfasthtml/assets/core/hierarchical_canvas_graph.css
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Hierarchical Canvas Graph Styles
|
||||
*
|
||||
* Styles for the canvas-based hierarchical graph visualization control.
|
||||
*/
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** Color Variables (DaisyUI) ********** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Instance kind colors - hardcoded to preserve visual identity */
|
||||
:root {
|
||||
--hcg-color-root: #2563eb;
|
||||
--hcg-color-single: #7c3aed;
|
||||
--hcg-color-multiple: #047857;
|
||||
--hcg-color-unique: #b45309;
|
||||
|
||||
/* UI colors */
|
||||
--hcg-bg-main: var(--color-base-100, #0d1117);
|
||||
--hcg-bg-button: var(--color-base-200, rgba(22, 27, 34, 0.92));
|
||||
--hcg-border: var(--color-border, #30363d);
|
||||
--hcg-text-muted: color-mix(in oklab, var(--color-base-content, #e6edf3) 50%, transparent);
|
||||
--hcg-text-primary: var(--color-base-content, #e6edf3);
|
||||
|
||||
/* Canvas drawing colors */
|
||||
--hcg-dot-grid: rgba(125, 133, 144, 0.12);
|
||||
--hcg-edge: rgba(48, 54, 61, 0.9);
|
||||
--hcg-edge-dimmed: rgba(48, 54, 61, 0.25);
|
||||
--hcg-node-bg: var(--color-base-300, #1c2128);
|
||||
--hcg-node-bg-selected: color-mix(in oklab, var(--color-base-300, #1c2128) 70%, #f0883e 30%);
|
||||
--hcg-node-border-selected: #f0883e;
|
||||
--hcg-node-border-match: #e3b341;
|
||||
--hcg-node-glow: #f0883e;
|
||||
}
|
||||
|
||||
/* Main control wrapper */
|
||||
.mf-hierarchical-canvas-graph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Container that holds the canvas */
|
||||
.mf-hcg-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--hcg-bg-main);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Toggle button positioned absolutely within container */
|
||||
.mf-hcg-container button {
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Canvas element (sized by JavaScript) */
|
||||
.mf-hcg-container canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mf-hcg-container canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Optional: toolbar/controls overlay (if needed in future) */
|
||||
.mf-hcg-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: var(--hcg-bg-button);
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Layout toggle button */
|
||||
.mf-hcg-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--hcg-bg-button);
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mf-hcg-toggle-btn:hover {
|
||||
color: var(--hcg-text-primary);
|
||||
background: color-mix(in oklab, var(--hcg-bg-main) 90%, var(--hcg-text-primary) 10%);
|
||||
}
|
||||
|
||||
/* Optional: loading state */
|
||||
.mf-hcg-container.loading::after {
|
||||
content: 'Loading...';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 14px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
861
src/myfasthtml/assets/core/hierarchical_canvas_graph.js
Normal file
861
src/myfasthtml/assets/core/hierarchical_canvas_graph.js
Normal file
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* Hierarchical Canvas Graph
|
||||
*
|
||||
* Canvas-based visualization for hierarchical graph data with expand/collapse.
|
||||
* Features: Reingold-Tilford layout, zoom/pan, search filter, dot grid background.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize hierarchical canvas graph visualization.
|
||||
*
|
||||
* Creates an interactive canvas-based hierarchical graph with the following features:
|
||||
* - Reingold-Tilford tree layout algorithm
|
||||
* - Expand/collapse nodes with children
|
||||
* - Zoom (mouse wheel) and pan (drag) controls
|
||||
* - Layout mode toggle (horizontal/vertical)
|
||||
* - Search/filter nodes by label or kind
|
||||
* - Click events for node selection and toggle
|
||||
* - Stable zoom on container resize
|
||||
* - Dot grid background (Figma-style)
|
||||
*
|
||||
* @param {string} containerId - The ID of the container div element
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Array<Object>} options.nodes - Array of node objects with properties:
|
||||
* @param {string} options.nodes[].id - Unique node identifier
|
||||
* @param {string} options.nodes[].label - Display label
|
||||
* @param {string} options.nodes[].kind - Instance kind (root|single|unique|multiple)
|
||||
* @param {string} options.nodes[].type - Class type/name
|
||||
* @param {Array<Object>} options.edges - Array of edge objects with properties:
|
||||
* @param {string} options.edges[].from - Source node ID
|
||||
* @param {string} options.edges[].to - Target node ID
|
||||
* @param {Array<string>} [options.collapsed=[]] - Array of initially collapsed node IDs
|
||||
* @param {Object} [options.events={}] - Event handlers mapping event names to HTMX options:
|
||||
* @param {Object} [options.events.select_node] - Handler for node selection (click on node)
|
||||
* @param {Object} [options.events.toggle_node] - Handler for expand/collapse toggle
|
||||
*
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* initHierarchicalCanvasGraph('graph-container', {
|
||||
* nodes: [
|
||||
* { id: 'root', label: 'Root', kind: 'root', type: 'RootInstance' },
|
||||
* { id: 'child', label: 'Child', kind: 'single', type: 'MyComponent' }
|
||||
* ],
|
||||
* edges: [{ from: 'root', to: 'child' }],
|
||||
* collapsed: [],
|
||||
* events: {
|
||||
* select_node: { url: '/api/select', target: '#panel', swap: 'innerHTML' }
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
function initHierarchicalCanvasGraph(containerId, options = {}) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error(`HierarchicalCanvasGraph: Container "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double initialization
|
||||
if (container._hcgInitialized) {
|
||||
console.warn(`HierarchicalCanvasGraph: Container "${containerId}" already initialized`);
|
||||
return;
|
||||
}
|
||||
container._hcgInitialized = true;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Configuration & Constants
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODES = options.nodes || [];
|
||||
const EDGES = options.edges || [];
|
||||
const EVENTS = options.events || {};
|
||||
// filtered_nodes: null = no filter, [] = filter but no matches, [ids] = filter with matches
|
||||
const FILTERED_NODES = options.filtered_nodes === null ? null : new Set(options.filtered_nodes);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Visual Constants
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODE_W = 178; // Node width in pixels
|
||||
const NODE_H_SMALL = 36; // Node height without description
|
||||
const NODE_H_LARGE = 54; // Node height with description
|
||||
const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
|
||||
|
||||
function getNodeHeight(node) {
|
||||
return node.description ? NODE_H_LARGE : NODE_H_SMALL;
|
||||
}
|
||||
|
||||
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
|
||||
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
|
||||
|
||||
const FIT_PADDING = 48; // Padding around graph when fitting
|
||||
const FIT_MAX_SCALE = 1.5; // Maximum zoom level when fitting
|
||||
|
||||
const DOT_GRID_SPACING = 24; // Dot grid spacing in pixels
|
||||
const DOT_GRID_RADIUS = 0.9; // Dot radius in pixels
|
||||
|
||||
const ZOOM_FACTOR = 1.12; // Zoom multiplier per wheel tick
|
||||
const ZOOM_MIN = 0.12; // Minimum zoom level
|
||||
const ZOOM_MAX = 3.5; // Maximum zoom level
|
||||
|
||||
// Spacing constants (adjusted per mode)
|
||||
const HORIZONTAL_MODE_SPACING = {
|
||||
levelGap: 84, // vertical distance between parent-child levels
|
||||
siblingGap: 22 // gap between siblings (in addition to NODE_W)
|
||||
};
|
||||
|
||||
const VERTICAL_MODE_SPACING = {
|
||||
levelGap: 220, // horizontal distance between parent-child (after swap)
|
||||
siblingGap: 14 // gap between siblings (in addition to NODE_H_LARGE)
|
||||
};
|
||||
|
||||
function getSpacing() {
|
||||
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
|
||||
}
|
||||
|
||||
// Color mapping based on instance kind (read from CSS variables for DaisyUI theme compatibility)
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const KIND_COLOR = {
|
||||
root: computedStyle.getPropertyValue('--hcg-color-root').trim() || '#2563eb',
|
||||
single: computedStyle.getPropertyValue('--hcg-color-single').trim() || '#7c3aed',
|
||||
multiple: computedStyle.getPropertyValue('--hcg-color-multiple').trim() || '#047857',
|
||||
unique: computedStyle.getPropertyValue('--hcg-color-unique').trim() || '#b45309',
|
||||
};
|
||||
|
||||
// UI colors from CSS variables
|
||||
const UI_COLORS = {
|
||||
dotGrid: computedStyle.getPropertyValue('--hcg-dot-grid').trim() || 'rgba(125,133,144,0.12)',
|
||||
edge: computedStyle.getPropertyValue('--hcg-edge').trim() || 'rgba(48,54,61,0.9)',
|
||||
edgeDimmed: computedStyle.getPropertyValue('--hcg-edge-dimmed').trim() || 'rgba(48,54,61,0.25)',
|
||||
nodeBg: computedStyle.getPropertyValue('--hcg-node-bg').trim() || '#1c2128',
|
||||
nodeBgSelected: computedStyle.getPropertyValue('--hcg-node-bg-selected').trim() || '#2a1f0f',
|
||||
nodeBorderSel: computedStyle.getPropertyValue('--hcg-node-border-selected').trim() || '#f0883e',
|
||||
nodeBorderMatch: computedStyle.getPropertyValue('--hcg-node-border-match').trim() || '#e3b341',
|
||||
nodeGlow: computedStyle.getPropertyValue('--hcg-node-glow').trim() || '#f0883e',
|
||||
textPrimary: computedStyle.getPropertyValue('--hcg-text-primary').trim() || '#e6edf3',
|
||||
textMuted: computedStyle.getPropertyValue('--hcg-text-muted').trim() || 'rgba(125,133,144,0.5)',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Graph structure
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const childMap = {};
|
||||
const hasParentSet = new Set();
|
||||
|
||||
for (const n of NODES) childMap[n.id] = [];
|
||||
for (const e of EDGES) {
|
||||
(childMap[e.from] = childMap[e.from] || []).push(e.to);
|
||||
hasParentSet.add(e.to);
|
||||
}
|
||||
|
||||
function hasChildren(id) {
|
||||
return (childMap[id] || []).length > 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// State
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const collapsed = new Set(options.collapsed || []);
|
||||
let selectedId = null;
|
||||
let filterQuery = '';
|
||||
let transform = options.transform || { x: 0, y: 0, scale: 1 };
|
||||
let pos = {};
|
||||
let layoutMode = options.layout_mode || 'horizontal'; // 'horizontal' | 'vertical'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Visibility & Layout
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function getHiddenSet() {
|
||||
const hidden = new Set();
|
||||
function addDesc(id) {
|
||||
for (const c of childMap[id] || []) {
|
||||
if (!hidden.has(c)) { hidden.add(c); addDesc(c); }
|
||||
}
|
||||
}
|
||||
for (const id of collapsed) addDesc(id);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function getDescendants(nodeId) {
|
||||
/**
|
||||
* Get all descendant node IDs for a given node.
|
||||
* Used to highlight descendants when a node is selected.
|
||||
*/
|
||||
const descendants = new Set();
|
||||
function addDesc(id) {
|
||||
for (const child of childMap[id] || []) {
|
||||
if (!descendants.has(child)) {
|
||||
descendants.add(child);
|
||||
addDesc(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
addDesc(nodeId);
|
||||
return descendants;
|
||||
}
|
||||
|
||||
function visNodes() {
|
||||
const h = getHiddenSet();
|
||||
return NODES.filter(n => !h.has(n.id));
|
||||
}
|
||||
|
||||
function visEdges(vn) {
|
||||
const vi = new Set(vn.map(n => n.id));
|
||||
return EDGES.filter(e => vi.has(e.from) && vi.has(e.to));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hierarchical layout using Reingold-Tilford algorithm (simplified)
|
||||
*/
|
||||
function computeLayout(nodes, edges) {
|
||||
const spacing = getSpacing();
|
||||
const cm = {};
|
||||
const hp = new Set();
|
||||
for (const n of nodes) cm[n.id] = [];
|
||||
for (const e of edges) { (cm[e.from] = cm[e.from] || []).push(e.to); hp.add(e.to); }
|
||||
|
||||
const roots = nodes.filter(n => !hp.has(n.id)).map(n => n.id);
|
||||
|
||||
// Assign depth via BFS
|
||||
const depth = {};
|
||||
const q = roots.map(r => [r, 0]);
|
||||
while (q.length) {
|
||||
const [id, d] = q.shift();
|
||||
if (depth[id] !== undefined) continue;
|
||||
depth[id] = d;
|
||||
for (const c of cm[id] || []) q.push([c, d + 1]);
|
||||
}
|
||||
|
||||
// Assign positions
|
||||
const positions = {};
|
||||
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap };
|
||||
|
||||
// Create node map for quick access by ID
|
||||
const nodeMap = {};
|
||||
for (const n of nodes) nodeMap[n.id] = n;
|
||||
|
||||
// Sibling stride for horizontal mode
|
||||
const siblingStride = NODE_W + spacing.siblingGap;
|
||||
|
||||
// DFS to assign x (sibling spacing)
|
||||
let slot = 0;
|
||||
let currentX = 0; // For dynamic spacing in vertical mode
|
||||
|
||||
function dfs(id) {
|
||||
const children = cm[id] || [];
|
||||
if (children.length === 0) {
|
||||
// Leaf node: assign x position based on layout mode
|
||||
if (layoutMode === 'vertical') {
|
||||
// Dynamic spacing based on actual node height
|
||||
const node = nodeMap[id];
|
||||
const h = getNodeHeight(node);
|
||||
positions[id].x = currentX + h / 2; // Center of the node
|
||||
currentX += h + spacing.siblingGap; // Move to next position
|
||||
} else {
|
||||
// Horizontal mode: constant spacing
|
||||
positions[id].x = slot++ * siblingStride;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-leaf: recurse children, then center between them
|
||||
for (const c of children) dfs(c);
|
||||
const xs = children.map(c => positions[c].x);
|
||||
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
}
|
||||
for (const r of roots) dfs(r);
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function recomputeLayout() {
|
||||
const vn = visNodes();
|
||||
pos = computeLayout(vn, visEdges(vn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform position based on current layout mode
|
||||
* In vertical mode: swap x and y so tree grows left-to-right instead of top-to-bottom
|
||||
* @param {Object} p - Position {x, y}
|
||||
* @returns {Object} - Transformed position
|
||||
*/
|
||||
function transformPos(p) {
|
||||
if (layoutMode === 'vertical') {
|
||||
// Swap x and y: horizontal spread becomes vertical, depth becomes horizontal
|
||||
return { x: p.y, y: p.x };
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Canvas Setup
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = `${containerId}_canvas`;
|
||||
canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-smooth: always;';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Logical dimensions (CSS pixels) - used for drawing coordinates
|
||||
let logicalWidth = 0;
|
||||
let logicalHeight = 0;
|
||||
|
||||
// Tooltip element for showing full text when truncated
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'mf-tooltip-container';
|
||||
tooltip.setAttribute('data-visible', 'false');
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// Layout toggle button overlay
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'mf-hcg-toggle-btn';
|
||||
toggleBtn.innerHTML = '⇄';
|
||||
toggleBtn.title = 'Toggle layout orientation';
|
||||
toggleBtn.setAttribute('aria-label', 'Toggle between horizontal and vertical layout');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
toggleBtn.innerHTML = layoutMode === 'horizontal' ? '⇄' : '⇅';
|
||||
toggleBtn.title = `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`;
|
||||
toggleBtn.setAttribute('aria-label', `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`);
|
||||
// Recompute layout with new spacing
|
||||
recomputeLayout();
|
||||
fitAll();
|
||||
// Save layout mode change
|
||||
saveViewState();
|
||||
});
|
||||
container.appendChild(toggleBtn);
|
||||
|
||||
function resize() {
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
|
||||
// Store logical dimensions (CSS pixels) for drawing coordinates
|
||||
logicalWidth = container.clientWidth;
|
||||
logicalHeight = container.clientHeight;
|
||||
|
||||
// Set canvas internal resolution to match physical pixels (prevents blur on HiDPI screens)
|
||||
canvas.width = logicalWidth * ratio;
|
||||
canvas.height = logicalHeight * ratio;
|
||||
|
||||
// Reset transformation matrix to identity (prevents cumulative scaling)
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
// Scale context to maintain logical coordinate system
|
||||
ctx.scale(ratio, ratio);
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Drawing
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function drawDotGrid() {
|
||||
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
|
||||
const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
|
||||
ctx.fillStyle = UI_COLORS.dotGrid;
|
||||
for (let x = ox - DOT_GRID_SPACING; x < logicalWidth + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
|
||||
for (let y = oy - DOT_GRID_SPACING; y < logicalHeight + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
|
||||
drawDotGrid();
|
||||
|
||||
// Calculate matchIds based on filter state:
|
||||
// - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed)
|
||||
// - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed)
|
||||
// - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches)
|
||||
const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES;
|
||||
|
||||
// Get descendants of selected node for highlighting
|
||||
const descendantIds = selectedId ? getDescendants(selectedId) : new Set();
|
||||
|
||||
const vn = visNodes();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(transform.x, transform.y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
|
||||
// Edges
|
||||
for (const edge of EDGES) {
|
||||
const p1 = pos[edge.from], p2 = pos[edge.to];
|
||||
if (!p1 || !p2) continue;
|
||||
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
||||
const isHighlighted = selectedId && (edge.from === selectedId || descendantIds.has(edge.from));
|
||||
|
||||
// Get dynamic heights for source and target nodes
|
||||
const node1 = NODES.find(n => n.id === edge.from);
|
||||
const node2 = NODES.find(n => n.id === edge.to);
|
||||
const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
|
||||
const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
|
||||
|
||||
const tp1 = transformPos(p1);
|
||||
const tp2 = transformPos(p2);
|
||||
|
||||
let x1, y1, x2, y2, cx, cy;
|
||||
if (layoutMode === 'horizontal') {
|
||||
// Horizontal: edges go from bottom of parent to top of child
|
||||
x1 = tp1.x; y1 = tp1.y + h1 / 2;
|
||||
x2 = tp2.x; y2 = tp2.y - h2 / 2;
|
||||
cy = (y1 + y2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(x1, cy, x2, cy, x2, y2);
|
||||
} else {
|
||||
// Vertical: edges go from right of parent to left of child
|
||||
x1 = tp1.x + NODE_W / 2; y1 = tp1.y;
|
||||
x2 = tp2.x - NODE_W / 2; y2 = tp2.y;
|
||||
cx = (x1 + x2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
|
||||
}
|
||||
|
||||
if (isHighlighted) {
|
||||
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
|
||||
ctx.lineWidth = 2.5;
|
||||
} else if (dimmed) {
|
||||
ctx.strokeStyle = UI_COLORS.edgeDimmed;
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
ctx.strokeStyle = UI_COLORS.edge;
|
||||
ctx.lineWidth = 1.5;
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const node of vn) {
|
||||
const p = pos[node.id];
|
||||
if (!p) continue;
|
||||
const tp = transformPos(p);
|
||||
const isSel = node.id === selectedId;
|
||||
const isDesc = descendantIds.has(node.id);
|
||||
const isMatch = matchIds !== null && matchIds.has(node.id);
|
||||
const isDim = matchIds !== null && !matchIds.has(node.id);
|
||||
drawNode(node, tp.x, tp.y, isSel, isDesc, isMatch, isDim, transform.scale);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) {
|
||||
// Nodes have dynamic height (with or without description)
|
||||
const nodeH = getNodeHeight(node);
|
||||
const hw = NODE_W / 2, hh = nodeH / 2, r = 6;
|
||||
const x = cx - hw, y = cy - hh;
|
||||
const color = KIND_COLOR[node.kind] || '#334155';
|
||||
|
||||
ctx.globalAlpha = isDim ? 0.15 : 1;
|
||||
|
||||
// Glow for selected
|
||||
if (isSel) { ctx.shadowColor = UI_COLORS.nodeGlow; ctx.shadowBlur = 16; }
|
||||
|
||||
// Background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
ctx.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Left color strip (always on left, regardless of mode)
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, 4, nodeH);
|
||||
ctx.restore();
|
||||
|
||||
// Border
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||
if (isSel || isDesc) {
|
||||
// Selected node or descendant: orange border
|
||||
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
|
||||
ctx.lineWidth = 1.5;
|
||||
} else if (isMatch) {
|
||||
ctx.strokeStyle = UI_COLORS.nodeBorderMatch;
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
ctx.strokeStyle = `${color}44`;
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Type badge (class name) - with dynamic font size for sharp rendering at all zoom levels
|
||||
const kindText = node.type;
|
||||
const badgeFontSize = 9 * zoomLevel;
|
||||
ctx.save();
|
||||
ctx.scale(1 / zoomLevel, 1 / zoomLevel);
|
||||
ctx.font = `${badgeFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`;
|
||||
const rawW = ctx.measureText(kindText).width;
|
||||
const badgeW = Math.min(rawW + 8, 66 * zoomLevel);
|
||||
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
|
||||
const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel;
|
||||
const badgeY = (y + (nodeH - 14) / 2) * zoomLevel;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
|
||||
ctx.fillStyle = `${color}22`;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
let kLabel = kindText;
|
||||
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6 * zoomLevel) kLabel = kLabel.slice(0, -1);
|
||||
if (kLabel !== kindText) kLabel += '…';
|
||||
ctx.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel));
|
||||
ctx.restore();
|
||||
|
||||
// Label (centered if no description, top if description) - with dynamic font size for sharp rendering at all zoom levels
|
||||
const labelFontSize = 12 * zoomLevel;
|
||||
ctx.save();
|
||||
ctx.scale(1 / zoomLevel, 1 / zoomLevel);
|
||||
ctx.font = `${isSel ? 500 : 400} ${labelFontSize}px "SF Mono", "Cascadia Code", "Consolas", "Menlo", "Monaco", monospace`;
|
||||
ctx.fillStyle = isDim ? UI_COLORS.textMuted : UI_COLORS.textPrimary;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const labelX = (x + 12) * zoomLevel;
|
||||
const labelMaxW = (badgeX / zoomLevel - (x + 12) - 6) * zoomLevel;
|
||||
let label = node.label;
|
||||
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
|
||||
if (label !== node.label) label += '…';
|
||||
const labelY = node.description ? (cy - 9) * zoomLevel : cy * zoomLevel;
|
||||
ctx.fillText(label, Math.round(labelX), Math.round(labelY));
|
||||
|
||||
// Description (bottom line, only if present)
|
||||
if (node.description) {
|
||||
const descFontSize = 9 * zoomLevel;
|
||||
ctx.font = `${descFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`;
|
||||
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
|
||||
let desc = node.description;
|
||||
while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
|
||||
if (desc !== node.description) desc += '…';
|
||||
ctx.fillText(desc, Math.round(labelX), Math.round((cy + 8) * zoomLevel));
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Chevron toggle (same position in both modes)
|
||||
if (hasChildren(node.id)) {
|
||||
const chevX = x + NODE_W - CHEV_ZONE / 2 - 1;
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
drawChevron(ctx, chevX, cy, !isCollapsed, color);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawChevron(ctx, cx, cy, pointDown, color) {
|
||||
const s = 4;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
// Chevron always drawn the same way (down or right)
|
||||
if (pointDown) {
|
||||
ctx.moveTo(cx - s, cy - s * 0.5);
|
||||
ctx.lineTo(cx, cy + s * 0.5);
|
||||
ctx.lineTo(cx + s, cy - s * 0.5);
|
||||
} else {
|
||||
ctx.moveTo(cx - s * 0.5, cy - s);
|
||||
ctx.lineTo(cx + s * 0.5, cy);
|
||||
ctx.lineTo(cx - s * 0.5, cy + s);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Fit all
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function fitAll() {
|
||||
const vn = visNodes();
|
||||
if (vn.length === 0) return;
|
||||
|
||||
// Calculate bounds using dynamic node heights
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const n of vn) {
|
||||
const p = pos[n.id];
|
||||
if (!p) continue;
|
||||
const tp = transformPos(p);
|
||||
const h = getNodeHeight(n);
|
||||
minX = Math.min(minX, tp.x - NODE_W / 2);
|
||||
maxX = Math.max(maxX, tp.x + NODE_W / 2);
|
||||
minY = Math.min(minY, tp.y - h / 2);
|
||||
maxY = Math.max(maxY, tp.y + h / 2);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) return;
|
||||
|
||||
minX -= FIT_PADDING;
|
||||
maxX += FIT_PADDING;
|
||||
minY -= FIT_PADDING;
|
||||
maxY += FIT_PADDING;
|
||||
|
||||
const scale = Math.min(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE);
|
||||
transform.scale = scale;
|
||||
transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
|
||||
transform.y = (logicalHeight - (minY + maxY) * scale) / 2;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Tooltip helpers
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function showTooltip(text, clientX, clientY) {
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = `${clientX + 10}px`;
|
||||
tooltip.style.top = `${clientY + 10}px`;
|
||||
tooltip.setAttribute('data-visible', 'true');
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip.setAttribute('data-visible', 'false');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Hit testing
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function hitTest(sx, sy) {
|
||||
const wx = (sx - transform.x) / transform.scale;
|
||||
const wy = (sy - transform.y) / transform.scale;
|
||||
const vn = visNodes();
|
||||
|
||||
for (let i = vn.length - 1; i >= 0; i--) {
|
||||
const n = vn[i];
|
||||
const p = pos[n.id];
|
||||
if (!p) continue;
|
||||
const tp = transformPos(p);
|
||||
const nodeH = getNodeHeight(n);
|
||||
|
||||
// Nodes have dynamic height based on description
|
||||
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) {
|
||||
const hw = NODE_W / 2, hh = nodeH / 2;
|
||||
const x = tp.x - hw, y = tp.y - hh;
|
||||
|
||||
// Check border (left strip) - 4px wide
|
||||
const isBorder = wx >= x && wx <= x + 4;
|
||||
|
||||
// Check toggle zone (chevron on right)
|
||||
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
|
||||
|
||||
// Check badge (type badge) - approximate zone (right side, excluding toggle)
|
||||
// Badge is positioned at: x + NODE_W - chevSpace - badgeW - 2
|
||||
// For hit testing, we use a simplified zone: last ~70px before toggle area
|
||||
const chevSpace = hasChildren(n.id) ? CHEV_ZONE : 8;
|
||||
const badgeZoneStart = x + NODE_W - chevSpace - 70;
|
||||
const badgeZoneEnd = x + NODE_W - chevSpace - 2;
|
||||
const badgeY = y + (nodeH - 14) / 2;
|
||||
const isBadge = !isToggle && wx >= badgeZoneStart && wx <= badgeZoneEnd
|
||||
&& wy >= badgeY && wy <= badgeY + 14;
|
||||
|
||||
return { node: n, isToggle, isBadge, isBorder };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Event posting to server
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function postEvent(eventName, eventData) {
|
||||
const handler = EVENTS[eventName];
|
||||
if (!handler || !handler.url) {
|
||||
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
|
||||
return;
|
||||
}
|
||||
htmx.ajax('POST', handler.url, {
|
||||
values: eventData, // Send data as separate fields (default command engine behavior)
|
||||
target: handler.target || 'body',
|
||||
swap: handler.swap || 'none'
|
||||
});
|
||||
}
|
||||
|
||||
function saveViewState() {
|
||||
postEvent('_internal_update_state', {
|
||||
transform: JSON.stringify(transform), // Serialize to JSON string for server
|
||||
layout_mode: layoutMode
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Interaction
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let isPanning = false;
|
||||
let panOrigin = { x: 0, y: 0 };
|
||||
let tfAtStart = null;
|
||||
let didMove = false;
|
||||
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
isPanning = true; didMove = false;
|
||||
panOrigin = { x: e.clientX, y: e.clientY };
|
||||
tfAtStart = { ...transform };
|
||||
canvas.style.cursor = 'grabbing';
|
||||
hideTooltip();
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (isPanning) {
|
||||
const dx = e.clientX - panOrigin.x;
|
||||
const dy = e.clientY - panOrigin.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
|
||||
transform.x = tfAtStart.x + dx;
|
||||
transform.y = tfAtStart.y + dy;
|
||||
draw();
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show tooltip if hovering over a node with truncated text
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
// Check if mouse is over canvas
|
||||
if (canvasX >= 0 && canvasX <= rect.width && canvasY >= 0 && canvasY <= rect.height) {
|
||||
const hit = hitTest(canvasX, canvasY);
|
||||
if (hit && !hit.isToggle) {
|
||||
const node = hit.node;
|
||||
// Check if label or type is truncated (contains ellipsis)
|
||||
const labelTruncated = node.label.length > 15; // Approximate truncation threshold
|
||||
const typeTruncated = node.type.length > 8; // Approximate truncation threshold
|
||||
|
||||
if (labelTruncated || typeTruncated) {
|
||||
const tooltipText = `${node.label}${node.type !== node.label ? ` (${node.type})` : ''}`;
|
||||
showTooltip(tooltipText, e.clientX, e.clientY);
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (!isPanning) return;
|
||||
isPanning = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
|
||||
if (!didMove) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (hit) {
|
||||
if (hit.isToggle) {
|
||||
// Save screen position of clicked node before layout change
|
||||
const oldPos = pos[hit.node.id];
|
||||
const oldTp = transformPos(oldPos);
|
||||
const screenX = oldTp.x * transform.scale + transform.x;
|
||||
const screenY = oldTp.y * transform.scale + transform.y;
|
||||
|
||||
// Toggle collapse
|
||||
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
|
||||
else collapsed.add(hit.node.id);
|
||||
recomputeLayout();
|
||||
|
||||
// Adjust transform to keep clicked node at same screen position
|
||||
const newPos = pos[hit.node.id];
|
||||
if (newPos) {
|
||||
const newTp = transformPos(newPos);
|
||||
transform.x = screenX - newTp.x * transform.scale;
|
||||
transform.y = screenY - newTp.y * transform.scale;
|
||||
}
|
||||
|
||||
// Post toggle_node event
|
||||
postEvent('toggle_node', {
|
||||
node_id: hit.node.id,
|
||||
collapsed: collapsed.has(hit.node.id)
|
||||
});
|
||||
|
||||
// Clear selection if node is now hidden
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
||||
selectedId = null;
|
||||
}
|
||||
} else if (hit.isBadge) {
|
||||
// Badge click: filter by type
|
||||
postEvent('_internal_filter_by_type', { query_param: 'type', value: hit.node.type });
|
||||
} else if (hit.isBorder) {
|
||||
// Border click: filter by kind
|
||||
postEvent('_internal_filter_by_kind', { query_param: 'kind', value: hit.node.kind });
|
||||
} else {
|
||||
selectedId = hit.node.id;
|
||||
|
||||
// Post select_node event
|
||||
postEvent('select_node', {
|
||||
node_id: hit.node.id,
|
||||
label: hit.node.label,
|
||||
kind: hit.node.kind,
|
||||
type: hit.node.type
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selectedId = null;
|
||||
}
|
||||
draw();
|
||||
} else {
|
||||
// Panning occurred - save view state
|
||||
saveViewState();
|
||||
}
|
||||
});
|
||||
|
||||
let zoomTimeout = null;
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
hideTooltip();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const f = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
|
||||
const ns = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, transform.scale * f));
|
||||
transform.x = mx - (mx - transform.x) * (ns / transform.scale);
|
||||
transform.y = my - (my - transform.y) * (ns / transform.scale);
|
||||
transform.scale = ns;
|
||||
draw();
|
||||
|
||||
// Debounce save to avoid too many requests during continuous zoom
|
||||
clearTimeout(zoomTimeout);
|
||||
zoomTimeout = setTimeout(saveViewState, 500);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
hideTooltip();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Resize observer (stable zoom on resize)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
new ResizeObserver(resize).observe(container);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Public API (attached to container for potential external access)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
container._hcgAPI = {
|
||||
fitAll,
|
||||
setFilter: (query) => { filterQuery = query; draw(); },
|
||||
expandAll: () => { collapsed.clear(); recomputeLayout(); draw(); },
|
||||
collapseAll: () => {
|
||||
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) selectedId = null;
|
||||
recomputeLayout(); draw();
|
||||
},
|
||||
redraw: draw
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Init
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
recomputeLayout();
|
||||
resize();
|
||||
|
||||
// Only fit all if no stored transform (first time or reset)
|
||||
const hasStoredTransform = options.transform &&
|
||||
(options.transform.x !== 0 || options.transform.y !== 0 || options.transform.scale !== 1);
|
||||
|
||||
if (!hasStoredTransform) {
|
||||
setTimeout(fitAll, 30);
|
||||
}
|
||||
}
|
||||
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* HTMX debug tracing — toggle with window.HTMX_DEBUG = true in the browser console.
|
||||
*
|
||||
* Each request gets a unique ID (#1, #2, ...). Timings are deltas from beforeRequest.
|
||||
*
|
||||
* Full event sequence (→ = network boundary):
|
||||
* beforeRequest — request about to be sent
|
||||
* beforeSend — XHR about to be sent (after HTMX setup)
|
||||
* → network round-trip ←
|
||||
* beforeOnLoad — response received, HTMX about to process it
|
||||
* beforeSwap — DOM swap about to happen
|
||||
* afterSwap — DOM swap done
|
||||
* afterSettle — settle phase complete
|
||||
* afterRequest — full request lifecycle complete
|
||||
* sendError — network error
|
||||
* responseError — non-2xx response
|
||||
*/
|
||||
|
||||
window.HTMX_DEBUG = false;
|
||||
(function () {
|
||||
console.log('Debug HTMX: htmx.logAll();');
|
||||
console.log('Perf HTMX: window.HTMX_DEBUG=true;');
|
||||
})();
|
||||
|
||||
(function () {
|
||||
const EVENTS = [
|
||||
'htmx:beforeRequest',
|
||||
'htmx:beforeSend',
|
||||
'htmx:beforeOnLoad',
|
||||
'htmx:beforeSwap',
|
||||
'htmx:afterSwap',
|
||||
'htmx:afterSettle',
|
||||
'htmx:afterRequest',
|
||||
'htmx:sendError',
|
||||
'htmx:responseError',
|
||||
];
|
||||
|
||||
let counter = 0;
|
||||
const requests = new WeakMap();
|
||||
|
||||
function getInfo(detail) {
|
||||
const key = detail?.requestConfig ?? detail?.xhr ?? null;
|
||||
if (!key || !requests.has(key)) return null;
|
||||
return requests.get(key);
|
||||
}
|
||||
|
||||
EVENTS.forEach(eventName => {
|
||||
document.addEventListener(eventName, (e) => {
|
||||
if (!window.HTMX_DEBUG) return;
|
||||
|
||||
const short = eventName.replace('htmx:', '').padEnd(14);
|
||||
const path = e.detail?.requestConfig?.path ?? e.detail?.pathInfo?.requestPath ?? '';
|
||||
const isError = eventName === 'htmx:sendError' || eventName === 'htmx:responseError';
|
||||
|
||||
let prefix;
|
||||
|
||||
if (eventName === 'htmx:beforeRequest') {
|
||||
const key = e.detail?.requestConfig ?? null;
|
||||
if (key) {
|
||||
const id = ++counter;
|
||||
const now = performance.now();
|
||||
requests.set(key, {id, start: now, last: now});
|
||||
prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`;
|
||||
} else {
|
||||
prefix = `# ? + 0.0ms (Δ 0.0ms)`;
|
||||
}
|
||||
} else {
|
||||
const info = getInfo(e.detail);
|
||||
if (info) {
|
||||
const now = performance.now();
|
||||
const total = (now - info.start).toFixed(1);
|
||||
const step = (now - info.last).toFixed(1);
|
||||
info.last = now;
|
||||
prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`;
|
||||
} else {
|
||||
prefix = `# ? + ?.?ms (Δ ?.?ms)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
console.warn(`[HTMX] ${prefix} ${short}`, path, e.detail);
|
||||
} else {
|
||||
console.debug(`[HTMX] ${prefix} ${short}`, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
421
src/myfasthtml/assets/core/keyboard.js
Normal file
421
src/myfasthtml/assets/core/keyboard.js
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Create keyboard bindings
|
||||
*/
|
||||
|
||||
// Set window.KEYBOARD_DEBUG = true in the browser console to enable traces
|
||||
window.KEYBOARD_DEBUG = false;
|
||||
|
||||
(function () {
|
||||
function kbLog(...args) {
|
||||
if (window.KEYBOARD_DEBUG) console.debug('[Keyboard]', ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Create a snapshot of current keyboard state
|
||||
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||
|
||||
// Add snapshot to history
|
||||
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
kbLog(`key="${key}" | history length=${KeyboardRegistry.snapshotHistory.length} | registeredElements=${KeyboardRegistry.elements.size}`);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
kbLog(` cancelled pending timeout`);
|
||||
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) {
|
||||
kbLog(` element="${elementId}" → no match in tree`);
|
||||
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;
|
||||
|
||||
kbLog(` element="${elementId}" | isInside=${isInside} | hasMatch=${hasMatch} | hasLongerSequences=${hasLongerSequences}`);
|
||||
|
||||
// Track if ANY element has longer sequences possible
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches, respecting require_inside and enabled flags
|
||||
if (hasMatch) {
|
||||
const requireInside = currentNode.config["require_inside"] === true;
|
||||
const enabled = isCombinationEnabled(data.controlDivId, currentNode.combinationStr);
|
||||
kbLog(` combination="${currentNode.combinationStr}" | requireInside=${requireInside} | enabled=${enabled}`);
|
||||
if (enabled && (!requireInside || isInside)) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
} else {
|
||||
kbLog(` → skipped (requireInside=${requireInside} but isInside=${isInside}, or disabled)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kbLog(` result: matches=${currentMatches.length} | anyHasLongerSequence=${anyHasLongerSequence}`);
|
||||
|
||||
// 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) {
|
||||
kbLog(` → TRIGGER immediately`);
|
||||
// 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) {
|
||||
kbLog(` → WAITING ${KeyboardRegistry.sequenceTimeout}ms (longer sequence possible)`);
|
||||
// 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(() => {
|
||||
kbLog(` → TRIGGER after timeout`);
|
||||
// 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) {
|
||||
kbLog(` → WAITING (partial match, no full match yet)`);
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
kbLog(` → NO MATCH, clearing history`);
|
||||
// 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) {
|
||||
kbLog(` → foundAnyMatch=false, clearing history`);
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||
kbLog(` → history too long, clearing`);
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a combination is enabled via the control div
|
||||
* @param {string} controlDivId - The ID of the keyboard control div
|
||||
* @param {string} combinationStr - The combination string (e.g., "esc")
|
||||
* @returns {boolean} - True if enabled (default: true if entry not found)
|
||||
*/
|
||||
function isCombinationEnabled(controlDivId, combinationStr) {
|
||||
const controlDiv = document.getElementById(controlDivId);
|
||||
if (!controlDiv) return true;
|
||||
|
||||
const entry = controlDiv.querySelector(`[data-combination="${combinationStr}"]`);
|
||||
if (!entry) return true;
|
||||
|
||||
return entry.dataset.enabled !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} controlDivId - The ID of the keyboard control div
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_keyboard_support = function (elementId, controlDivId, 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,
|
||||
controlDivId: controlDivId
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,471 +1,270 @@
|
||||
: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-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);
|
||||
}
|
||||
|
||||
|
||||
.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
|
||||
*/
|
||||
|
||||
/* 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-wrapper:hover .mf-dropdown {
|
||||
display: 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;
|
||||
}
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
1126
src/myfasthtml/assets/core/mouse.js
Normal file
1126
src/myfasthtml/assets/core/mouse.js
Normal file
File diff suppressed because it is too large
Load Diff
165
src/myfasthtml/assets/core/myfasthtml.css
Normal file
165
src/myfasthtml/assets/core/myfasthtml.css
Normal file
@@ -0,0 +1,165 @@
|
||||
: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);
|
||||
--color-button-hover: var(--color-base-300);
|
||||
|
||||
--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-button-hover);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
127
src/myfasthtml/assets/core/panel.css
Normal file
127
src/myfasthtml/assets/core/panel.css
Normal file
@@ -0,0 +1,127 @@
|
||||
/* *********************************************** */
|
||||
/* *************** 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;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Remove padding-top when using title layout */
|
||||
.mf-panel-left.mf-panel-with-title,
|
||||
.mf-panel-right.mf-panel-with-title {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mf-panel-body.mf-panel-body-right {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-body.mf-panel-body-left {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
177
src/myfasthtml/assets/core/profiler.css
Normal file
177
src/myfasthtml/assets/core/profiler.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* ================================================================== */
|
||||
/* Profiler Control */
|
||||
/* ================================================================== */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Root wrapper — fills parent, stacks toolbar above panel
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Toolbar
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Danger variant for clear button */
|
||||
.mf-profiler-btn-danger {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-btn-danger:hover {
|
||||
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Overhead metrics — right-aligned text */
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace list — left panel content
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-col-header {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace row
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected {
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Command name + description cell */
|
||||
.mf-profiler-cmd-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd-description {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-style: italic;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Duration cell — monospace, color-coded */
|
||||
.mf-profiler-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-fast {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Timestamp cell */
|
||||
.mf-profiler-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Empty state
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
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;
|
||||
}
|
||||
11
src/myfasthtml/assets/core/search.css
Normal file
11
src/myfasthtml/assets/core/search.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.mf-search {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
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-button-hover);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
365
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
365
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
@@ -0,0 +1,365 @@
|
||||
/* ********************************************* */
|
||||
/* ************* 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 */
|
||||
height: 24px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.dt2-last-cell {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dt2-add-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.dt2-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
|
||||
.dt2-row-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
min-width: 24px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
|
||||
.grid:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dt2-cell-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dt2-cell-input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
846
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
846
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
@@ -0,0 +1,846 @@
|
||||
function initDataGrid(gridId) {
|
||||
initDataGridScrollbars(gridId);
|
||||
initDataGridMouseOver(gridId);
|
||||
makeDatagridColumnsResizable(gridId);
|
||||
makeDatagridColumnsMovable(gridId);
|
||||
updateDatagridSelection(gridId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set width for a column and reinitialize handlers.
|
||||
* Updates all cells (header + body + footer) and reattaches event listeners.
|
||||
*
|
||||
* @param {string} gridId - The DataGrid instance ID
|
||||
* @param {string} colId - The column ID
|
||||
* @param {number} width - The new width in pixels
|
||||
*/
|
||||
function setColumnWidth(gridId, colId, width) {
|
||||
const table = document.getElementById(`t_${gridId}`);
|
||||
if (!table) {
|
||||
console.error(`Table with id "t_${gridId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all cells in the column (header + body + footer)
|
||||
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`);
|
||||
cells.forEach(cell => {
|
||||
cell.style.width = `${width}px`;
|
||||
});
|
||||
|
||||
// Reinitialize resize and move handlers (lost after header re-render)
|
||||
makeDatagridColumnsResizable(gridId);
|
||||
makeDatagridColumnsMovable(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 colId = cell.getAttribute('data-col');
|
||||
const resetCommandId = handle.dataset.resetCommandId;
|
||||
|
||||
// Call server command to calculate and apply optimal width
|
||||
if (resetCommandId) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
||||
target: '#th_' + datagridId,
|
||||
swap: 'outerHTML',
|
||||
values: {
|
||||
c_id: resetCommandId,
|
||||
col_id: colId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
let hasFocusedCell = false;
|
||||
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';
|
||||
requestAnimationFrame(() => cellElement.focus({ preventScroll: false }));
|
||||
hasFocusedCell = true;
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasFocusedCell) {
|
||||
const grid = document.getElementById(datagridId);
|
||||
if (grid) requestAnimationFrame(() => grid.focus({ preventScroll: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
2
src/myfasthtml/assets/sortableJs/Sortable.min.js
vendored
Normal file
2
src/myfasthtml/assets/sortableJs/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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,26 +1,26 @@
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
@@ -32,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()
|
||||
1344
src/myfasthtml/controls/DataGrid.py
Normal file
1344
src/myfasthtml/controls/DataGrid.py
Normal file
File diff suppressed because it is too large
Load Diff
109
src/myfasthtml/controls/DataGridColumnsList.py
Normal file
109
src/myfasthtml/controls/DataGridColumnsList.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.Sortable import Sortable
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular
|
||||
from myfasthtml.icons.tabler import grip_horizontal
|
||||
|
||||
logger = logging.getLogger("DataGridColumnsList")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def on_reorder(self):
|
||||
return Command("ReorderColumns",
|
||||
"Reorder columns in DataGrid",
|
||||
self._owner,
|
||||
self._owner.handle_on_reorder
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def toggle_column_visibility(self, col_id: str):
|
||||
return Command("ToggleColumnVisibility",
|
||||
"Toggle column visibility",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_column_visibility,
|
||||
kwargs={"col_id": col_id}
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class DataGridColumnsList(MultipleInstance):
|
||||
"""
|
||||
Show the list of columns in a DataGrid.
|
||||
You can set the visibility of each column.
|
||||
You can also reorder the columns via drag and drop.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
return [c for c in self._parent.get_columns() if c.type != ColumnType.RowSelection_]
|
||||
|
||||
def handle_on_reorder(self, order: list):
|
||||
logger.debug(f"on_reorder {order=}")
|
||||
ret = self._parent.handle_columns_reorder(order)
|
||||
return self.render(), ret
|
||||
|
||||
def handle_toggle_column_visibility(self, col_id):
|
||||
logger.debug(f"handle_toggle_column_visibility {col_id=}")
|
||||
col_def = [c for c in self.columns if c.col_id == col_id][0]
|
||||
updates = {col_id: {"visible": not col_def.visible}}
|
||||
ret = self._parent.handle_columns_updates(updates)
|
||||
return self.render(), ret
|
||||
|
||||
def mk_column_label(self, col_def: DataGridColumnState):
|
||||
return Div(
|
||||
mk.icon(grip_horizontal, cls="mf-drag-handle cursor-grab mr-1 opacity-40"),
|
||||
mk.mk(
|
||||
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
|
||||
command=self.commands.toggle_column_visibility(col_def.col_id)
|
||||
),
|
||||
mk.mk(
|
||||
Div(
|
||||
Div(mk.label(col_def.col_id, icon=IconsHelper.get(col_def.type), 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}",
|
||||
data_sort_id=col_def.col_id,
|
||||
)
|
||||
|
||||
def mk_columns(self):
|
||||
return Search(self,
|
||||
items_names="Columns",
|
||||
items=self.columns,
|
||||
get_attr=lambda x: x.col_id,
|
||||
get_id=lambda x: x.col_id,
|
||||
template=self.mk_column_label,
|
||||
max_height=None,
|
||||
_id="-Search"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
search = self.mk_columns()
|
||||
sortable = Sortable(self,
|
||||
command=self.commands.on_reorder(),
|
||||
container_id=f"{search.get_id()}-results",
|
||||
handle=".mf-drag-handle",
|
||||
_id="-sortable")
|
||||
return Div(search,
|
||||
sortable,
|
||||
id=self._id,
|
||||
cls="pt-2",
|
||||
style="height: 100%;")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
287
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
287
src/myfasthtml/controls/DataGridColumnsManager.py
Normal file
@@ -0,0 +1,287 @@
|
||||
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.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType, get_columns_types
|
||||
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.handle_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.handle_save_column_details,
|
||||
kwargs={"col_id": col_id}
|
||||
).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def add_new_column(self):
|
||||
return Command(f"AddNewColumn",
|
||||
f"Add a new column",
|
||||
self._owner,
|
||||
self._owner.handle_add_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.adding_new_column = False
|
||||
self._all_columns = True # Do not return to all_columns after column details
|
||||
|
||||
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 set_all_columns(self, all_columns):
|
||||
self._all_columns = all_columns
|
||||
|
||||
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 handle_show_all_columns(self):
|
||||
self.adding_new_column = False
|
||||
return self._mk_inner_content() if self._all_columns else None
|
||||
|
||||
def handle_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.adding_new_column = False
|
||||
self._register_formula(col_def)
|
||||
self._parent.save_state()
|
||||
|
||||
return self._mk_inner_content() if self._all_columns else None
|
||||
|
||||
def handle_add_new_column(self):
|
||||
self.adding_new_column = True
|
||||
col_def = DataGridColumnState("__new__", -1)
|
||||
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 via DataService.
|
||||
|
||||
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.
|
||||
"""
|
||||
data_service = getattr(self._parent, "_data_service", None)
|
||||
if data_service is None:
|
||||
return
|
||||
if col_def.type == ColumnType.Formula and col_def.formula:
|
||||
data_service.register_formula(col_def.col_id, col_def.formula)
|
||||
logger.debug("Registered formula for col %s", col_def.col_id)
|
||||
else:
|
||||
data_service.remove_formula(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=IconsHelper.get(col_def.type), 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 get_columns_types()],
|
||||
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.add_new_column()),
|
||||
cls="mb-1",
|
||||
)
|
||||
|
||||
def _mk_inner_content(self):
|
||||
if self.adding_new_column:
|
||||
col_def = DataGridColumnState("__new__", -1)
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
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 DataGridRowUiState
|
||||
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 = DataGridRowUiState(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.get_state().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")
|
||||
419
src/myfasthtml/controls/DataGridFormattingManager.py
Normal file
419
src/myfasthtml/controls/DataGridFormattingManager.py
Normal file
@@ -0,0 +1,419 @@
|
||||
import logging
|
||||
from typing import Optional, Literal
|
||||
|
||||
from fasthtml.common import Form, Fieldset, Label, Input, Span
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DslEditor import DslEditor, DslEditorConf
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Menu import Menu, MenuConf
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.formatting.dataclasses import RulePreset
|
||||
from myfasthtml.core.dsls import DslsManager
|
||||
from myfasthtml.core.formatting.dsl import parse_dsl
|
||||
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine
|
||||
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
|
||||
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
|
||||
from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
logger = logging.getLogger("DataGridFormattingManager")
|
||||
|
||||
|
||||
class DataGridFormattingManagerState(DbObject):
|
||||
def __init__(self, owner):
|
||||
with self.initializing():
|
||||
super().__init__(owner)
|
||||
self.presets: list = []
|
||||
self.selected_name: Optional[str] = None
|
||||
self.ns_mode: str = "view"
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def new_preset(self):
|
||||
return Command(
|
||||
"NewPreset",
|
||||
"New preset",
|
||||
self._owner,
|
||||
self._owner.handle_new_preset,
|
||||
icon=IconsHelper.get("add_circle20_regular"),
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def save_preset(self):
|
||||
return Command(
|
||||
"SavePreset",
|
||||
"Save preset",
|
||||
self._owner,
|
||||
self._owner.handle_save_preset,
|
||||
icon=IconsHelper.get("Save20Regular"),
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def rename_preset(self):
|
||||
return Command(
|
||||
"RenamePreset",
|
||||
"Rename preset",
|
||||
self._owner,
|
||||
self._owner.handle_rename_preset,
|
||||
icon=IconsHelper.get("edit20_regular"),
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def delete_preset(self):
|
||||
return Command(
|
||||
"DeletePreset",
|
||||
"Delete preset",
|
||||
self._owner,
|
||||
self._owner.handle_delete_preset,
|
||||
icon=IconsHelper.get("Delete20Regular"),
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def confirm_new(self):
|
||||
return Command(
|
||||
"ConfirmNew",
|
||||
"Confirm new preset",
|
||||
self._owner,
|
||||
self._owner.handle_confirm_new,
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def confirm_rename(self):
|
||||
return Command(
|
||||
"ConfirmRename",
|
||||
"Confirm rename",
|
||||
self._owner,
|
||||
self._owner.handle_confirm_rename,
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def cancel(self):
|
||||
return Command(
|
||||
"Cancel",
|
||||
"Cancel",
|
||||
self._owner,
|
||||
self._owner.handle_cancel,
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def select_preset(self, name: str):
|
||||
return Command(
|
||||
"SelectPreset",
|
||||
f"Select {name}",
|
||||
self._owner,
|
||||
self._owner.handle_select_preset,
|
||||
args=[name],
|
||||
).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
|
||||
class DataGridFormattingManager(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._state = DataGridFormattingManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
|
||||
self._panel = Panel(
|
||||
self,
|
||||
conf=PanelConf(left=True, right=False, left_title="Presets"),
|
||||
_id="-panel",
|
||||
)
|
||||
self._menu = Menu(
|
||||
self,
|
||||
conf=MenuConf(fixed_items=["NewPreset", "SavePreset", "RenamePreset", "DeletePreset"]),
|
||||
save_state=False,
|
||||
_id="-menu",
|
||||
)
|
||||
self._search = Search(
|
||||
self,
|
||||
items_names="Presets",
|
||||
template=self._mk_preset_item,
|
||||
_id="-search",
|
||||
)
|
||||
provider = DatagridMetadataProvider(self)
|
||||
completion_engine = FormattingCompletionEngine(provider, "")
|
||||
DslsManager.register(completion_engine, DSLParser())
|
||||
|
||||
self._editor = DslEditor(
|
||||
self,
|
||||
dsl=FormattingDSL(),
|
||||
conf=DslEditorConf(
|
||||
save_button=False,
|
||||
autocompletion=True,
|
||||
linting=True,
|
||||
engine_id=completion_engine.get_id(),
|
||||
),
|
||||
save_state=False,
|
||||
_id="-editor",
|
||||
)
|
||||
|
||||
self.handle_select_preset(self._state.selected_name)
|
||||
self._sync_provider()
|
||||
logger.debug(f"DataGridFormattingManager created with id={self._id}")
|
||||
|
||||
def get_main_content_id(self):
|
||||
return self._panel.get_ids().main
|
||||
|
||||
# === Helpers ===
|
||||
|
||||
def _get_all_presets(self) -> list:
|
||||
"""Returns builtin presets followed by user presets."""
|
||||
return list(DEFAULT_RULE_PRESETS.values()) + self._state.presets
|
||||
|
||||
def _is_builtin(self, name: str) -> bool:
|
||||
return name in DEFAULT_RULE_PRESETS
|
||||
|
||||
def _get_selected_preset(self) -> Optional[RulePreset]:
|
||||
if not self._state.selected_name:
|
||||
return None
|
||||
for p in self._get_all_presets():
|
||||
if p.name == self._state.selected_name:
|
||||
return p
|
||||
return None
|
||||
|
||||
def _get_user_preset(self, name: str) -> Optional[RulePreset]:
|
||||
for p in self._state.presets:
|
||||
if p.name == name:
|
||||
return p
|
||||
return None
|
||||
|
||||
def _parse_dsl_to_rules(self, dsl_text: str) -> list:
|
||||
"""Parse DSL text and extract FormatRule objects, ignoring scopes."""
|
||||
try:
|
||||
scoped_rules = parse_dsl(dsl_text)
|
||||
return [sr.rule for sr in scoped_rules]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _sync_provider(self):
|
||||
"""Sync all presets (builtin + user) into the session-scoped metadata provider."""
|
||||
provider = DatagridMetadataProvider(self)
|
||||
provider.rule_presets = {p.name: p for p in self._get_all_presets()}
|
||||
|
||||
# === Command handlers ===
|
||||
|
||||
def handle_new_preset(self):
|
||||
self._state.ns_mode = "new"
|
||||
return self.render()
|
||||
|
||||
def handle_save_preset(self):
|
||||
if not self._state.selected_name or self._is_builtin(self._state.selected_name):
|
||||
return self.render()
|
||||
preset = self._get_user_preset(self._state.selected_name)
|
||||
if preset is None:
|
||||
return self.render()
|
||||
dsl = self._editor.get_content()
|
||||
preset.dsl = dsl
|
||||
preset.rules = self._parse_dsl_to_rules(dsl)
|
||||
self._state.save()
|
||||
self._sync_provider()
|
||||
logger.debug(f"Saved preset '{preset.name}' with {len(preset.rules)} rules")
|
||||
return self.render()
|
||||
|
||||
def handle_rename_preset(self):
|
||||
if not self._state.selected_name or self._is_builtin(self._state.selected_name):
|
||||
return self.render()
|
||||
self._state.ns_mode = "rename"
|
||||
return self.render()
|
||||
|
||||
def handle_delete_preset(self):
|
||||
if not self._state.selected_name or self._is_builtin(self._state.selected_name):
|
||||
return self.render()
|
||||
deleted_name = self._state.selected_name
|
||||
self._state.presets = [p for p in self._state.presets if p.name != deleted_name]
|
||||
self._state.selected_name = None
|
||||
self._editor.set_content("")
|
||||
self._editor.conf.readonly = False
|
||||
self._sync_provider()
|
||||
logger.debug(f"Deleted preset '{deleted_name}'")
|
||||
return self.render()
|
||||
|
||||
def handle_select_preset(self, name: str):
|
||||
preset = None
|
||||
for p in self._get_all_presets():
|
||||
if p.name == name:
|
||||
preset = p
|
||||
break
|
||||
if preset is None:
|
||||
return None
|
||||
self._state.selected_name = name
|
||||
self._state.ns_mode = "view"
|
||||
self._editor.set_content(preset.dsl)
|
||||
self._editor.conf.readonly = self._is_builtin(name)
|
||||
logger.debug(f"Selected preset '{name}', readonly={self._editor.conf.readonly}")
|
||||
return self.render() # to also update the selected item in the search
|
||||
|
||||
def handle_confirm_new(self, client_response):
|
||||
name = (client_response.get("name") or "").strip()
|
||||
description = (client_response.get("description") or "").strip()
|
||||
|
||||
if not name:
|
||||
self._state.ns_mode = "view"
|
||||
return self.render()
|
||||
|
||||
all_names = {p.name for p in self._get_all_presets()}
|
||||
if name in all_names:
|
||||
logger.debug(f"Cannot create preset '{name}': name already exists")
|
||||
self._state.ns_mode = "view"
|
||||
return self.render()
|
||||
|
||||
new_preset = RulePreset(name=name, description=description, rules=[], dsl="")
|
||||
self._state.presets.append(new_preset)
|
||||
self._state.selected_name = name
|
||||
self._state.ns_mode = "view"
|
||||
self._editor.set_content("")
|
||||
self._editor.conf.readonly = False
|
||||
self._sync_provider()
|
||||
logger.debug(f"Created preset '{name}'")
|
||||
return self.render()
|
||||
|
||||
def handle_confirm_rename(self, client_response):
|
||||
name = (client_response.get("name") or "").strip()
|
||||
description = (client_response.get("description") or "").strip()
|
||||
|
||||
preset = self._get_user_preset(self._state.selected_name)
|
||||
if not name or preset is None:
|
||||
self._state.ns_mode = "view"
|
||||
return self.render()
|
||||
|
||||
if name != preset.name:
|
||||
all_names = {p.name for p in self._get_all_presets()}
|
||||
if name in all_names:
|
||||
logger.debug(f"Cannot rename to '{name}': name already exists")
|
||||
self._state.ns_mode = "view"
|
||||
return self.render()
|
||||
|
||||
old_name = preset.name
|
||||
preset.name = name
|
||||
preset.description = description
|
||||
self._state.selected_name = name
|
||||
self._state.ns_mode = "view"
|
||||
self._sync_provider()
|
||||
logger.debug(f"Renamed preset '{old_name}' → '{name}'")
|
||||
return self.render()
|
||||
|
||||
def handle_cancel(self):
|
||||
self._state.ns_mode = "view"
|
||||
return self.render()
|
||||
|
||||
# === Rendering ===
|
||||
|
||||
def _get_badges(self, preset: RulePreset, is_builtin: bool):
|
||||
badges = []
|
||||
if preset.has_formatter():
|
||||
badges.append(self._mk_badge("format"))
|
||||
if preset.has_style():
|
||||
badges.append(self._mk_badge("style"))
|
||||
if is_builtin:
|
||||
badges.append(self._mk_badge("built-in"))
|
||||
|
||||
return badges
|
||||
|
||||
def _mk_badge(self, text: Literal["format", "style", "built-in"]):
|
||||
if text == "built-in":
|
||||
return Span("built-in", cls="badge badge-xs badge-ghost")
|
||||
if text == "style":
|
||||
return Span("style", cls="badge badge-xs badge-primary")
|
||||
return Span("format", cls="badge badge-xs badge-secondary")
|
||||
|
||||
def _mk_preset_item(self, preset: RulePreset):
|
||||
is_active = self._state.selected_name == preset.name
|
||||
is_builtin = self._is_builtin(preset.name)
|
||||
badges = self._get_badges(preset, is_builtin)
|
||||
|
||||
item_cls = "mf-fmgr-preset-item"
|
||||
if is_active:
|
||||
item_cls += " mf-fmgr-preset-item-active"
|
||||
|
||||
return mk.mk(
|
||||
Div(
|
||||
Div(preset.name, cls="mf-fmgr-preset-name"),
|
||||
Div(preset.description, cls="mf-fmgr-preset-desc") if preset.description else None,
|
||||
Div(*badges, cls="mf-fmgr-preset-badges") if badges else None,
|
||||
cls=item_cls,
|
||||
),
|
||||
command=self.commands.select_preset(preset.name),
|
||||
)
|
||||
|
||||
def _mk_preset_header(self, preset: RulePreset, is_builtin: bool):
|
||||
badges = self._get_badges(preset, is_builtin)
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
Div(preset.name, cls="text-sm font-semibold"),
|
||||
Div(preset.description, cls="text-xs opacity-50") if preset.description else None,
|
||||
),
|
||||
Div(*badges, cls="flex gap-1") if badges else None,
|
||||
cls="mf-fmgr-editor-meta mb-2",
|
||||
)
|
||||
|
||||
def _mk_editor_view(self):
|
||||
preset = self._get_selected_preset()
|
||||
if preset is None:
|
||||
return Div("Select a preset to edit", cls="mf-fmgr-placeholder p-4 text-sm opacity-50")
|
||||
is_builtin = self._is_builtin(preset.name)
|
||||
return Div(
|
||||
self._mk_preset_header(preset, is_builtin),
|
||||
self._editor,
|
||||
cls="mf-fmgr-editor-view p-2",
|
||||
)
|
||||
|
||||
def _mk_new_form(self):
|
||||
return Div(
|
||||
Form(
|
||||
Fieldset(
|
||||
Label("Name"),
|
||||
Input(name="name", cls="input input-sm w-full"),
|
||||
Label("Description"),
|
||||
Input(name="description", cls="input input-sm w-full"),
|
||||
legend="New preset",
|
||||
cls="fieldset border-base-300 rounded-box p-2",
|
||||
),
|
||||
mk.dialog_buttons(
|
||||
on_ok=self.commands.confirm_new(),
|
||||
on_cancel=self.commands.cancel(),
|
||||
),
|
||||
),
|
||||
cls="mf-fmgr-form p-2",
|
||||
)
|
||||
|
||||
def _mk_rename_form(self):
|
||||
preset = self._get_selected_preset()
|
||||
return Div(
|
||||
Form(
|
||||
Fieldset(
|
||||
Label("Name"),
|
||||
Input(name="name", value=preset.name if preset else "", cls="input input-sm w-full"),
|
||||
Label("Description"),
|
||||
Input(name="description", value=preset.description if preset else "", cls="input input-sm w-full"),
|
||||
legend="Rename preset",
|
||||
cls="fieldset border-base-300 rounded-box p-4",
|
||||
),
|
||||
mk.dialog_buttons(
|
||||
on_ok=self.commands.confirm_rename(),
|
||||
on_cancel=self.commands.cancel(),
|
||||
),
|
||||
),
|
||||
cls="mf-fmgr-form p-2",
|
||||
)
|
||||
|
||||
def _mk_main_content(self):
|
||||
if self._state.ns_mode == "new":
|
||||
return self._mk_new_form()
|
||||
elif self._state.ns_mode == "rename":
|
||||
return self._mk_rename_form()
|
||||
return self._mk_editor_view()
|
||||
|
||||
def render(self):
|
||||
self._search.set_items(self._get_all_presets())
|
||||
self._panel._main = self._mk_main_content()
|
||||
self._panel._left = self._search
|
||||
|
||||
return Div(
|
||||
self._menu,
|
||||
self._panel,
|
||||
id=self._id,
|
||||
cls="mf-formatting-manager",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
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()
|
||||
339
src/myfasthtml/controls/DataGridsManager.py
Normal file
339
src/myfasthtml/controls/DataGridsManager.py
Normal file
@@ -0,0 +1,339 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode, TreeViewConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.data.DataServicesManager import DataServicesManager
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.formatting.dataclasses import FormatRule
|
||||
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, save_state, name=None):
|
||||
super().__init__(owner, save_state=save_state, name=name)
|
||||
with self.initializing():
|
||||
self.elements: list[DocumentDefinition] = []
|
||||
self.all_tables_formats: list[FormatRule] = []
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def upload_from_source(self):
|
||||
return Command("UploadFromSource",
|
||||
"Upload from source",
|
||||
self._owner,
|
||||
self._owner.upload_from_source).htmx(target=None)
|
||||
|
||||
def new_grid(self):
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.handle_new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def open_from_excel(self, tab_id, file_upload):
|
||||
return Command("OpenFromExcel",
|
||||
"Open from Excel",
|
||||
self._owner,
|
||||
self._owner.open_from_excel,
|
||||
args=[tab_id,
|
||||
file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def clear_tree(self):
|
||||
return Command("ClearTree",
|
||||
"Clear tree",
|
||||
self._owner,
|
||||
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def show_document(self):
|
||||
return Command("ShowDocument",
|
||||
"Show document",
|
||||
self._owner,
|
||||
self._owner.select_document,
|
||||
key="SelectNode")
|
||||
|
||||
def delete_grid(self):
|
||||
return Command("DeleteGrid",
|
||||
"Delete grid",
|
||||
self._owner,
|
||||
self._owner.delete_grid,
|
||||
key="DeleteNode")
|
||||
|
||||
|
||||
class DataGridsManager(SingleInstance):
|
||||
"""UI manager for DataGrids.
|
||||
|
||||
Responsible for the visual organisation of DataGrids: TreeView, TabsManager,
|
||||
and document lifecycle (create, open, delete). All data concerns are handled
|
||||
by DataServicesManager and DataService.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None, save_state=None):
|
||||
if not getattr(self, "_is_new_instance", False):
|
||||
return
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridsState(self, save_state)
|
||||
self._tree = self._mk_tree()
|
||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||
self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before")
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
|
||||
self._registry = DataGridsRegistry(parent)
|
||||
|
||||
# Data layer — session-scoped singletons
|
||||
self._data_services_manager = DataServicesManager(self._parent)
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Grid lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_and_register_grid(self, namespace: str, name: str, df: pd.DataFrame) -> DataGrid:
|
||||
"""Create a DataGrid and its companion DataService, then register both.
|
||||
|
||||
Args:
|
||||
namespace: Grid namespace.
|
||||
name: Grid name.
|
||||
df: DataFrame to initialise the grid with.
|
||||
|
||||
Returns:
|
||||
Created DataGrid instance.
|
||||
"""
|
||||
table_name = f"{namespace}.{name}" if namespace else name
|
||||
data_service = self._data_services_manager.create_service(table_name, save_state=True)
|
||||
data_service.load_dataframe(df)
|
||||
|
||||
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
|
||||
dg_conf = DatagridConf(namespace=namespace, name=name)
|
||||
dg = DataGrid(self, conf=dg_conf, save_state=True, _id=grid_id)
|
||||
|
||||
self._registry.put(namespace, name, dg.get_id())
|
||||
return dg
|
||||
|
||||
def _create_document(self, namespace: str, name: str, datagrid: DataGrid, tab_id: str = None) -> tuple[
|
||||
str, DocumentDefinition]:
|
||||
"""Create a DocumentDefinition and its associated tab.
|
||||
|
||||
Args:
|
||||
namespace: Document namespace.
|
||||
name: Document name.
|
||||
datagrid: Associated DataGrid instance.
|
||||
tab_id: Optional existing tab ID. If None, creates a new tab.
|
||||
|
||||
Returns:
|
||||
Tuple of (tab_id, document).
|
||||
"""
|
||||
if tab_id is None:
|
||||
tab_id = self._tabs_manager.create_tab(name, datagrid)
|
||||
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=datagrid.get_id()
|
||||
)
|
||||
self._state.elements = self._state.elements + [document]
|
||||
return tab_id, document
|
||||
|
||||
def _add_document_to_tree(self, document: DocumentDefinition, parent_id: str) -> TreeNode:
|
||||
"""Add a document node to the tree view.
|
||||
|
||||
Args:
|
||||
document: Document to add.
|
||||
parent_id: Parent node ID in the tree.
|
||||
|
||||
Returns:
|
||||
Created TreeNode.
|
||||
"""
|
||||
tree_node = TreeNode(
|
||||
id=document.document_id,
|
||||
label=document.name,
|
||||
type=document.type,
|
||||
parent=parent_id,
|
||||
bag=document.document_id
|
||||
)
|
||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||
return tree_node
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commands handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upload_from_source(self):
|
||||
file_upload = FileUpload(self)
|
||||
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
|
||||
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
|
||||
return self._tabs_manager.show_tab(tab_id)
|
||||
|
||||
def handle_new_grid(self):
|
||||
selected_id = self._tree.get_selected_id()
|
||||
if selected_id is None:
|
||||
parent_id = self._tree.ensure_path("Untitled")
|
||||
else:
|
||||
node = self._tree._state.items[selected_id]
|
||||
if node.type == "folder":
|
||||
parent_id = selected_id
|
||||
else:
|
||||
parent_id = node.parent
|
||||
|
||||
namespace = self._tree.get_state().items[parent_id].label
|
||||
name = self._generate_unique_sheet_name(parent_id)
|
||||
|
||||
dg = self._create_and_register_grid(namespace, name, pd.DataFrame())
|
||||
tab_id, document = self._create_document(namespace, name, dg)
|
||||
self._add_document_to_tree(document, parent_id)
|
||||
|
||||
if parent_id not in self._tree.get_state().opened:
|
||||
self._tree.get_state().opened.append(parent_id)
|
||||
self._tree.get_state().selected = document.document_id
|
||||
self._tree.handle_start_rename(document.document_id)
|
||||
|
||||
return self._tree, self._tabs_manager.show_tab(tab_id)
|
||||
|
||||
def _generate_unique_sheet_name(self, parent_id: str) -> str:
|
||||
children = self._tree.get_state().items[parent_id].children
|
||||
existing_labels = {self._tree.get_state().items[c].label for c in children}
|
||||
n = 1
|
||||
while f"Sheet{n}" in existing_labels:
|
||||
n += 1
|
||||
return f"Sheet{n}"
|
||||
|
||||
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 = self._create_and_register_grid(namespace, name, df)
|
||||
tab_id, document = self._create_document(namespace, name, dg, tab_id=tab_id)
|
||||
|
||||
parent_id = self._tree.ensure_path(document.namespace)
|
||||
self._add_document_to_tree(document, 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)
|
||||
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def delete_grid(self, node_id):
|
||||
"""Delete a grid and all its associated resources.
|
||||
|
||||
Called BEFORE TreeView._delete_node() so we can access the node bag.
|
||||
|
||||
Args:
|
||||
node_id: ID of the TreeView node to delete.
|
||||
|
||||
Returns:
|
||||
List of UI updates, or None.
|
||||
"""
|
||||
document_id = self._tree.get_bag(node_id)
|
||||
if document_id is None:
|
||||
return None
|
||||
|
||||
res = []
|
||||
try:
|
||||
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
|
||||
dg = DataGrid(self, _id=document.datagrid_id)
|
||||
|
||||
close_tab_res = self._tabs_manager.close_tab(document.tab_id)
|
||||
res.append(close_tab_res)
|
||||
|
||||
self._registry.remove(document.datagrid_id)
|
||||
self._data_services_manager.remove_service(document.datagrid_id)
|
||||
|
||||
dg.delete()
|
||||
InstancesManager.remove(self._session, document.datagrid_id)
|
||||
|
||||
self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
|
||||
self._state.save()
|
||||
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
def create_tab_content(self, tab_id):
|
||||
"""Recreate tab content after restart.
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab to recreate.
|
||||
|
||||
Returns:
|
||||
DataGrid instance for the 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}")
|
||||
dg = DataGrid(self, _id=document.datagrid_id)
|
||||
return dg
|
||||
|
||||
def clear_tree(self):
|
||||
self._state.elements = []
|
||||
self._tree.clear()
|
||||
return self._tree
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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.new_grid()),
|
||||
cls="flex"
|
||||
)
|
||||
|
||||
def _mk_tree(self):
|
||||
conf = TreeViewConf(add_leaf=False,
|
||||
icons={"folder": "database20_regular", "excel": "table20_regular"})
|
||||
tree = TreeView(self, conf=conf, _id="-treeview")
|
||||
for element in self._state.elements:
|
||||
parent_id = tree.ensure_path(element.namespace, node_type="folder")
|
||||
tree.add_node(TreeNode(id=element.document_id,
|
||||
label=element.name,
|
||||
type=element.type,
|
||||
parent=parent_id,
|
||||
bag=element.document_id))
|
||||
return tree
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._tree,
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,50 +1,106 @@
|
||||
from fastcore.xml import FT
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def close(self):
|
||||
return Command("Close",
|
||||
"Close Dropdown",
|
||||
self._owner,
|
||||
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
def click(self):
|
||||
return Command("Click",
|
||||
"Click on Dropdown",
|
||||
self._owner,
|
||||
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
|
||||
class DropdownState:
|
||||
def __init__(self):
|
||||
self.opened = False
|
||||
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
def __init__(self, parent, content, button=None, _id=None):
|
||||
"""
|
||||
Interactive dropdown component that toggles open/closed on button click.
|
||||
|
||||
Provides automatic close behavior when clicking outside or pressing ESC.
|
||||
Supports configurable positioning relative to the trigger button.
|
||||
|
||||
Args:
|
||||
parent: Parent instance (required).
|
||||
content: Content to display in the dropdown panel.
|
||||
button: Trigger element that toggles the dropdown.
|
||||
_id: Custom ID for the instance.
|
||||
position: Vertical position relative to button.
|
||||
- "below" (default): Dropdown appears below the button.
|
||||
- "above": Dropdown appears above the button.
|
||||
align: Horizontal alignment relative to button.
|
||||
- "left" (default): Aligns to the left edge of the button.
|
||||
- "right": Aligns to the right edge of the button.
|
||||
- "center": Centers relative to the button.
|
||||
|
||||
Example:
|
||||
dropdown = Dropdown(
|
||||
parent=root,
|
||||
button=Button("Menu"),
|
||||
content=Ul(Li("Option 1"), Li("Option 2")),
|
||||
position="below",
|
||||
align="right"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, parent, content=None, button=None, _id=None,
|
||||
position="below", align="left"):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = button
|
||||
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
|
||||
return self._mk_content()
|
||||
|
||||
def close(self):
|
||||
self._state.opened = False
|
||||
return self._mk_content()
|
||||
|
||||
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 {position_cls} {align_cls} {'is-visible' if self._state.opened else ''}",
|
||||
id=f"{self._id}-content"),
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
Div(self.button) if self.button else Div("None"),
|
||||
Div(self.content, cls="mf-dropdown"),
|
||||
cls="mf-dropdown-wrapper"
|
||||
Div(
|
||||
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(), require_inside=True),
|
||||
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()
|
||||
@@ -6,7 +6,7 @@ from fastapi import UploadFile
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
@@ -24,32 +24,62 @@ class FileUploadState(DbObject):
|
||||
self.ns_file_name: str | None = None
|
||||
self.ns_sheets_names: list | None = None
|
||||
self.ns_selected_sheet_name: str | None = None
|
||||
self.ns_file_content: bytes | None = None
|
||||
self.ns_on_ok = None
|
||||
self.ns_on_cancel = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def 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):
|
||||
"""
|
||||
Represents a file upload component.
|
||||
|
||||
This class provides functionality to handle the uploading process of a file,
|
||||
extract sheet names from an Excel file, and enables users to select a specific
|
||||
sheet for further processing. It integrates commands and state management
|
||||
to ensure smooth operation within a parent application.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
def __init__(self, parent, _id=None, **kwargs):
|
||||
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:
|
||||
file_content = file.file.read()
|
||||
self._state.ns_sheets_names = self.get_sheets_names(file_content)
|
||||
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(
|
||||
@@ -57,12 +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):
|
||||
@@ -86,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):
|
||||
|
||||
346
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
346
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
@@ -0,0 +1,346 @@
|
||||
import json
|
||||
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.Menu import Menu, MenuConf
|
||||
from myfasthtml.controls.Query import Query, QueryConf
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent import arrow_reset20_regular
|
||||
|
||||
logger = logging.getLogger("HierarchicalCanvasGraph")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HierarchicalCanvasGraphConf:
|
||||
"""Configuration for HierarchicalCanvasGraph control.
|
||||
|
||||
Attributes:
|
||||
nodes: List of node dictionaries with keys: id, label, type, kind
|
||||
edges: List of edge dictionaries with keys: from, to
|
||||
events_handlers: Optional dict mapping event names to Command objects
|
||||
Supported events: 'select_node', 'toggle_node'
|
||||
"""
|
||||
nodes: list[dict]
|
||||
edges: list[dict]
|
||||
events_handlers: Optional[dict] = None
|
||||
|
||||
|
||||
class HierarchicalCanvasGraphState(DbObject):
|
||||
"""Persistent state for HierarchicalCanvasGraph.
|
||||
|
||||
Persists collapsed nodes, view transform (zoom/pan), and layout orientation.
|
||||
"""
|
||||
|
||||
def __init__(self, owner, save_state=True):
|
||||
super().__init__(owner, save_state=save_state)
|
||||
with self.initializing():
|
||||
# Persisted: set of collapsed node IDs (stored as list for JSON serialization)
|
||||
self.collapsed: list = []
|
||||
|
||||
# Persisted: zoom/pan transform
|
||||
self.transform: dict = {"x": 0, "y": 0, "scale": 1}
|
||||
|
||||
# Persisted: layout orientation ('horizontal' or 'vertical')
|
||||
self.layout_mode: str = 'horizontal'
|
||||
|
||||
# Persisted: filter state
|
||||
self.filter_text: Optional[str] = None # Text search filter
|
||||
self.filter_type: Optional[str] = None # Type filter (badge click)
|
||||
self.filter_kind: Optional[str] = None # Kind filter (border click)
|
||||
|
||||
# Not persisted: current selection (ephemeral)
|
||||
self.ns_selected_id: Optional[str] = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
"""Commands for HierarchicalCanvasGraph internal state management."""
|
||||
|
||||
def update_view_state(self):
|
||||
"""Update view transform and layout mode.
|
||||
|
||||
This command is called internally by the JS to persist view state changes.
|
||||
"""
|
||||
return Command(
|
||||
"UpdateViewState",
|
||||
"Update view transform and layout mode",
|
||||
self._owner,
|
||||
self._owner.handle_update_view_state
|
||||
).htmx(target=f"#{self._id}", swap='none')
|
||||
|
||||
def apply_filter(self):
|
||||
"""Apply current filter and update the graph display.
|
||||
|
||||
This command is called when the filter changes (search text, type, or kind).
|
||||
"""
|
||||
return Command(
|
||||
"ApplyFilter",
|
||||
"Apply filter to graph",
|
||||
self._owner,
|
||||
self._owner.handle_apply_filter,
|
||||
key="#{id}-apply-filter",
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def reset_view(self):
|
||||
"""Reset the view transform to default values.
|
||||
|
||||
This command can be used to fix stuck/frozen canvas by resetting zoom/pan state.
|
||||
"""
|
||||
return Command(
|
||||
"ResetView",
|
||||
"Reset view transform",
|
||||
self._owner,
|
||||
self._owner.handle_reset_view,
|
||||
icon=arrow_reset20_regular
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""A canvas-based hierarchical graph visualization control.
|
||||
|
||||
Displays nodes and edges in a tree layout with expand/collapse functionality.
|
||||
Uses HTML5 Canvas for rendering with stable zoom/pan and search filtering.
|
||||
|
||||
Features:
|
||||
- Reingold-Tilford hierarchical layout
|
||||
- Expand/collapse nodes with children
|
||||
- Zoom and pan with mouse wheel and drag
|
||||
- Search/filter nodes by label or kind
|
||||
- Click to select nodes
|
||||
- Dot grid background
|
||||
- Stable zoom on container resize
|
||||
|
||||
Events:
|
||||
- select_node: Fired when a node is clicked (not on toggle button)
|
||||
- toggle_node: Fired when a node's expand/collapse button is clicked
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf: HierarchicalCanvasGraphConf, _id=None):
|
||||
"""Initialize the HierarchicalCanvasGraph control.
|
||||
|
||||
Args:
|
||||
parent: Parent instance
|
||||
conf: Configuration object with nodes, edges, and event handlers
|
||||
_id: Optional custom ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf
|
||||
self._state = HierarchicalCanvasGraphState(self)
|
||||
self.commands = Commands(self)
|
||||
|
||||
# Add Query component for filtering
|
||||
self._query = Query(self, QueryConf(placeholder="Filter instances..."), _id="-query")
|
||||
self._query.bind_command("QueryChanged", self.commands.apply_filter())
|
||||
self._query.bind_command("CancelQuery", self.commands.apply_filter())
|
||||
|
||||
# Add Menu
|
||||
self._menu = Menu(self, conf=MenuConf(["ResetView"]), _id="-menu")
|
||||
|
||||
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
|
||||
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
|
||||
|
||||
def get_state(self):
|
||||
"""Get the control's persistent state.
|
||||
|
||||
Returns:
|
||||
HierarchicalCanvasGraphState: The state object
|
||||
"""
|
||||
return self._state
|
||||
|
||||
def handle_update_view_state(self, transform: Optional[dict] = None, layout_mode: Optional[str] = None):
|
||||
"""Internal handler to update view state from client.
|
||||
|
||||
Args:
|
||||
transform: Optional dict with zoom/pan transform state (received as JSON string)
|
||||
layout_mode: Optional string with layout orientation
|
||||
|
||||
Returns:
|
||||
str: Empty string (no UI update needed)
|
||||
"""
|
||||
if transform is not None:
|
||||
# Parse JSON string to dict (sent as JSON.stringify() from JS)
|
||||
if isinstance(transform, str):
|
||||
try:
|
||||
transform = json.loads(transform)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.error(f"Failed to parse transform JSON: {e}")
|
||||
return ""
|
||||
|
||||
self._state.transform = transform
|
||||
|
||||
if layout_mode is not None:
|
||||
self._state.layout_mode = layout_mode
|
||||
|
||||
return ""
|
||||
|
||||
def handle_reset_view(self):
|
||||
"""Internal handler to reset view transform to default values.
|
||||
|
||||
Returns:
|
||||
self: For HTMX to render the updated graph with reset transform
|
||||
"""
|
||||
self._state.transform = {"x": 0, "y": 0, "scale": 1}
|
||||
self._state.collapsed = []
|
||||
logger.debug("Transform and collapsed state reset to defaults")
|
||||
return self
|
||||
|
||||
def handle_apply_filter(self, query_param="text", value=None):
|
||||
"""Internal handler to apply filter and re-render the graph.
|
||||
|
||||
Args:
|
||||
query_param: Type of filter - "text", "type", or "kind"
|
||||
value: The filter value (type name or kind name). Toggles off if same value clicked again.
|
||||
|
||||
Returns:
|
||||
self: For HTMX to render the updated graph
|
||||
"""
|
||||
# Save old values to detect toggle
|
||||
old_filter_type = self._state.filter_type
|
||||
old_filter_kind = self._state.filter_kind
|
||||
|
||||
# Reset all filters
|
||||
self._state.filter_text = None
|
||||
self._state.filter_type = None
|
||||
self._state.filter_kind = None
|
||||
|
||||
# Apply the requested filter
|
||||
if query_param == "text":
|
||||
# Text filter from Query component
|
||||
self._state.filter_text = self._query.get_query()
|
||||
|
||||
elif query_param == "type":
|
||||
# Type filter from badge click - toggle if same type clicked again
|
||||
if old_filter_type != value:
|
||||
self._state.filter_type = value
|
||||
|
||||
elif query_param == "kind":
|
||||
# Kind filter from border click - toggle if same kind clicked again
|
||||
if old_filter_kind != value:
|
||||
self._state.filter_kind = value
|
||||
|
||||
logger.debug(f"Applying filter: query_param={query_param}, value={value}, "
|
||||
f"text={self._state.filter_text}, type={self._state.filter_type}, kind={self._state.filter_kind}")
|
||||
|
||||
return self
|
||||
|
||||
def _calculate_filtered_nodes(self) -> Optional[list[str]]:
|
||||
"""Calculate which node IDs match the current filter criteria.
|
||||
|
||||
Returns:
|
||||
Optional[list[str]]:
|
||||
- None: No filter is active (all nodes visible, nothing dimmed)
|
||||
- []: Filter active but no matches (all nodes dimmed)
|
||||
- [ids]: Filter active with matches (only these nodes visible)
|
||||
"""
|
||||
# If no filters are active, return None (no filtering)
|
||||
if not self._state.filter_text and not self._state.filter_type and not self._state.filter_kind:
|
||||
return None
|
||||
|
||||
filtered_ids = []
|
||||
for node in self.conf.nodes:
|
||||
matches = True
|
||||
|
||||
# Check text filter (searches in id, label, type, kind)
|
||||
if self._state.filter_text:
|
||||
search_text = self._state.filter_text.lower()
|
||||
searchable = f"{node.get('id', '')} {node.get('label', '')} {node.get('type', '')} {node.get('kind', '')}".lower()
|
||||
if search_text not in searchable:
|
||||
matches = False
|
||||
|
||||
# Check type filter
|
||||
if self._state.filter_type and node.get('type') != self._state.filter_type:
|
||||
matches = False
|
||||
|
||||
# Check kind filter
|
||||
if self._state.filter_kind and node.get('kind') != self._state.filter_kind:
|
||||
matches = False
|
||||
|
||||
if matches:
|
||||
filtered_ids.append(node['id'])
|
||||
|
||||
return filtered_ids
|
||||
|
||||
def _prepare_options(self) -> dict:
|
||||
"""Prepare JavaScript options object.
|
||||
|
||||
Returns:
|
||||
dict: Options to pass to the JS initialization function
|
||||
"""
|
||||
# Convert event handlers to HTMX options
|
||||
events = {}
|
||||
|
||||
# Add internal handler for view state persistence
|
||||
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options()
|
||||
|
||||
# Add internal handlers for filtering by type and kind (badge/border clicks)
|
||||
events['_internal_filter_by_type'] = self.commands.apply_filter().ajax_htmx_options()
|
||||
events['_internal_filter_by_kind'] = self.commands.apply_filter().ajax_htmx_options()
|
||||
|
||||
# Add user-provided event handlers
|
||||
if self.conf.events_handlers:
|
||||
for event_name, command in self.conf.events_handlers.items():
|
||||
events[event_name] = command.ajax_htmx_options()
|
||||
|
||||
# Calculate filtered nodes
|
||||
filtered_nodes = self._calculate_filtered_nodes()
|
||||
|
||||
return {
|
||||
"nodes": self.conf.nodes,
|
||||
"edges": self.conf.edges,
|
||||
"collapsed": self._state.collapsed,
|
||||
"transform": self._state.transform,
|
||||
"layout_mode": self._state.layout_mode,
|
||||
"filtered_nodes": filtered_nodes,
|
||||
"events": events
|
||||
}
|
||||
|
||||
def render(self):
|
||||
"""Render the HierarchicalCanvasGraph control.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control with canvas and initialization script
|
||||
"""
|
||||
options = self._prepare_options()
|
||||
options_json = json.dumps(options, indent=2)
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
self._query,
|
||||
self._menu,
|
||||
cls="flex justify-between m-2"
|
||||
),
|
||||
|
||||
# Canvas element (sized by JS to fill container)
|
||||
Div(
|
||||
id=f"{self._id}_container",
|
||||
cls="mf-hcg-container"
|
||||
),
|
||||
|
||||
# Initialization script
|
||||
Script(f"""
|
||||
(function() {{
|
||||
if (typeof initHierarchicalCanvasGraph === 'function') {{
|
||||
initHierarchicalCanvasGraph('{self._id}_container', {options_json});
|
||||
}} else {{
|
||||
console.error('initHierarchicalCanvasGraph function not found');
|
||||
}}
|
||||
}})();
|
||||
"""),
|
||||
|
||||
id=self._id,
|
||||
cls="mf-hierarchical-canvas-graph"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control
|
||||
"""
|
||||
return self.render()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user