Compare commits
61 Commits
fc38196ad9
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea551bc1a | |||
| 3bcf50f55f | |||
| 7f099b14f6 | |||
| 0e1087a614 | |||
| 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 |
@@ -205,7 +205,7 @@ def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
@@ -340,6 +340,124 @@ def mk_content(self):
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
@@ -378,7 +496,7 @@ class Commands(BaseCommands):
|
||||
return Command("Toggle",
|
||||
"Toggle visibility",
|
||||
self._owner,
|
||||
self._owner.toggle
|
||||
self._owner.handle_toggle
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
@@ -391,7 +509,7 @@ class MyControl(MultipleInstance):
|
||||
|
||||
logger.debug(f"MyControl created with id={self._id}")
|
||||
|
||||
def toggle(self):
|
||||
def handle_toggle(self):
|
||||
self._state.visible = not self._state.visible
|
||||
return self
|
||||
|
||||
|
||||
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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
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
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
@@ -1,551 +0,0 @@
|
||||
# DataGrid Formatting
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| **Core Module** | | `src/myfasthtml/core/formatting/` |
|
||||
| Dataclasses (Condition, Style, Formatter, FormatRule) | :white_check_mark: Implemented | `dataclasses.py` |
|
||||
| Style Presets (DaisyUI 5) | :white_check_mark: Implemented | `presets.py` |
|
||||
| Formatter Presets (EUR, USD, etc.) | :white_check_mark: Implemented | `presets.py` |
|
||||
| ConditionEvaluator (12 operators) | :white_check_mark: Implemented | `condition_evaluator.py` |
|
||||
| StyleResolver | :white_check_mark: Implemented | `style_resolver.py` |
|
||||
| FormatterResolver (Number, Date, Boolean, Text, Enum) | :white_check_mark: Implemented | `formatter_resolver.py` |
|
||||
| FormattingEngine (facade + conflict resolution) | :white_check_mark: Implemented | `engine.py` |
|
||||
| **Condition Features** | | |
|
||||
| `col` parameter (row-level conditions) | :white_check_mark: Implemented | |
|
||||
| `row` parameter (column-level conditions) | :x: Not implemented | |
|
||||
| Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | |
|
||||
| **Scope Levels** | | |
|
||||
| Cell scope | :white_check_mark: Implemented | |
|
||||
| Row scope | :white_check_mark: Implemented | |
|
||||
| Column scope | :white_check_mark: Implemented | |
|
||||
| Table scope | :o: To implement | |
|
||||
| Tables scope (global) | :o: To implement | |
|
||||
| **DataGrid Integration** | | |
|
||||
| Integration in `mk_body_cell_content()` | :white_check_mark: Implemented | `DataGrid.py` |
|
||||
| DataGridFormattingEditor | :white_check_mark: Implemented | `DataGridFormattingEditor.py` |
|
||||
| DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` |
|
||||
| **Tests** | | `tests/core/formatting/` |
|
||||
| test_condition_evaluator.py | :white_check_mark: ~45 test cases | |
|
||||
| test_style_resolver.py | :white_check_mark: ~12 test cases | |
|
||||
| test_formatter_resolver.py | :white_check_mark: ~40 test cases | |
|
||||
| test_engine.py | :white_check_mark: ~18 test cases | |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the formatting capabilities for the DataGrid component.
|
||||
|
||||
**Formatting applies at five levels:**
|
||||
|
||||
| Level | Cells Targeted | Condition Evaluated On | Specificity |
|
||||
|------------|-----------------------------------|----------------------------------------------|-------------|
|
||||
| **Cell** | 1 specific cell | The cell value | Highest (1) |
|
||||
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) | High (2) |
|
||||
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) | Medium (3) |
|
||||
| **Table** | All cells in a specific table | Each cell value | Low (4) |
|
||||
| **Tables** | All cells in all tables (global) | Each cell value | Lowest (5) |
|
||||
|
||||
---
|
||||
|
||||
## Format Rule Structure
|
||||
|
||||
A format is a **list** of rules. Each rule is an object:
|
||||
|
||||
```json
|
||||
{
|
||||
"condition": {},
|
||||
"style": {},
|
||||
"formatter": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- `style` and `formatter` can appear alone (unconditional formatting)
|
||||
- `condition` **cannot** appear alone - must be paired with `style` and/or `formatter`
|
||||
- If `condition` is present, the `style`/`formatter` is applied **only if** the condition is met
|
||||
- Rules are evaluated in order; multiple rules can match
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When multiple rules match the same cell:
|
||||
|
||||
1. **Specificity** = number of conditions in the rule
|
||||
2. **Higher specificity wins**
|
||||
3. **At equal specificity, last rule wins entirely** (no fusion)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"style": {
|
||||
"color": "gray"
|
||||
}
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"operator": "<",
|
||||
"value": 0
|
||||
},
|
||||
"style": {
|
||||
"color": "red"
|
||||
}
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"operator": "==",
|
||||
"value": -5
|
||||
},
|
||||
"style": {
|
||||
"color": "black"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
For `value = -5`: Rule 3 wins (same specificity as rule 2, but defined later).
|
||||
|
||||
---
|
||||
|
||||
## Condition Structure
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Default | Required | Description | Status |
|
||||
|------------------|------------------------|---------|----------|---------------------------------------------|--------|
|
||||
| `operator` | string | - | Yes | Comparison operator | :white_check_mark: |
|
||||
| `value` | scalar / list / object | - | Depends | Value to compare against | :white_check_mark: |
|
||||
| `not` | bool | `false` | No | Inverts the condition result | :white_check_mark: (as `negate`) |
|
||||
| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison | :white_check_mark: |
|
||||
| `col` | string | - | No | Reference column (for row-level conditions) | :white_check_mark: |
|
||||
| `row` | int | - | No | Reference row (for column-level conditions) | :x: Not implemented |
|
||||
|
||||
### Operators
|
||||
|
||||
All operators are :white_check_mark: **implemented**.
|
||||
|
||||
| Operator | Description | Value Required |
|
||||
|--------------|--------------------------|------------------|
|
||||
| `==` | Equal | Yes |
|
||||
| `!=` | Not equal | Yes |
|
||||
| `<` | Less than | Yes |
|
||||
| `<=` | Less than or equal | Yes |
|
||||
| `>` | Greater than | Yes |
|
||||
| `>=` | Greater than or equal | Yes |
|
||||
| `contains` | String contains | Yes |
|
||||
| `startswith` | String starts with | Yes |
|
||||
| `endswith` | String ends with | Yes |
|
||||
| `in` | Value in list | Yes (list) |
|
||||
| `between` | Value between two values | Yes ([min, max]) |
|
||||
| `isempty` | Value is empty/null | No |
|
||||
| `isnotempty` | Value is not empty/null | No |
|
||||
|
||||
### Value Types
|
||||
|
||||
**Literal value:**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "<",
|
||||
"value": 0
|
||||
}
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"A",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Cell reference (compare with another column):**
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": ">",
|
||||
"value": {
|
||||
"col": "budget"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Negation
|
||||
|
||||
Use the `not` flag instead of separate operators:
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"A",
|
||||
"B"
|
||||
],
|
||||
"not": true
|
||||
}
|
||||
{
|
||||
"operator": "contains",
|
||||
"value": "error",
|
||||
"not": true
|
||||
}
|
||||
```
|
||||
|
||||
### Case Sensitivity
|
||||
|
||||
String comparisons are **case-insensitive by default**.
|
||||
|
||||
```json
|
||||
{
|
||||
"operator": "==",
|
||||
"value": "Error",
|
||||
"case_sensitive": true
|
||||
}
|
||||
```
|
||||
|
||||
### Evaluation Behavior
|
||||
|
||||
| Situation | Behavior |
|
||||
|---------------------------|-----------------------------------|
|
||||
| Cell value is `null` | Condition = `false` |
|
||||
| Referenced cell is `null` | Condition = `false` |
|
||||
| Type mismatch | Condition = `false` (no coercion) |
|
||||
| String operators | Converts value to string first |
|
||||
|
||||
### Examples
|
||||
|
||||
```json
|
||||
// Row-level: highlight if "status" column == "error"
|
||||
{
|
||||
"col": "status",
|
||||
"operator": "==",
|
||||
"value": "error"
|
||||
}
|
||||
|
||||
// Column-level: bold if row 0 has value "Total"
|
||||
{
|
||||
"row": 0,
|
||||
"operator": "==",
|
||||
"value": "Total"
|
||||
}
|
||||
|
||||
// Compare with another column
|
||||
{
|
||||
"operator": ">",
|
||||
"value": {
|
||||
"col": "budget"
|
||||
}
|
||||
}
|
||||
|
||||
// Negated condition
|
||||
{
|
||||
"operator": "in",
|
||||
"value": [
|
||||
"draft",
|
||||
"pending"
|
||||
],
|
||||
"not": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Structure
|
||||
|
||||
:white_check_mark: **Fully implemented** in `style_resolver.py`
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|--------------------|--------|------------|---------------------------------------------------|
|
||||
| `preset` | string | - | Preset name (applied first, can be overridden) |
|
||||
| `background_color` | string | - | Background color (hex, CSS name, or CSS variable) |
|
||||
| `color` | string | - | Text color |
|
||||
| `font_weight` | string | `"normal"` | `"normal"` or `"bold"` |
|
||||
| `font_style` | string | `"normal"` | `"normal"` or `"italic"` |
|
||||
| `font_size` | string | - | Font size (`"12px"`, `"0.9em"`) |
|
||||
| `text_decoration` | string | `"none"` | `"none"`, `"underline"`, `"line-through"` |
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"style": {
|
||||
"preset": "success",
|
||||
"font_weight": "bold"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Default Presets (DaisyUI 5)
|
||||
|
||||
| Preset | Background | Text |
|
||||
|-------------|--------------------------|----------------------------------|
|
||||
| `primary` | `var(--color-primary)` | `var(--color-primary-content)` |
|
||||
| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` |
|
||||
| `accent` | `var(--color-accent)` | `var(--color-accent-content)` |
|
||||
| `neutral` | `var(--color-neutral)` | `var(--color-neutral-content)` |
|
||||
| `info` | `var(--color-info)` | `var(--color-info-content)` |
|
||||
| `success` | `var(--color-success)` | `var(--color-success-content)` |
|
||||
| `warning` | `var(--color-warning)` | `var(--color-warning-content)` |
|
||||
| `error` | `var(--color-error)` | `var(--color-error-content)` |
|
||||
|
||||
All presets default to `font_weight: "normal"`, `font_style: "normal"`, `text_decoration: "none"`.
|
||||
|
||||
### Resolution Logic
|
||||
|
||||
1. If `preset` is specified, apply all preset properties
|
||||
2. Override with any explicit properties
|
||||
|
||||
**No style fusion:** When multiple rules match, the winning rule's style applies entirely.
|
||||
|
||||
---
|
||||
|
||||
## Formatter Structure
|
||||
|
||||
Formatters transform cell values for display without changing the underlying data.
|
||||
|
||||
### Usage
|
||||
|
||||
```json
|
||||
{
|
||||
"formatter": {
|
||||
"preset": "EUR"
|
||||
}
|
||||
}
|
||||
{
|
||||
"formatter": {
|
||||
"preset": "EUR",
|
||||
"precision": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
If formatting fails (e.g., non-numeric value for `number` formatter), display `"⚠"`.
|
||||
|
||||
---
|
||||
|
||||
## Formatter Types
|
||||
|
||||
All formatter types are :white_check_mark: **implemented** in `formatter_resolver.py`.
|
||||
|
||||
### `number`
|
||||
|
||||
For numbers, currencies, and percentages.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|-----------------|--------|---------|-------------------------------|
|
||||
| `prefix` | string | `""` | Text before value |
|
||||
| `suffix` | string | `""` | Text after value |
|
||||
| `thousands_sep` | string | `""` | Thousands separator |
|
||||
| `decimal_sep` | string | `"."` | Decimal separator |
|
||||
| `precision` | int | `0` | Number of decimal places |
|
||||
| `multiplier` | number | `1` | Multiply value before display |
|
||||
|
||||
### `date`
|
||||
|
||||
For dates and datetimes.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|--------|--------------|-------------------------|
|
||||
| `format` | string | `"%Y-%m-%d"` | strftime format pattern |
|
||||
|
||||
### `boolean`
|
||||
|
||||
For true/false values.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---------------|--------|-----------|-------------------|
|
||||
| `true_value` | string | `"true"` | Display for true |
|
||||
| `false_value` | string | `"false"` | Display for false |
|
||||
| `null_value` | string | `""` | Display for null |
|
||||
|
||||
### `text`
|
||||
|
||||
For text transformations.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|--------------|--------|---------|----------------------------------------------|
|
||||
| `transform` | string | - | `"uppercase"`, `"lowercase"`, `"capitalize"` |
|
||||
| `max_length` | int | - | Truncate if exceeded |
|
||||
| `ellipsis` | string | `"..."` | Suffix when truncated |
|
||||
|
||||
### `enum`
|
||||
|
||||
For mapping values to display labels. Also used for Select dropdowns.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---------------|--------|------------------|------------------------------------|
|
||||
| `source` | object | - | Data source (see below) |
|
||||
| `default` | string | `""` | Label for unknown values |
|
||||
| `allow_empty` | bool | `true` | Show empty option in Select |
|
||||
| `empty_label` | string | `"-- Select --"` | Label for empty option |
|
||||
| `order_by` | string | `"source"` | `"source"`, `"display"`, `"value"` |
|
||||
|
||||
#### Source Types
|
||||
|
||||
**Static mapping:** :white_check_mark: Implemented
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "enum",
|
||||
"source": {
|
||||
"type": "mapping",
|
||||
"value": {
|
||||
"draft": "Brouillon",
|
||||
"pending": "En attente",
|
||||
"approved": "Approuvé"
|
||||
}
|
||||
},
|
||||
"default": "Inconnu"
|
||||
}
|
||||
```
|
||||
|
||||
**From another DataGrid:** :white_check_mark: Implemented (requires `lookup_resolver` injection)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "enum",
|
||||
"source": {
|
||||
"type": "datagrid",
|
||||
"value": "categories_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Empty Value Behavior
|
||||
|
||||
- `allow_empty: true` → Empty option displayed with `empty_label`
|
||||
- `allow_empty: false` → First entry selected by default
|
||||
|
||||
---
|
||||
|
||||
## Default Formatter Presets
|
||||
|
||||
```python
|
||||
formatter_presets = {
|
||||
"EUR": {
|
||||
"type": "number",
|
||||
"suffix": " €",
|
||||
"thousands_sep": " ",
|
||||
"decimal_sep": ",",
|
||||
"precision": 2
|
||||
},
|
||||
"USD": {
|
||||
"type": "number",
|
||||
"prefix": "$",
|
||||
"thousands_sep": ",",
|
||||
"decimal_sep": ".",
|
||||
"precision": 2
|
||||
},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"suffix": "%",
|
||||
"precision": 1,
|
||||
"multiplier": 100
|
||||
},
|
||||
"short_date": {
|
||||
"type": "date",
|
||||
"format": "%d/%m/%Y"
|
||||
},
|
||||
"iso_date": {
|
||||
"type": "date",
|
||||
"format": "%Y-%m-%d"
|
||||
},
|
||||
"yes_no": {
|
||||
"type": "boolean",
|
||||
"true_value": "Yes",
|
||||
"false_value": "No"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
:warning: **Structures exist but integration with formatting engine not implemented**
|
||||
|
||||
### Format Storage Location
|
||||
|
||||
| Level | Storage | Key | Status |
|
||||
|------------|--------------------------------|---------|--------|
|
||||
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
|
||||
| **Row** | `DataGridRowState.format` | - | Structure exists |
|
||||
| **Column** | `DataGridColumnState.format` | - | Structure exists |
|
||||
| **Table** | `DatagridState.table_format` | - | :o: To implement |
|
||||
| **Tables** | `DataGridsManager.all_tables_formats` | - | :o: To implement |
|
||||
|
||||
### Cell ID Format
|
||||
|
||||
```
|
||||
tcell_{datagrid_id}-{row_index}-{col_index}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataGridsManager
|
||||
|
||||
:white_check_mark: **Implemented** in `src/myfasthtml/controls/DataGridsManager.py`
|
||||
|
||||
Global presets stored as instance attributes:
|
||||
|
||||
| Property | Type | Description | Status |
|
||||
|---------------------|--------|-------------------------------------------|--------|
|
||||
| `style_presets` | dict | Style presets (primary, success, etc.) | :white_check_mark: |
|
||||
| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) | :white_check_mark: |
|
||||
| `default_locale` | string | Default locale for number/date formatting | :x: Not implemented |
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `get_style_presets()` | Get the global style presets |
|
||||
| `get_formatter_presets()` | Get the global formatter presets |
|
||||
| `add_style_preset(name, preset)` | Add or update a style preset |
|
||||
| `add_formatter_preset(name, preset)` | Add or update a formatter preset |
|
||||
| `remove_style_preset(name)` | Remove a style preset |
|
||||
| `remove_formatter_preset(name)` | Remove a formatter preset |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```python
|
||||
# Add custom presets
|
||||
manager.add_style_preset("highlight", {"background-color": "yellow", "color": "black"})
|
||||
manager.add_formatter_preset("CHF", {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
All items below are :x: **not implemented**.
|
||||
|
||||
- **`row` parameter for column-level conditions**: Evaluate condition on a specific row
|
||||
- **AND/OR conditions**: Add explicit `and`/`or` operators if `between`/`in` prove insufficient
|
||||
- **Cell references**: Extend to `{"col": "x", "row": 0}` for specific cell and `{"col": "x", "row_offset": -1}` for
|
||||
relative references
|
||||
- **Enum cascade (draft)**: Dependent dropdowns with `depends_on` and `filter_column`
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"type": "datagrid",
|
||||
"value": "cities_grid",
|
||||
"value_column": "id",
|
||||
"display_column": "name",
|
||||
"filter_column": "country_id"
|
||||
},
|
||||
"depends_on": "country"
|
||||
}
|
||||
```
|
||||
- **API source for enum**: `{"type": "api", "value": "https://...", ...}`
|
||||
- **Searchable enum**: For large option lists
|
||||
- **Formatter chaining**: Apply multiple formatters in sequence
|
||||
- ~~**DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()`~~ Done
|
||||
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`
|
||||
@@ -151,18 +151,6 @@ The DataGrid automatically detects column types from pandas dtypes:
|
||||
| `datetime64` | Datetime | Formatted date |
|
||||
| `object`, others | Text | Left-aligned, truncated |
|
||||
|
||||
### Row Index Column
|
||||
|
||||
By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows:
|
||||
|
||||
```python
|
||||
# Row index is enabled by default
|
||||
grid._state.row_index = True
|
||||
|
||||
# To disable the row index column
|
||||
grid._state.row_index = False
|
||||
grid.init_from_dataframe(df)
|
||||
```
|
||||
|
||||
## Column Features
|
||||
|
||||
|
||||
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** |
|
||||
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
|
||||
@@ -21,19 +21,47 @@
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multiple Simultaneous Triggers
|
||||
### Scope Control with `require_inside`
|
||||
|
||||
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
|
||||
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
|
||||
add_keyboard_support('modal', '{"esc": "/close-modal"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
|
||||
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
|
||||
// Only fires when focus is inside #tree-panel
|
||||
add_keyboard_support('tree-panel', '{"esc": {"hx-post": "/cancel", "require_inside": true}}');
|
||||
|
||||
// Pressing ESC will trigger all 3 URLs simultaneously
|
||||
// Fires anywhere on the page
|
||||
add_keyboard_support('app', '{"ctrl+n": {"hx-post": "/new", "require_inside": false}}');
|
||||
```
|
||||
|
||||
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.).
|
||||
**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)
|
||||
|
||||
@@ -72,6 +100,57 @@ add_keyboard_support('elem2', '{"C D": "/url2"}');
|
||||
|
||||
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:
|
||||
@@ -232,6 +311,8 @@ The library automatically adds these parameters to every request:
|
||||
- `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', {
|
||||
@@ -334,16 +415,56 @@ remove_keyboard_support('modal');
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_keyboard_support(elementId, combinationsJson)
|
||||
### add_keyboard_support(elementId, controlDivId, combinationsJson)
|
||||
|
||||
Adds keyboard support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
- `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.
|
||||
|
||||
@@ -19,6 +19,13 @@ The mouse support library provides keyboard-like binding capabilities for mouse
|
||||
- `ctrl+shift+click` - Multiple modifiers
|
||||
- Any combination of modifiers
|
||||
|
||||
**Drag Actions**:
|
||||
- `mousedown>mouseup` - Left button drag (press, drag at least 5px, release)
|
||||
- `rmousedown>mouseup` - Right button drag
|
||||
- `ctrl+mousedown>mouseup` - Ctrl + left drag
|
||||
- `shift+mousedown>mouseup` - Shift + left drag
|
||||
- Any combination of modifiers
|
||||
|
||||
**Sequences**:
|
||||
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||
- `click click` - Double click sequence
|
||||
@@ -128,6 +135,127 @@ function getCellId(event) {
|
||||
}
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -237,20 +365,23 @@ mouse.add("right_click", context_menu_command)
|
||||
def add(self, sequence: str, command: Command = None, *,
|
||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||
hx_delete: str = None, hx_patch: str = None,
|
||||
hx_target: str = None, hx_swap: str = None, hx_vals=None)
|
||||
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", "click right_click")
|
||||
- `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
|
||||
- `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
|
||||
|
||||
@@ -275,6 +406,16 @@ mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="in
|
||||
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
|
||||
@@ -558,6 +699,43 @@ const combinations = {
|
||||
};
|
||||
```
|
||||
|
||||
### Range Selection with Visual Feedback
|
||||
|
||||
```python
|
||||
# Python: configure drag with live feedback
|
||||
mouse.add(
|
||||
"mousedown>mouseup",
|
||||
self.commands.on_mouse_selection(),
|
||||
hx_vals="js:getCellId()",
|
||||
on_move="js:highlightDragRange()"
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript: real-time highlight during drag
|
||||
function highlightDragRange(event, combination, mousedownResult) {
|
||||
const startCell = mousedownResult ? mousedownResult.cell_id : null;
|
||||
const endCell = event.target.closest('.dt2-cell');
|
||||
if (!startCell || !endCell) return;
|
||||
|
||||
document.querySelectorAll('.dt2-drag-preview')
|
||||
.forEach(el => el.classList.remove('dt2-drag-preview'));
|
||||
|
||||
applyRangeClass(startCell, endCell.id, 'dt2-drag-preview');
|
||||
// Server response will replace .dt2-drag-preview with final selection classes
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python: server handler receives both positions
|
||||
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
|
||||
if is_inside and cell_id_mousedown and cell_id_mouseup:
|
||||
pos_start = self._get_pos_from_element_id(cell_id_mousedown)
|
||||
pos_end = self._get_pos_from_element_id(cell_id_mouseup)
|
||||
self._state.selection.set_range(pos_start, pos_end)
|
||||
return self.render_partial()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Clicks not detected
|
||||
@@ -578,14 +756,29 @@ const combinations = {
|
||||
- Check if longer sequences exist (causes waiting)
|
||||
- Verify the combination string format (space-separated)
|
||||
|
||||
### Drag not triggering
|
||||
|
||||
- Ensure the mouse moved at least 5px before releasing
|
||||
- Verify `mousedown>mouseup` (not `mousedown_mouseup`) in the combination string
|
||||
- Check that `hx-vals-extra` function exists and is accessible via `window`
|
||||
|
||||
### `on_move` not called
|
||||
|
||||
- Verify `on_move` is only used with `mousedown>mouseup` sequences
|
||||
- Check that the function name matches exactly (case-sensitive)
|
||||
- Ensure the function is accessible via `window` (not inside a module scope)
|
||||
- Remember: `on_move` only fires after the 5px threshold — it won't fire on small movements
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||
- **Global listeners** on `document` for `click`, `contextmenu`, `mousedown`, `mouseup` events
|
||||
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||
- **Independent from keyboard support** (separate registry and timeouts)
|
||||
- **Drag detection**: temporary `mousemove` listener attached on `mousedown`, removed when 5px threshold exceeded
|
||||
- **`on_move` throttling**: `requestAnimationFrame` used internally — no manual throttling needed in user functions
|
||||
|
||||
### Performance
|
||||
|
||||
|
||||
@@ -941,4 +941,4 @@ The Panel component uses JavaScript for manual resizing:
|
||||
- Sends width updates to server via HTMX
|
||||
- Constrains width between 150px and 500px
|
||||
|
||||
**File:** `src/myfasthtml/assets/myfasthtml.js`
|
||||
**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`
|
||||
@@ -181,13 +181,13 @@ Users can rename nodes via the edit button:
|
||||
|
||||
```python
|
||||
# Programmatically start rename
|
||||
tree._start_rename("node-id")
|
||||
tree.handle_start_rename("node-id")
|
||||
|
||||
# Save rename
|
||||
tree._save_rename("node-id", "New Label")
|
||||
tree.handle_save_rename("node-id", "New Label")
|
||||
|
||||
# Cancel rename
|
||||
tree._cancel_rename()
|
||||
tree.handle_cancel_rename()
|
||||
```
|
||||
|
||||
### Deleting Nodes
|
||||
@@ -201,7 +201,7 @@ Users can delete nodes via the delete button:
|
||||
|
||||
```python
|
||||
# Programmatically delete node
|
||||
tree._delete_node("node-id") # Raises ValueError if node has children
|
||||
tree.handle_delete_node("node-id") # Raises ValueError if node has children
|
||||
```
|
||||
|
||||
## Content System
|
||||
@@ -449,18 +449,20 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||
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._select_node(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)}")
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -522,7 +522,254 @@ expected = Input(
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 5. Combining matches() and find()
|
||||
|
||||
### 5. Testing MyFastHtml Components with Test Helpers
|
||||
|
||||
**Goal**: Understand why test helpers exist and how they simplify testing MyFastHtml controls.
|
||||
|
||||
When testing components built with `mk` helpers (buttons, labels, icons), writing the expected pattern manually quickly becomes verbose. Consider testing a label with an icon:
|
||||
|
||||
```python
|
||||
# Without test helpers - verbose and fragile
|
||||
from fastcore.basics import NotStr
|
||||
from fasthtml.common import Span
|
||||
from myfasthtml.test.matcher import matches, Regex
|
||||
|
||||
actual = label_component.render()
|
||||
expected = Span(
|
||||
Span(NotStr('<svg name="fluent-Info"')),
|
||||
Span("My Label")
|
||||
)
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
MyFastHtml provides **test helpers** — specialized `TestObject` subclasses that encapsulate these patterns. They know how `mk` renders components and abstract away the implementation details:
|
||||
|
||||
```python
|
||||
# With test helpers - concise and readable
|
||||
from myfasthtml.test.matcher import matches, TestLabel
|
||||
|
||||
actual = label_component.render()
|
||||
matches(actual, TestLabel("My Label", icon="info"))
|
||||
```
|
||||
|
||||
`TestObject` is the base class for all these helpers. You can also create your own helpers for custom components by subclassing it.
|
||||
|
||||
**TestObject constructor:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `cls` | type or str | The element type to match (e.g., `"div"`, `"span"`, `NotStr`) |
|
||||
| `**kwargs` | any | Attributes to match on the element |
|
||||
|
||||
**Creating a custom helper:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import TestObject, Contains
|
||||
from fasthtml.common import Div, H2
|
||||
|
||||
class TestCard(TestObject):
|
||||
def __init__(self, title: str):
|
||||
super().__init__("div")
|
||||
self.attrs["cls"] = Contains("card")
|
||||
self.children = [
|
||||
Div(H2(title), cls="card-header")
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Testing Labels with TestLabel
|
||||
|
||||
**Goal**: Verify elements produced by `mk.label()` — text with an optional icon and optional command.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import TestLabel
|
||||
```
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|-----------|------|-------------|---------|
|
||||
| `label` | str | The text content to match | - |
|
||||
| `icon` | str | Icon name (`snake_case` or `PascalCase`) | `None` |
|
||||
| `command` | Command | Command whose HTMX params to verify | `None` |
|
||||
|
||||
**Example 1: Label with text only**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.test.matcher import matches, TestLabel
|
||||
|
||||
actual = mk.label("Settings")
|
||||
matches(actual, TestLabel("Settings"))
|
||||
```
|
||||
|
||||
**Example 2: Label with icon**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.test.matcher import matches, TestLabel
|
||||
|
||||
actual = mk.label("Settings", icon="settings")
|
||||
matches(actual, TestLabel("Settings", icon="settings"))
|
||||
```
|
||||
|
||||
**Note:** Icon names can be passed in `snake_case` or `PascalCase` — `TestLabel` handles the conversion automatically.
|
||||
|
||||
**Example 3: Label with command**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.test.matcher import matches, TestLabel
|
||||
|
||||
def save():
|
||||
return "Saved"
|
||||
|
||||
save_cmd = Command("save", "Save document", save)
|
||||
actual = mk.label("Save", command=save_cmd)
|
||||
|
||||
matches(actual, TestLabel("Save", command=save_cmd))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Testing Icons with TestIcon and TestIconNotStr
|
||||
|
||||
**Goal**: Verify icon elements produced by `mk.icon()`.
|
||||
|
||||
MyFastHtml renders icons in two ways depending on context:
|
||||
- **With a wrapper** (`div` or `span`): use `TestIcon`
|
||||
- **As a raw SVG `NotStr`** without wrapper: use `TestIconNotStr`
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import TestIcon, TestIconNotStr
|
||||
```
|
||||
|
||||
#### TestIcon — Icon with wrapper
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|-----------|------|-------------|---------|
|
||||
| `name` | str | Icon name (`snake_case` or `PascalCase`) | `''` |
|
||||
| `wrapper` | str | Wrapper element: `"div"` or `"span"` | `"div"` |
|
||||
| `command` | Command | Command whose HTMX params to verify | `None` |
|
||||
|
||||
**Example 1: Simple icon in a div**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.test.matcher import matches, TestIcon
|
||||
|
||||
actual = mk.icon(info_svg)
|
||||
matches(actual, TestIcon("info"))
|
||||
```
|
||||
|
||||
**Example 2: Icon in a span with command**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.test.matcher import matches, TestIcon
|
||||
|
||||
delete_cmd = Command("delete", "Delete item", lambda: None)
|
||||
actual = mk.icon(trash_svg, command=delete_cmd)
|
||||
|
||||
matches(actual, TestIcon("trash", wrapper="span", command=delete_cmd))
|
||||
```
|
||||
|
||||
#### TestIconNotStr — Raw SVG without wrapper
|
||||
|
||||
Use `TestIconNotStr` when the icon appears directly as a `NotStr` in the element tree (e.g., embedded inside another element without its own wrapper).
|
||||
|
||||
| Parameter | Type | Description | Default |
|
||||
|-----------|------|-------------|---------|
|
||||
| `name` | str | Icon name (`snake_case` or `PascalCase`) | `''` |
|
||||
|
||||
**Example: Raw SVG icon embedded in a button**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, TestIconNotStr
|
||||
|
||||
actual = button_component.render()
|
||||
|
||||
icons = find(actual, TestIconNotStr("info"))
|
||||
assert len(icons) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Testing Commands with TestCommand
|
||||
|
||||
**Goal**: Verify that an element is linked to a specific command.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import TestCommand
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | str | The command name to match |
|
||||
| `**kwargs` | any | Additional command attributes to verify |
|
||||
|
||||
**Example 1: Verify a button is bound to a command**
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.test.matcher import find, TestCommand
|
||||
|
||||
delete_cmd = Command("delete_row", "Delete row", lambda: None)
|
||||
button = mk.button("Delete", command=delete_cmd)
|
||||
|
||||
commands = find(button, TestCommand("delete_row"))
|
||||
assert len(commands) == 1
|
||||
```
|
||||
|
||||
**Example 2: Verify command with additional attributes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, TestCommand
|
||||
|
||||
commands = find(component.render(), TestCommand("save", target="#result"))
|
||||
assert len(commands) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Testing Scripts with TestScript
|
||||
|
||||
**Goal**: Verify the content of `<script>` elements injected by a component.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import TestScript
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `script` | str | The expected script content (checked with `startswith`) |
|
||||
|
||||
**Example 1: Verify a script is present**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, TestScript
|
||||
|
||||
actual = widget.render()
|
||||
|
||||
scripts = find(actual, TestScript("initWidget("))
|
||||
assert len(scripts) == 1
|
||||
```
|
||||
|
||||
**Example 2: Verify a specific script content**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, TestScript
|
||||
|
||||
scripts = find(actual, TestScript("document.getElementById('my-component')"))
|
||||
assert len(scripts) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Combining matches() and find()
|
||||
|
||||
**Goal**: First find elements, then validate them in detail.
|
||||
|
||||
@@ -574,7 +821,7 @@ for card in cards:
|
||||
matches(card, expected_card)
|
||||
```
|
||||
|
||||
### 6. Testing Edge Cases
|
||||
### 11. Testing Edge Cases
|
||||
|
||||
**Testing empty elements:**
|
||||
|
||||
|
||||
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>
|
||||
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>
|
||||
@@ -159,6 +159,14 @@
|
||||
<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>
|
||||
@@ -197,6 +205,14 @@
|
||||
</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"
|
||||
@@ -346,10 +362,48 @@
|
||||
|
||||
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>
|
||||
@@ -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 = [
|
||||
@@ -74,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"
|
||||
]
|
||||
83
src/app.py
83
src/app.py
@@ -1,27 +1,26 @@
|
||||
import json
|
||||
import logging.config
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from dbengine.handlers import BaseRefHandler, handlers
|
||||
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.Profiler import Profiler
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.dbengine_utils import DataFrameHandler
|
||||
from myfasthtml.core.instances import UniqueInstance
|
||||
from myfasthtml.icons.carbon import volume_object_storage
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_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:
|
||||
@@ -39,53 +38,6 @@ app, rt = create_app(protect_routes=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
|
||||
Args:
|
||||
parent: Parent instance for the TreeView
|
||||
|
||||
Returns:
|
||||
TreeView: Configured TreeView instance with sample data
|
||||
"""
|
||||
tree_view = TreeView(parent, _id="-treeview")
|
||||
|
||||
# Create sample file structure
|
||||
projects = TreeNode(label="Projects", type="folder")
|
||||
tree_view.add_node(projects)
|
||||
|
||||
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
|
||||
tree_view.add_node(myfasthtml, parent_id=projects.id)
|
||||
|
||||
app_py = TreeNode(label="app.py", type="file")
|
||||
tree_view.add_node(app_py, parent_id=myfasthtml.id)
|
||||
|
||||
readme = TreeNode(label="README.md", type="file")
|
||||
tree_view.add_node(readme, parent_id=myfasthtml.id)
|
||||
|
||||
src_folder = TreeNode(label="src", type="folder")
|
||||
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
|
||||
|
||||
controls_py = TreeNode(label="controls.py", type="file")
|
||||
tree_view.add_node(controls_py, parent_id=src_folder.id)
|
||||
|
||||
documents = TreeNode(label="Documents", type="folder")
|
||||
tree_view.add_node(documents, parent_id=projects.id)
|
||||
|
||||
notes = TreeNode(label="notes.txt", type="file")
|
||||
tree_view.add_node(notes, parent_id=documents.id)
|
||||
|
||||
todo = TreeNode(label="todo.md", type="file")
|
||||
tree_view.add_node(todo, parent_id=documents.id)
|
||||
|
||||
# Expand all nodes to show the full structure
|
||||
# tree_view.expand_all()
|
||||
|
||||
return tree_view
|
||||
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session,
|
||||
@@ -104,13 +56,19 @@ def index(session):
|
||||
btn_show_instances_debugger = mk.label("Instances",
|
||||
icon=volume_object_storage,
|
||||
command=add_tab("Instances", instances_debugger),
|
||||
id=instances_debugger.get_id())
|
||||
id=f"l_{instances_debugger.get_id()}")
|
||||
|
||||
commands_debugger = CommandsDebugger(layout)
|
||||
btn_show_commands_debugger = mk.label("Commands",
|
||||
icon=key_command16_regular,
|
||||
command=add_tab("Commands", commands_debugger),
|
||||
id=commands_debugger.get_id())
|
||||
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,
|
||||
@@ -120,23 +78,30 @@ def index(session):
|
||||
btn_popup = mk.label("Popup",
|
||||
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
|
||||
|
||||
# Create TreeView with sample data
|
||||
tree_view = create_sample_treeview(layout)
|
||||
|
||||
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")
|
||||
layout.left_drawer.add(tree_view, "TreeView")
|
||||
|
||||
# data grids
|
||||
dgs_manager = DataGridsManager(layout, _id="-datagrids")
|
||||
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
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Commands used
|
||||
|
||||
```
|
||||
cd src/myfasthtml/assets
|
||||
|
||||
# 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
|
||||
@@ -12,4 +11,8 @@ wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/pla
|
||||
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
|
||||
```
|
||||
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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
270
src/myfasthtml/assets/core/layout.css
Normal file
270
src/myfasthtml/assets/core/layout.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* MF Layout Component - CSS Grid Layout
|
||||
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
/* Main layout container using CSS Grid */
|
||||
.mf-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"left-drawer main right-drawer"
|
||||
"footer footer footer";
|
||||
grid-template-rows: 32px 1fr 32px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header - fixed at top */
|
||||
.mf-layout-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* put one item on each side */
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-base-300);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Footer - fixed at bottom */
|
||||
.mf-layout-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-neutral);
|
||||
color: var(--color-neutral-content);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Main content area - scrollable */
|
||||
.mf-layout-main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Drawer base styles */
|
||||
.mf-layout-drawer {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-base-100);
|
||||
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
|
||||
width: 250px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Left drawer */
|
||||
.mf-layout-left-drawer {
|
||||
grid-area: left-drawer;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right drawer */
|
||||
.mf-layout-right-drawer {
|
||||
grid-area: right-drawer;
|
||||
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Collapsed drawer states */
|
||||
.mf-layout-drawer.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toggle buttons positioning */
|
||||
.mf-layout-toggle-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mf-layout-toggle-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar styling for webkit browsers */
|
||||
.mf-layout-main::-webkit-scrollbar,
|
||||
.mf-layout-drawer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-track,
|
||||
.mf-layout-drawer::-webkit-scrollbar-track {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb:hover,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.mf-layout-drawer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mf-layout-header,
|
||||
.mf-layout-footer {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.mf-layout-main {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle layouts with no drawers */
|
||||
.mf-layout[data-left-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main right-drawer"
|
||||
"footer footer";
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.mf-layout[data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"left-drawer main"
|
||||
"footer footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"footer";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
* Styles for the resizable drawer borders with visual feedback
|
||||
*/
|
||||
|
||||
/* Ensure drawer has relative positioning and no overflow */
|
||||
.mf-layout-drawer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content wrapper handles scrolling */
|
||||
.mf-layout-drawer-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Base resizer styles */
|
||||
.mf-layout-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Resizer on the right side (for left drawer) */
|
||||
.mf-layout-resizer-right {
|
||||
right: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Resizer on the left side (for right drawer) */
|
||||
.mf-layout-resizer-left {
|
||||
left: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.mf-layout-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-layout-drawer-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-layout-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-layout-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-layout-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-right::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-left::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer:hover::before,
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.mf-layout-group {
|
||||
font-weight: bold;
|
||||
/*font-size: var(--text-sm);*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
4
src/myfasthtml/assets/core/layout.js
Normal file
4
src/myfasthtml/assets/core/layout.js
Normal file
@@ -0,0 +1,4 @@
|
||||
function initLayout(elementId) {
|
||||
initResizer(elementId);
|
||||
bindTooltipsWithDelegation(elementId);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
340
src/myfasthtml/assets/core/profiler.css
Normal file
340
src/myfasthtml/assets/core/profiler.css
Normal file
@@ -0,0 +1,340 @@
|
||||
/* ================================================================== */
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Detail panel — right side
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-cmd {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-view-btn-active {
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Span tree — inside a Properties group card
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 120px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name-root {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: color-mix(in oklab, var(--color-border) 80%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-medium {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-slow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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',
|
||||
|
||||
@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
|
||||
Keep the boundaries updated
|
||||
"""
|
||||
|
||||
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(owner, _id=_id)
|
||||
self._owner = owner
|
||||
self._container_id = container_id or owner.get_id()
|
||||
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._owner = parent
|
||||
self._container_id = container_id or parent.get_id()
|
||||
self._on_resize = on_resize
|
||||
self._commands = Commands(self)
|
||||
self._state = BoundariesState()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
@@ -6,6 +8,7 @@ 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):
|
||||
@@ -24,7 +27,7 @@ class Commands(BaseCommands):
|
||||
|
||||
class CycleStateControl(MultipleInstance):
|
||||
def __init__(self, parent, controls: dict, _id=None, save_state=True):
|
||||
super().__init__(parent, _id)
|
||||
super().__init__(parent, _id=_id)
|
||||
self._state = CycleState(self, save_state)
|
||||
self.controls_by_states = controls
|
||||
self.commands = Commands(self)
|
||||
@@ -34,6 +37,7 @@ class CycleStateControl(MultipleInstance):
|
||||
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)]
|
||||
|
||||
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()
|
||||
@@ -3,11 +3,17 @@ 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 icons, mk
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
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
|
||||
|
||||
@@ -33,36 +39,83 @@ class Commands(BaseCommands):
|
||||
return Command(f"ShowAllColumns",
|
||||
f"Show all columns",
|
||||
self._owner,
|
||||
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
self._owner.handle_show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def update_column(self, col_id):
|
||||
return Command(f"UpdateColumn",
|
||||
f"Update column {col_id}",
|
||||
def save_column_details(self, col_id):
|
||||
return Command(f"SaveColumnDetails",
|
||||
f"Save column {col_id}",
|
||||
self._owner,
|
||||
self._owner.update_column,
|
||||
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):
|
||||
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
|
||||
else:
|
||||
return cols_defs[0]
|
||||
|
||||
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)
|
||||
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")
|
||||
@@ -73,39 +126,53 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
|
||||
def show_column_details(self, col_id):
|
||||
logger.debug(f"show_column_details {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
col_def = self._get_updated_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
return Div(f"Column '{col_id}' not found")
|
||||
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
def show_all_columns(self):
|
||||
return self.mk_all_columns()
|
||||
def handle_show_all_columns(self):
|
||||
self.adding_new_column = False
|
||||
return self._mk_inner_content() if self._all_columns else None
|
||||
|
||||
def update_column(self, col_id, client_response):
|
||||
logger.debug(f"update_column {col_id=}, {client_response=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
else:
|
||||
for k, v in client_response.items():
|
||||
if not hasattr(col_def, k):
|
||||
continue
|
||||
|
||||
if k == "visible":
|
||||
col_def.visible = v == "on"
|
||||
elif k == "type":
|
||||
col_def.type = ColumnType(v)
|
||||
elif k == "width":
|
||||
col_def.width = int(v)
|
||||
else:
|
||||
setattr(col_def, k, v)
|
||||
|
||||
# save the new values
|
||||
self._parent.save_state()
|
||||
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_all_columns()
|
||||
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(
|
||||
@@ -115,7 +182,7 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
),
|
||||
mk.mk(
|
||||
Div(
|
||||
Div(mk.label(col_def.col_id, icon=icons.get(col_def.type, None), cls="ml-2")),
|
||||
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"
|
||||
),
|
||||
@@ -127,6 +194,7 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
|
||||
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(
|
||||
@@ -137,6 +205,28 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
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"),
|
||||
@@ -155,23 +245,10 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
cls="flex",
|
||||
),
|
||||
|
||||
Label("Title"),
|
||||
Input(name="title",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.title),
|
||||
|
||||
Label("type"),
|
||||
Select(
|
||||
*[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType],
|
||||
name="type",
|
||||
cls=f"select select-{size}",
|
||||
value=col_def.title,
|
||||
),
|
||||
|
||||
legend="Column details",
|
||||
cls="fieldset border-base-300 rounded-box"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
|
||||
mk.dialog_buttons(on_ok=self.commands.save_column_details(col_def.col_id),
|
||||
on_cancel=self.commands.show_all_columns()),
|
||||
cls="mb-1",
|
||||
),
|
||||
@@ -186,9 +263,23 @@ class DataGridColumnsManager(MultipleInstance):
|
||||
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_all_columns(),
|
||||
*self._mk_inner_content(),
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -108,10 +109,11 @@ class DataGridFormattingEditor(DslEditor):
|
||||
logger.warning(f"Column '{column_name}' not found, skipping rules")
|
||||
|
||||
for row_index, rules in rows_rules.items():
|
||||
if row_index < len(state.rows):
|
||||
state.rows[row_index].format = rules
|
||||
else:
|
||||
logger.warning(f"Row {row_index} out of range, skipping rules")
|
||||
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
|
||||
@@ -125,7 +127,7 @@ class DataGridFormattingEditor(DslEditor):
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
|
||||
if manager:
|
||||
manager.all_tables_formats = tables_rules
|
||||
manager.get_state().all_tables_formats = tables_rules
|
||||
|
||||
# Step 6: Update state atomically
|
||||
self._parent.get_state().update(state)
|
||||
|
||||
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")
|
||||
@@ -12,7 +12,7 @@ from myfasthtml.icons.fluent import brain_circuit20_regular
|
||||
from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
|
||||
from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
|
||||
|
||||
logger = logging.getLogger("DataGridFilter")
|
||||
logger = logging.getLogger("DataGridQuery")
|
||||
|
||||
DG_QUERY_FILTER = "filter"
|
||||
DG_QUERY_SEARCH = "search"
|
||||
|
||||
@@ -9,13 +9,13 @@ from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.TreeView import TreeView, 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.dsl.completion.provider import DatagridMetadataProvider
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||
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
|
||||
@@ -32,10 +32,11 @@ class DocumentDefinition:
|
||||
|
||||
|
||||
class DataGridsState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
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):
|
||||
@@ -49,7 +50,7 @@ class Commands(BaseCommands):
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.new_grid)
|
||||
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",
|
||||
@@ -71,26 +72,117 @@ class Commands(BaseCommands):
|
||||
self._owner,
|
||||
self._owner.select_document,
|
||||
key="SelectNode")
|
||||
|
||||
|
||||
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
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):
|
||||
# Skip __init__ if instance already existed
|
||||
return
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridsState(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)
|
||||
|
||||
# Global presets shared across all DataGrids
|
||||
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
||||
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
||||
self.all_tables_formats: list = []
|
||||
# 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)
|
||||
@@ -98,58 +190,112 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
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_conf = DatagridConf(namespace=namespace, name=name)
|
||||
dg = DataGrid(self, conf=dg_conf, save_state=True) # first time the Datagrid is created
|
||||
dg.init_from_dataframe(df)
|
||||
self._registry.put(namespace, name, dg.get_id())
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=dg.get_id()
|
||||
)
|
||||
self._state.elements = self._state.elements + [document] # do not use append() other it won't be saved
|
||||
|
||||
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)
|
||||
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||
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) # reload the state & settings
|
||||
dg = DataGrid(self, _id=document.datagrid_id)
|
||||
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
|
||||
except StopIteration:
|
||||
# the selected node is not a document (it's a folder)
|
||||
return None
|
||||
|
||||
def create_tab_content(self, tab_id):
|
||||
"""
|
||||
Recreate the content for a tab managed by this DataGridsManager.
|
||||
Called by TabsManager when the content is not in cache (e.g., after restart).
|
||||
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:
|
||||
tab_id: ID of the tab to recreate content for
|
||||
node_id: ID of the TreeView node to delete.
|
||||
|
||||
Returns:
|
||||
The recreated component (Panel with DataGrid)
|
||||
List of UI updates, or None.
|
||||
"""
|
||||
# Find the document associated with this tab
|
||||
document = next((d for d in self._state.elements if d.tab_id == tab_id), 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}")
|
||||
|
||||
# Recreate the DataGrid with its saved state
|
||||
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
|
||||
dg = DataGrid(self, _id=document.datagrid_id)
|
||||
return dg
|
||||
|
||||
def clear_tree(self):
|
||||
@@ -157,79 +303,25 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||
self._tree.clear()
|
||||
return self._tree
|
||||
|
||||
# === DatagridMetadataProvider ===
|
||||
|
||||
def list_tables(self):
|
||||
return self._registry.get_all_tables()
|
||||
|
||||
def list_columns(self, table_name):
|
||||
return self._registry.get_columns(table_name)
|
||||
|
||||
def list_column_values(self, table_name, column_name):
|
||||
return self._registry.get_column_values(table_name, column_name)
|
||||
|
||||
def get_row_count(self, table_name):
|
||||
return self._registry.get_row_count(table_name)
|
||||
|
||||
def list_style_presets(self) -> list[str]:
|
||||
return list(self.style_presets.keys())
|
||||
|
||||
def list_format_presets(self) -> list[str]:
|
||||
return list(self.formatter_presets.keys())
|
||||
|
||||
# === Presets Management ===
|
||||
|
||||
def get_style_presets(self) -> dict:
|
||||
"""Get the global style presets."""
|
||||
return self.style_presets
|
||||
|
||||
def get_formatter_presets(self) -> dict:
|
||||
"""Get the global formatter presets."""
|
||||
return self.formatter_presets
|
||||
|
||||
def add_style_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a style preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_highlight")
|
||||
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
|
||||
"""
|
||||
self.style_presets[name] = preset
|
||||
|
||||
def add_formatter_preset(self, name: str, preset: dict):
|
||||
"""
|
||||
Add or update a formatter preset.
|
||||
|
||||
Args:
|
||||
name: Preset name (e.g., "custom_currency")
|
||||
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||
"""
|
||||
self.formatter_presets[name] = preset
|
||||
|
||||
def remove_style_preset(self, name: str):
|
||||
"""Remove a style preset."""
|
||||
if name in self.style_presets:
|
||||
del self.style_presets[name]
|
||||
|
||||
def remove_formatter_preset(self, name: str):
|
||||
"""Remove a formatter preset."""
|
||||
if name in self.formatter_presets:
|
||||
del self.formatter_presets[name]
|
||||
|
||||
# === UI ===
|
||||
# ------------------------------------------------------------------
|
||||
# UI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def mk_main_icons(self):
|
||||
return Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()),
|
||||
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):
|
||||
tree = TreeView(self, _id="-treeview")
|
||||
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)
|
||||
parent_id = tree.ensure_path(element.namespace, node_type="folder")
|
||||
tree.add_node(TreeNode(id=element.document_id,
|
||||
label=element.name,
|
||||
type=element.type,
|
||||
|
||||
@@ -97,7 +97,7 @@ class Dropdown(MultipleInstance):
|
||||
self._mk_content(),
|
||||
cls="mf-dropdown-wrapper"
|
||||
),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
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
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ class DslEditorConf:
|
||||
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):
|
||||
@@ -166,7 +167,7 @@ class DslEditor(MultipleInstance):
|
||||
return Textarea(
|
||||
self._state.content,
|
||||
id=f"ta_{self._id}",
|
||||
name=f"ta_{self._id}",
|
||||
name=self.conf.name if (self.conf and self.conf.name) else f"ta_{self._id}",
|
||||
cls="hidden",
|
||||
)
|
||||
|
||||
@@ -184,6 +185,8 @@ class DslEditor(MultipleInstance):
|
||||
return Script(f"initDslEditor({config_json});")
|
||||
|
||||
def _mk_auto_save(self):
|
||||
if not self.conf.save_button:
|
||||
return None
|
||||
return Div(
|
||||
Label(
|
||||
mk.mk(
|
||||
|
||||
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()
|
||||
97
src/myfasthtml/controls/IconsHelper.py
Normal file
97
src/myfasthtml/controls/IconsHelper.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from fastcore.basics import NotStr
|
||||
|
||||
from myfasthtml.core.constants import ColumnType, MediaActions
|
||||
from myfasthtml.core.utils import pascal_to_snake
|
||||
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
|
||||
number_row20_regular
|
||||
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
||||
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular, pause_circle20_regular
|
||||
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
|
||||
dismiss_circle20_regular
|
||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
|
||||
|
||||
default_icons = {
|
||||
# default type icons
|
||||
None: question20_regular,
|
||||
True: checkbox_checked20_regular,
|
||||
False: checkbox_unchecked20_regular,
|
||||
|
||||
"Brain": brain_circuit20_regular,
|
||||
"QuestionMark": question20_regular,
|
||||
|
||||
# TreeView icons
|
||||
"TreeViewFolder": folder20_regular,
|
||||
"TreeViewFile": document20_regular,
|
||||
|
||||
# Media
|
||||
MediaActions.Play: play_circle20_regular,
|
||||
MediaActions.Pause: pause_circle20_regular,
|
||||
MediaActions.Stop: record_stop20_regular,
|
||||
MediaActions.Cancel: dismiss_circle20_regular,
|
||||
|
||||
# Datagrid column icons
|
||||
ColumnType.RowIndex: number_symbol20_regular,
|
||||
ColumnType.Text: text_field20_regular,
|
||||
ColumnType.Number: number_row20_regular,
|
||||
ColumnType.Datetime: calendar_ltr20_regular,
|
||||
ColumnType.Bool: checkbox_checked20_filled,
|
||||
ColumnType.Enum: text_bullet_list_square20_regular,
|
||||
ColumnType.Formula: math_formula16_regular,
|
||||
}
|
||||
|
||||
|
||||
class IconsHelper:
|
||||
_icons = default_icons.copy()
|
||||
|
||||
@staticmethod
|
||||
def get(name, package=None):
|
||||
"""
|
||||
Fetches and returns an icon resource based on the provided name and optional package. If the icon is not already
|
||||
cached, it will attempt to dynamically load the icon from the available modules under the `myfasthtml.icons` package.
|
||||
This method uses an internal caching mechanism to store previously fetched icons for future quick lookups.
|
||||
|
||||
:param name: The name of the requested icon.
|
||||
:param package: The optional sub-package to limit the search for the requested icon. If not provided, the method will
|
||||
iterate through all available modules within the `myfasthtml.icons` package.
|
||||
:return: The requested icon resource if found; otherwise, returns None.
|
||||
:rtype: object or None
|
||||
"""
|
||||
if isinstance(name, NotStr):
|
||||
return name
|
||||
|
||||
if name in IconsHelper._icons:
|
||||
return IconsHelper._icons[name]
|
||||
|
||||
if not isinstance(name, str):
|
||||
return question20_regular
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
import myfasthtml.icons as icons_pkg
|
||||
|
||||
_UTILITY_MODULES = {'manage_icons', 'update_icons'}
|
||||
|
||||
if name[0].isupper():
|
||||
name = pascal_to_snake(name)
|
||||
|
||||
if package:
|
||||
module = importlib.import_module(f"myfasthtml.icons.{package}")
|
||||
icon = getattr(module, name, None)
|
||||
if icon is not None:
|
||||
IconsHelper._icons[name] = icon
|
||||
return icon
|
||||
|
||||
for _, modname, _ in pkgutil.iter_modules(icons_pkg.__path__):
|
||||
if modname in _UTILITY_MODULES:
|
||||
continue
|
||||
module = importlib.import_module(f"myfasthtml.icons.{modname}")
|
||||
icon = getattr(module, name, None)
|
||||
if icon is not None:
|
||||
IconsHelper._icons[name] = icon
|
||||
return icon
|
||||
|
||||
return question20_regular
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
IconsHelper._icons = default_icons.copy()
|
||||
@@ -1,55 +1,195 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstancesDebuggerConf:
|
||||
"""Configuration for InstancesDebugger control.
|
||||
|
||||
Attributes:
|
||||
group_siblings_by_type: If True, sibling nodes (same parent) are grouped
|
||||
by their type for easier visual identification.
|
||||
Useful for detecting memory leaks. Default: True.
|
||||
"""
|
||||
group_siblings_by_type: bool = True
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
def __init__(self, parent, conf: InstancesDebuggerConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf if conf is not None else InstancesDebuggerConf()
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
self._canvas_graph = None # Will be created in render
|
||||
self._select_command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
graph_conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"select_node": self._select_command
|
||||
}
|
||||
)
|
||||
self._canvas_graph = HierarchicalCanvasGraph(self, conf=graph_conf, _id="-canvas-graph")
|
||||
|
||||
main_content = Div(
|
||||
self._canvas_graph,
|
||||
cls="flex flex-col h-full"
|
||||
)
|
||||
|
||||
return self._panel.set_main(main_content)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
parts = event_data["nodes"][0].split("#")
|
||||
def on_select_node(self, node_id=None, label=None, kind=None, type=None):
|
||||
"""Handle node selection event from canvas graph.
|
||||
|
||||
Args:
|
||||
node_id: Selected node's full ID (session#instance_id)
|
||||
label: Selected node's label
|
||||
kind: Selected node's kind (root|single|unique|multiple)
|
||||
type: Selected node's type (class name)
|
||||
"""
|
||||
if not node_id:
|
||||
return None
|
||||
|
||||
# Parse full ID (session#instance_id)
|
||||
parts = node_id.split("#")
|
||||
session = parts[0]
|
||||
instance_id = "#".join(parts[1:])
|
||||
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
|
||||
properties_def = {
|
||||
"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
return self._panel.set_right(Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
|
||||
_id="-properties",
|
||||
))
|
||||
|
||||
def _get_instance_kind(self, instance) -> str:
|
||||
"""Determine the instance kind for visualization.
|
||||
|
||||
Args:
|
||||
instance: The instance object
|
||||
|
||||
Returns:
|
||||
str: One of 'root', 'single', 'unique', 'multiple'
|
||||
"""
|
||||
# Check if it's the RootInstance (special singleton)
|
||||
if instance.get_parent() is None and instance.get_id() == "mf":
|
||||
return 'root'
|
||||
elif isinstance(instance, SingleInstance):
|
||||
return 'single'
|
||||
elif isinstance(instance, UniqueInstance):
|
||||
return 'unique'
|
||||
elif isinstance(instance, MultipleInstance):
|
||||
return 'multiple'
|
||||
else:
|
||||
return 'multiple' # Default
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
"""Build nodes and edges from current instances.
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where nodes include id, label, kind, type
|
||||
"""
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
instances,
|
||||
id_getter=lambda x: x.get_full_id(),
|
||||
label_getter=lambda x: f"{x.get_id()}",
|
||||
parent_getter=lambda x: x.get_parent_full_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
nodes = []
|
||||
edges = []
|
||||
existing_ids = set()
|
||||
|
||||
# Create nodes with kind (instance kind) and type (class name)
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
existing_ids.add(node_id)
|
||||
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": instance.get_id(),
|
||||
"kind": self._get_instance_kind(instance),
|
||||
"type": instance.__class__.__name__,
|
||||
"description": instance.get_description()
|
||||
})
|
||||
|
||||
# Track nodes with parents
|
||||
nodes_with_parent = set()
|
||||
|
||||
# Create edges
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
parent_id = instance.get_parent_full_id()
|
||||
|
||||
if parent_id is None or parent_id == "":
|
||||
continue
|
||||
|
||||
nodes_with_parent.add(node_id)
|
||||
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Create ghost node if parent not in existing instances
|
||||
if parent_id not in existing_ids:
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": f"Ghost: {parent_id}",
|
||||
"kind": "multiple", # Default kind for ghost nodes
|
||||
"type": "Ghost"
|
||||
})
|
||||
existing_ids.add(parent_id)
|
||||
|
||||
# Group siblings by type if configured
|
||||
if self.conf.group_siblings_by_type:
|
||||
edges = self._sort_edges_by_sibling_type(nodes, edges)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
def _sort_edges_by_sibling_type(self, nodes, edges):
|
||||
"""Sort edges so that siblings (same parent) are grouped by type.
|
||||
|
||||
Args:
|
||||
nodes: List of node dictionaries
|
||||
edges: List of edge dictionaries
|
||||
|
||||
Returns:
|
||||
list: Sorted edges with siblings grouped by type
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
# Create mapping node_id -> type for quick lookup
|
||||
node_types = {node["id"]: node["type"] for node in nodes}
|
||||
|
||||
# Group edges by parent
|
||||
edges_by_parent = defaultdict(list)
|
||||
for edge in edges:
|
||||
edges_by_parent[edge["from"]].append(edge)
|
||||
|
||||
# Sort each parent's children by type and rebuild edges list
|
||||
sorted_edges = []
|
||||
for parent_id in edges_by_parent:
|
||||
parent_edges = sorted(
|
||||
edges_by_parent[parent_id],
|
||||
key=lambda e: node_types.get(e["to"], "")
|
||||
)
|
||||
sorted_edges.extend(parent_edges)
|
||||
|
||||
return sorted_edges
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.core.utils import make_html_id
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
@@ -16,14 +18,38 @@ class Keyboard(MultipleInstance):
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: Command):
|
||||
self.combinations[sequence] = command
|
||||
|
||||
def add(self, sequence: str, command: Command, require_inside: bool = True, enabled: bool = True):
|
||||
self.combinations[sequence] = {"command": command, "require_inside": require_inside, "enabled": enabled}
|
||||
return self
|
||||
|
||||
|
||||
def render(self):
|
||||
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
str_combinations = {}
|
||||
control_children = []
|
||||
for sequence, value in self.combinations.items():
|
||||
params = value["command"].get_htmx_params()
|
||||
params["require_inside"] = value.get("require_inside", True)
|
||||
str_combinations[sequence] = params
|
||||
control_children.append(
|
||||
Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
|
||||
data_combination=sequence,
|
||||
data_enabled="true" if value.get("enabled", True) else "false")
|
||||
)
|
||||
script = Script(f"add_keyboard_support('{self._parent.get_id()}', '{self.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
control_div = Div(*control_children, id=self.get_id(), name="keyboard")
|
||||
return script, control_div
|
||||
|
||||
def mk_enable(self, sequence: str):
|
||||
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
|
||||
data_combination=sequence,
|
||||
data_enabled="true",
|
||||
hx_swap_oob="true")
|
||||
|
||||
def mk_disable(self, sequence: str):
|
||||
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
|
||||
data_combination=sequence,
|
||||
data_enabled="false",
|
||||
hx_swap_oob="true")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
70
src/myfasthtml/controls/Menu.py
Normal file
70
src/myfasthtml/controls/Menu.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuConf:
|
||||
fixed_items: list = field(default_factory=list)
|
||||
|
||||
|
||||
class MenuState(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
with self.initializing():
|
||||
super().__init__(owner, save_state=save_state)
|
||||
self.last_used: Optional[list] = None
|
||||
|
||||
|
||||
class Menu(MultipleInstance):
|
||||
def __init__(self, parent, conf=None, save_state=True, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or MenuConf()
|
||||
self._state = MenuState(self, save_state=save_state)
|
||||
self.usable_commands = self._get_parent_commands()
|
||||
|
||||
def _get_parent_commands(self):
|
||||
commands_obj = self._parent.commands
|
||||
|
||||
callables = [
|
||||
name
|
||||
for name in dir(commands_obj)
|
||||
if not name.startswith("_")
|
||||
and callable(attr := getattr(commands_obj, name))
|
||||
and len(inspect.signature(attr).parameters) == 0
|
||||
]
|
||||
|
||||
return {
|
||||
c.name: c for c in [getattr(commands_obj, name)() for name in callables]
|
||||
}
|
||||
|
||||
def _mk_menu(self, command_name):
|
||||
if not isinstance(command_name, str):
|
||||
return command_name
|
||||
|
||||
command = self.usable_commands.get(command_name)
|
||||
return mk.icon(command.icon or IconsHelper.get("QuestionMark"),
|
||||
command=command,
|
||||
tooltip=command.description)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
Div(
|
||||
*[self._mk_menu(command_name) for command_name in self.conf.fixed_items],
|
||||
*(
|
||||
Div("|"),
|
||||
*[self._mk_menu(command_name) for command_name in self._state.last_used[:3]]
|
||||
) if self._state.last_used else [],
|
||||
cls="flex mb-1"
|
||||
),
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -19,20 +19,77 @@ class Mouse(MultipleInstance):
|
||||
- Both (named params override command): mouse.add("click", command, hx_target="#other")
|
||||
|
||||
For dynamic hx_vals, use "js:functionName()" to call a client-side function.
|
||||
|
||||
Supported base actions:
|
||||
- ``click`` - Left mouse click (detected globally)
|
||||
- ``right_click`` (or alias ``rclick``) - Right mouse click (detected on element only)
|
||||
- ``mousedown>mouseup`` - Left mouse press-and-release (captures data at both phases)
|
||||
- ``rmousedown>mouseup`` - Right mouse press-and-release
|
||||
|
||||
Modifiers can be combined with ``+``: ``ctrl+click``, ``shift+mousedown>mouseup``.
|
||||
Sequences use space separation: ``click right_click``, ``click mousedown>mouseup``.
|
||||
|
||||
For ``mousedown>mouseup`` actions with ``hx_vals="js:functionName()"``, the JS function
|
||||
is called at both mousedown and mouseup. Results are suffixed: ``key_mousedown`` and
|
||||
``key_mouseup`` in the server request.
|
||||
"""
|
||||
|
||||
VALID_ACTIONS = {
|
||||
'click', 'right_click', 'rclick',
|
||||
'mousedown>mouseup', 'rmousedown>mouseup',
|
||||
'dblclick', 'double_click', 'dclick'
|
||||
}
|
||||
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def _validate_sequence(self, sequence: str):
|
||||
"""
|
||||
Validate a mouse event sequence string.
|
||||
|
||||
Checks that all elements in the sequence use valid action names and modifiers.
|
||||
|
||||
Args:
|
||||
sequence: Mouse event sequence string (e.g., "click", "ctrl+mousedown>mouseup")
|
||||
|
||||
Raises:
|
||||
ValueError: If any action or modifier is invalid.
|
||||
"""
|
||||
elements = sequence.strip().split()
|
||||
for element in elements:
|
||||
parts = element.split('+')
|
||||
# Last part should be the action, others are modifiers
|
||||
action = parts[-1].lower()
|
||||
modifiers = [p.lower() for p in parts[:-1]]
|
||||
|
||||
if action not in self.VALID_ACTIONS:
|
||||
raise ValueError(
|
||||
f"Invalid action '{action}' in sequence '{sequence}'. "
|
||||
f"Valid actions: {', '.join(sorted(self.VALID_ACTIONS))}"
|
||||
)
|
||||
|
||||
for mod in modifiers:
|
||||
if mod not in self.VALID_MODIFIERS:
|
||||
raise ValueError(
|
||||
f"Invalid modifier '{mod}' in sequence '{sequence}'. "
|
||||
f"Valid modifiers: {', '.join(sorted(self.VALID_MODIFIERS))}"
|
||||
)
|
||||
|
||||
def add(self, sequence: str, command: Command = None, *,
|
||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||
hx_delete: str = None, hx_patch: str = None,
|
||||
hx_target: str = None, hx_swap: str = None, hx_vals=None):
|
||||
hx_target: str = None, hx_swap: str = None, hx_vals=None,
|
||||
on_move: str = None):
|
||||
"""
|
||||
Add a mouse combination with optional command and HTMX parameters.
|
||||
|
||||
Args:
|
||||
sequence: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click")
|
||||
sequence: Mouse event sequence string. Supports:
|
||||
- Simple actions: ``"click"``, ``"right_click"``, ``"mousedown>mouseup"``
|
||||
- Modifiers: ``"ctrl+click"``, ``"shift+mousedown>mouseup"``
|
||||
- Sequences: ``"click right_click"``, ``"click mousedown>mouseup"``
|
||||
- Aliases: ``"rclick"`` for ``"right_click"``
|
||||
command: Optional Command object for server-side action
|
||||
hx_post: HTMX post URL (overrides command)
|
||||
hx_get: HTMX get URL (overrides command)
|
||||
@@ -41,11 +98,22 @@ class Mouse(MultipleInstance):
|
||||
hx_patch: HTMX patch URL (overrides command)
|
||||
hx_target: HTMX target selector (overrides command)
|
||||
hx_swap: HTMX swap strategy (overrides command)
|
||||
hx_vals: HTMX values dict or "js:functionName()" for dynamic values
|
||||
hx_vals: HTMX values dict or "js:functionName()" for dynamic values.
|
||||
For mousedown>mouseup actions, the JS function is called at both
|
||||
mousedown and mouseup, with results suffixed ``_mousedown`` and ``_mouseup``.
|
||||
on_move: Client-side JS function called on each animation frame during a drag,
|
||||
using ``"js:functionName()"`` format. Only valid with ``mousedown>mouseup``
|
||||
sequences. The function receives ``(event, combination, mousedown_result)``
|
||||
where ``mousedown_result`` is the raw result of ``hx_vals`` at mousedown,
|
||||
or ``None`` if ``hx_vals`` is not set. Return value is ignored.
|
||||
|
||||
Returns:
|
||||
self for method chaining
|
||||
|
||||
Raises:
|
||||
ValueError: If the sequence contains invalid actions or modifiers.
|
||||
"""
|
||||
self._validate_sequence(sequence)
|
||||
self.combinations[sequence] = {
|
||||
"command": command,
|
||||
"hx_post": hx_post,
|
||||
@@ -56,6 +124,7 @@ class Mouse(MultipleInstance):
|
||||
"hx_target": hx_target,
|
||||
"hx_swap": hx_swap,
|
||||
"hx_vals": hx_vals,
|
||||
"on_move": on_move,
|
||||
}
|
||||
return self
|
||||
|
||||
@@ -111,6 +180,15 @@ class Mouse(MultipleInstance):
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}")
|
||||
|
||||
# Handle on_move - client-side function for real-time drag feedback
|
||||
on_move = combination_data.get("on_move")
|
||||
if on_move is not None:
|
||||
if isinstance(on_move, str) and on_move.startswith("js:"):
|
||||
func_name = on_move[3:].rstrip("()")
|
||||
params["on-move"] = func_name
|
||||
else:
|
||||
raise ValueError(f"on_move must be 'js:functionName()', got: {on_move!r}")
|
||||
|
||||
return params
|
||||
|
||||
def render(self):
|
||||
|
||||
@@ -64,8 +64,8 @@ class PanelState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
return Command("SetVisiblePanelSide",
|
||||
f"Open / Close {side} side panel",
|
||||
self._owner,
|
||||
self._owner.set_side_visible,
|
||||
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
@@ -104,7 +104,7 @@ class Panel(MultipleInstance):
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id="-panel"):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
@@ -198,7 +198,7 @@ class Panel(MultipleInstance):
|
||||
body = Div(
|
||||
header,
|
||||
Div(content, id=self._ids.content(side), cls="mf-panel-content"),
|
||||
cls="mf-panel-body"
|
||||
cls=f"mf-panel-body mf-panel-body-{side}"
|
||||
)
|
||||
if side == "left":
|
||||
return Div(
|
||||
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
style=f"width: {self._state.right_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
|
||||
423
src/myfasthtml/controls/Profiler.py
Normal file
423
src/myfasthtml/controls/Profiler.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||||
from myfasthtml.icons.fluent import (
|
||||
arrow_clockwise20_regular,
|
||||
data_pie24_regular,
|
||||
text_bullet_list_tree20_filled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Span tree renderer — module-level, passed via PropertiesConf.types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mk_span_rows(span, depth: int, total_ms: float) -> list:
|
||||
"""Recursively build span rows for the tree view.
|
||||
|
||||
Args:
|
||||
span: A ProfilingSpan or CumulativeSpan to render.
|
||||
depth: Current nesting depth (controls indentation).
|
||||
total_ms: Reference duration used to compute bar widths.
|
||||
|
||||
Returns:
|
||||
List of FT elements, one per span row (depth-first order).
|
||||
"""
|
||||
rows = []
|
||||
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
|
||||
|
||||
if isinstance(span, CumulativeSpan):
|
||||
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.total_ms)
|
||||
badge = Span(
|
||||
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
|
||||
cls="mf-profiler-cumulative-badge",
|
||||
)
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls="mf-profiler-span-name"),
|
||||
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
badge,
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
else:
|
||||
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.duration_ms)
|
||||
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls=name_cls),
|
||||
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
for child in span.children:
|
||||
rows.extend(_mk_span_rows(child, depth + 1, total_ms))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _span_duration_cls(duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a span duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
|
||||
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
|
||||
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
|
||||
|
||||
Args:
|
||||
span: The root span to render as a tree.
|
||||
trace: The parent trace, used to compute proportional bar widths.
|
||||
|
||||
Returns:
|
||||
A FT element containing the full span tree.
|
||||
"""
|
||||
rows = _mk_span_rows(span, 0, trace.total_duration_ms)
|
||||
return Div(*rows, cls="mf-profiler-span-tree-content")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
def toggle_detail_view(self):
|
||||
return Command(
|
||||
"ProfilerToggleDetailView",
|
||||
"Switch between tree and pie view",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_detail_view,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def toggle_enable(self):
|
||||
return Command(
|
||||
"ProfilerToggleEnable",
|
||||
"Enable / Disable profiler",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_enable,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def clear_traces(self):
|
||||
return Command(
|
||||
"ProfilerClearTraces",
|
||||
"Clear all recorded traces",
|
||||
self._owner,
|
||||
self._owner.handle_clear_traces,
|
||||
icon=IconsHelper.get(MediaActions.Cancel),
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def refresh(self):
|
||||
return Command(
|
||||
"ProfilerRefresh",
|
||||
"Refresh traces",
|
||||
self._owner,
|
||||
self._owner.handle_refresh,
|
||||
icon=arrow_clockwise20_regular,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def select_trace(self, trace_id: str):
|
||||
return Command(
|
||||
"ProfilerSelectTrace",
|
||||
"Display trace details",
|
||||
self._owner,
|
||||
self._owner.handle_select_trace,
|
||||
kwargs={"trace_id": trace_id},
|
||||
).htmx(target=f"#tr_{trace_id}")
|
||||
|
||||
|
||||
class Profiler(SingleInstance):
|
||||
"""In-application profiler UI.
|
||||
|
||||
Displays all recorded traces in a scrollable list (left) and trace
|
||||
details in a resizable panel (right). The toolbar provides enable /
|
||||
disable toggle and clear actions via icon-only buttons.
|
||||
|
||||
Attributes:
|
||||
commands: Commands exposed by this control.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||||
self._panel.set_side_visible("right", True)
|
||||
self._selected_id: str | None = None
|
||||
self._detail_view: str = "tree"
|
||||
self.commands = Commands(self)
|
||||
logger.debug(f"Profiler created with id={self._id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_toggle_enable(self):
|
||||
"""Toggle profiler.enabled and re-render."""
|
||||
profiler.enabled = not profiler.enabled
|
||||
logger.debug(f"Profiler enabled set to {profiler.enabled}")
|
||||
return self
|
||||
|
||||
def handle_clear_traces(self):
|
||||
"""Clear the trace buffer and re-render."""
|
||||
profiler.clear()
|
||||
logger.debug("Profiler traces cleared from UI")
|
||||
return self
|
||||
|
||||
def handle_select_trace(self, trace_id: str):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
if self._selected_id is not None:
|
||||
old_trace = next(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
|
||||
else:
|
||||
old_trace = None
|
||||
|
||||
self._selected_id = trace_id
|
||||
trace = next(trace for trace in profiler.traces if trace.trace_id == trace_id)
|
||||
|
||||
return (self._mk_trace_item(trace),
|
||||
self._mk_trace_item(old_trace),
|
||||
self._panel.set_right(self._mk_right_panel(trace)))
|
||||
|
||||
def handle_toggle_detail_view(self):
|
||||
"""Toggle detail panel between tree and pie view."""
|
||||
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
|
||||
logger.debug(f"Profiler detail view set to {self._detail_view}")
|
||||
return self
|
||||
|
||||
def handle_refresh(self):
|
||||
"""Refresh the trace list without changing selection."""
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private rendering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _duration_cls(self, duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a given duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
def _mk_toolbar(self):
|
||||
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
|
||||
enable_icon = (
|
||||
IconsHelper.get(MediaActions.Pause)
|
||||
if profiler.enabled
|
||||
else IconsHelper.get(MediaActions.Play)
|
||||
)
|
||||
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
|
||||
|
||||
overhead = (
|
||||
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
|
||||
f"Total: {profiler.total_overhead_ms:.3f} ms "
|
||||
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
|
||||
)
|
||||
|
||||
return Div(
|
||||
mk.icon(
|
||||
enable_icon,
|
||||
command=self.commands.toggle_enable(),
|
||||
tooltip=enable_tooltip,
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.clear_traces(),
|
||||
tooltip="Clear traces",
|
||||
cls="mf-profiler-btn-danger",
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.refresh(),
|
||||
),
|
||||
Span(overhead, cls="mf-profiler-overhead"),
|
||||
cls="mf-profiler-toolbar",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def _mk_trace_item(self, trace: ProfilingTrace):
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
return mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
id=f"tr_{trace.trace_id}",
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
|
||||
def _mk_trace_list(self):
|
||||
"""Build the trace list with one clickable row per recorded trace."""
|
||||
traces = profiler.traces
|
||||
if not traces:
|
||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||
|
||||
rows = [self._mk_trace_item(trace) for trace in reversed(traces)]
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
Span("Command", cls="mf-profiler-col-header"),
|
||||
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
cls="mf-profiler-list-header",
|
||||
),
|
||||
Div(*rows, cls="mf-profiler-list-body"),
|
||||
cls="mf-profiler-list",
|
||||
)
|
||||
|
||||
def _mk_detail_placeholder(self):
|
||||
"""Placeholder shown in the right panel before a trace is selected."""
|
||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||||
|
||||
def _mk_detail_header(self, trace: "ProfilingTrace"):
|
||||
"""Build the detail panel header with title and tree/pie toggle.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail header.
|
||||
"""
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
title = Div(
|
||||
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
|
||||
Span(f" — {trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
|
||||
cls="mf-profiler-detail-title",
|
||||
)
|
||||
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
|
||||
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
|
||||
toggle = Div(
|
||||
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree",
|
||||
cls=tree_cls),
|
||||
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)",
|
||||
cls=pie_cls),
|
||||
cls="mf-profiler-view-toggle",
|
||||
)
|
||||
return Div(title, toggle, cls="mf-profiler-detail-header")
|
||||
|
||||
def _mk_detail_body(self, trace: "ProfilingTrace"):
|
||||
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail body.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
meta_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Metadata": {
|
||||
"command": "command_name",
|
||||
"description": "command_description",
|
||||
"duration_ms": "total_duration_ms",
|
||||
"timestamp": "timestamp",
|
||||
}},
|
||||
),
|
||||
_id="-detail-meta",
|
||||
)
|
||||
|
||||
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
|
||||
kwargs_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
|
||||
_id="-detail-kwargs",
|
||||
)
|
||||
|
||||
span_props = None
|
||||
if trace.root_span is not None:
|
||||
span_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Span breakdown": {"root_span": "root_span"}},
|
||||
types={ProfilingSpan: _span_tree_renderer},
|
||||
),
|
||||
_id="-detail-spans",
|
||||
)
|
||||
|
||||
if self._detail_view == "pie":
|
||||
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
|
||||
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
|
||||
|
||||
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
|
||||
|
||||
def _mk_detail_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the full detail panel for a selected trace.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail panel.
|
||||
"""
|
||||
return Div(
|
||||
self._mk_detail_header(trace),
|
||||
self._mk_detail_body(trace),
|
||||
cls="mf-profiler-detail",
|
||||
)
|
||||
|
||||
def _mk_right_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the right panel with a trace detail view."""
|
||||
return (
|
||||
self._mk_detail_panel(trace)
|
||||
if trace is not None
|
||||
else self._mk_detail_placeholder()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self):
|
||||
selected_trace = None
|
||||
if self._selected_id is not None:
|
||||
selected_trace = next(
|
||||
(t for t in profiler.traces if t.trace_id == self._selected_id), None
|
||||
)
|
||||
|
||||
self._panel.set_main(self._mk_trace_list())
|
||||
self._panel.set_right(self._mk_right_panel(selected_trace))
|
||||
return Div(
|
||||
self._mk_toolbar(),
|
||||
self._panel,
|
||||
id=self._id,
|
||||
cls="mf-profiler",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,21 +1,42 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertiesConf:
|
||||
"""Declarative configuration for the Properties control.
|
||||
|
||||
Attributes:
|
||||
obj: The Python object whose attributes are displayed.
|
||||
groups: Mapping of group name to ProxyObject spec.
|
||||
types: Mapping of Python type to renderer callable.
|
||||
Each renderer has the signature ``(value, obj) -> FT``.
|
||||
"""
|
||||
|
||||
obj: Any = None
|
||||
groups: Optional[dict] = None
|
||||
types: Optional[dict] = field(default=None)
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
def __init__(self, parent, conf: PropertiesConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
self.conf = conf or PropertiesConf()
|
||||
self._refresh()
|
||||
|
||||
def set_conf(self, conf: PropertiesConf):
|
||||
self.conf = conf
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
self._types = self.conf.types or {}
|
||||
self._properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(value, t):
|
||||
return renderer(value, self.conf.obj)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
|
||||
def _render_group_content(self, proxy) -> Div:
|
||||
"""Render a group's content.
|
||||
|
||||
When the group contains exactly one property whose type is registered in
|
||||
``conf.types``, the type renderer replaces the entire group content (not
|
||||
just the value cell). This lets custom renderers (e.g. span trees) fill
|
||||
the full card width without a key/value row wrapper.
|
||||
|
||||
Otherwise, the standard key/value row layout is used.
|
||||
|
||||
Args:
|
||||
proxy: ProxyObject for this group.
|
||||
|
||||
Returns:
|
||||
A FT element containing the group content.
|
||||
"""
|
||||
properties = proxy.as_dict()
|
||||
if len(properties) == 1:
|
||||
k, v = next(iter(properties.items()))
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(v, t):
|
||||
return renderer(v, self.conf.obj)
|
||||
return self._mk_group_content(properties)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
self._render_group_content(proxy),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
for group_name, proxy in self._properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
if self.conf.groups is None:
|
||||
return {None: ProxyObject(self.conf.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
109
src/myfasthtml/controls/Query.py
Normal file
109
src/myfasthtml/controls/Query.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
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("Query")
|
||||
|
||||
QUERY_FILTER = "filter"
|
||||
QUERY_SEARCH = "search"
|
||||
QUERY_AI = "ai"
|
||||
|
||||
query_type = {
|
||||
QUERY_FILTER: filter20_regular,
|
||||
QUERY_SEARCH: search20_regular,
|
||||
QUERY_AI: brain_circuit20_regular
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryConf:
|
||||
"""Configuration for Query control.
|
||||
|
||||
Attributes:
|
||||
placeholder: Placeholder text for the search input
|
||||
"""
|
||||
placeholder: str = "Search..."
|
||||
|
||||
|
||||
class QueryState(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) # prevent focus loss when typing
|
||||
|
||||
def on_cancel_query(self):
|
||||
return Command("CancelQuery",
|
||||
"Cancel query",
|
||||
self._owner,
|
||||
self._owner.query_changed,
|
||||
kwargs={"query": ""}
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class Query(MultipleInstance):
|
||||
def __init__(self, parent, conf: Optional[QueryConf] = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or QueryConf()
|
||||
self.commands = Commands(self)
|
||||
self._state = QueryState(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 # needed anyway to allow oob swap
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.label(
|
||||
Input(name="query",
|
||||
value=self._state.query if self._state.query is not None else "",
|
||||
placeholder=self.conf.placeholder,
|
||||
**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()
|
||||
@@ -14,12 +14,13 @@ logger = logging.getLogger("Search")
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
return Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML",
|
||||
auto_swap_oob=False)
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
@@ -45,6 +46,7 @@ class Search(MultipleInstance):
|
||||
items_names=None, # what is the name of the items to filter
|
||||
items=None, # first set of items to filter
|
||||
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
||||
get_id: Callable[[Any], str] = None, # use for deduplication
|
||||
template: Callable[[Any], Any] = None, # once filtered, what to render ?
|
||||
max_height: int = 400):
|
||||
"""
|
||||
@@ -65,6 +67,7 @@ class Search(MultipleInstance):
|
||||
self.items = items or []
|
||||
self.filtered = self.items.copy()
|
||||
self.get_attr = get_attr or (lambda x: x)
|
||||
self.get_item_id = get_id
|
||||
self.template = template or (lambda x: Div(self.get_attr(x)))
|
||||
self.commands = Commands(self)
|
||||
self.max_height = max_height
|
||||
@@ -86,6 +89,7 @@ class Search(MultipleInstance):
|
||||
return tuple(self._mk_search_results())
|
||||
|
||||
def search(self, query):
|
||||
|
||||
logger.debug(f"search {query=}")
|
||||
if query is None or query.strip() == "":
|
||||
self.filtered = self.items.copy()
|
||||
@@ -93,24 +97,39 @@ class Search(MultipleInstance):
|
||||
else:
|
||||
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
|
||||
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
|
||||
self.filtered = res_seq + res_fuzzy
|
||||
self.filtered = self._unique_items(res_seq + res_fuzzy)
|
||||
|
||||
return self.filtered
|
||||
|
||||
def _mk_search_results(self):
|
||||
return [self.template(item) for item in self.filtered]
|
||||
|
||||
def _unique_items(self, items: list):
|
||||
if self.get_item_id is None:
|
||||
return items
|
||||
|
||||
already_seen = set()
|
||||
res = []
|
||||
for item in items:
|
||||
_id = self.get_item_id(item)
|
||||
if _id not in already_seen:
|
||||
already_seen.add(_id)
|
||||
res.append(item)
|
||||
return res
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
|
||||
mk.mk(Input(name="query",
|
||||
id=f"{self._id}-search",
|
||||
type="text", placeholder="Search...",
|
||||
cls="input input-xs w-full"),
|
||||
command=self.commands.search()),
|
||||
Div(
|
||||
*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results",
|
||||
style="max-height: 400px;" if self.max_height else None
|
||||
),
|
||||
Div(*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results"),
|
||||
id=f"{self._id}",
|
||||
cls="mf-search",
|
||||
style=f"max-height: {self.max_height}px;" if self.max_height else None
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
91
src/myfasthtml/controls/Sortable.py
Normal file
91
src/myfasthtml/controls/Sortable.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Sortable control for drag-and-drop reordering of list items.
|
||||
|
||||
Wraps SortableJS to enable drag-and-drop on any container, posting
|
||||
the new item order to the server via HTMX after each drag operation.
|
||||
Requires SortableJS to be loaded via create_app(sortable=True).
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("Sortable")
|
||||
|
||||
|
||||
class Sortable(MultipleInstance):
|
||||
"""
|
||||
Composable control that enables SortableJS drag-and-drop on a container.
|
||||
|
||||
Place this inside a render() method alongside the sortable container.
|
||||
Items in the container must have a ``data-sort-id`` attribute identifying
|
||||
each item. After a drag, the new order is POSTed to the server via the
|
||||
provided command.
|
||||
|
||||
Args:
|
||||
parent: Parent instance that owns this control.
|
||||
command: Command to execute after reordering. Its handler must accept
|
||||
an ``order: list`` parameter receiving the sorted IDs.
|
||||
_id: Optional custom ID suffix.
|
||||
container_id: ID of the DOM element to make sortable. Defaults to
|
||||
``parent.get_id()`` if not provided.
|
||||
handle: Optional CSS selector for the drag handle within each item.
|
||||
If None, the entire item is draggable.
|
||||
group: Optional SortableJS group name to allow dragging between
|
||||
multiple connected lists.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
parent,
|
||||
command: Command,
|
||||
_id: Optional[str] = None,
|
||||
container_id: Optional[str] = None,
|
||||
handle: Optional[str] = None,
|
||||
group: Optional[str] = None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._command = command
|
||||
self._container_id = container_id
|
||||
self._handle = handle
|
||||
self._group = group
|
||||
|
||||
def render(self):
|
||||
container_id = self._container_id or self._parent.get_id()
|
||||
opts = self._command.ajax_htmx_options()
|
||||
|
||||
js_opts = ["animation: 150"]
|
||||
if self._handle:
|
||||
js_opts.append(f"handle: '{self._handle}'")
|
||||
if self._group:
|
||||
js_opts.append(f"group: '{self._group}'")
|
||||
|
||||
existing_values = ", ".join(f'"{k}": "{v}"' for k, v in opts["values"].items())
|
||||
|
||||
js_opts.append(f"""onEnd: function(evt) {{
|
||||
var items = Array.from(document.getElementById('{container_id}').children)
|
||||
.map(function(el) {{ return el.dataset.sortId; }})
|
||||
.filter(Boolean);
|
||||
htmx.ajax('POST', '{opts["url"]}', {{
|
||||
target: '{opts["target"]}',
|
||||
swap: '{opts["swap"]}',
|
||||
values: {{ {existing_values}, order: items.join(',') }}
|
||||
}});
|
||||
}}""")
|
||||
|
||||
js_opts_str = ",\n ".join(js_opts)
|
||||
|
||||
script = f"""(function() {{
|
||||
var container = document.getElementById('{container_id}');
|
||||
if (!container) {{ return; }}
|
||||
new Sortable(container, {{
|
||||
{js_opts_str}
|
||||
}});
|
||||
}})();"""
|
||||
|
||||
logger.debug(f"Sortable rendered for container={container_id}")
|
||||
return Script(script)
|
||||
|
||||
def __ft__(self):
|
||||
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