61 Commits

Author SHA1 Message Date
3ea551bc1a Fixed wrong full refresh 2026-03-23 22:32:05 +01:00
3bcf50f55f Hardened instance creation 2026-03-23 22:10:11 +01:00
7f099b14f6 Fixed double Panel double instantiation 2026-03-23 21:41:06 +01:00
0e1087a614 First version of Profiler control with right part 2026-03-22 16:40:21 +01:00
d3c0381e34 Fixed unit tests ! 2026-03-22 08:37:07 +01:00
b8fd4e5ed1 Added Profiler control with basic UI 2026-03-22 08:32:40 +01:00
72d6cce6ff Adding Profiler module 2026-03-21 18:08:34 +01:00
f887267362 Optimizing keyboard navigation and selection handling 2026-03-21 18:08:13 +01:00
853bc4abae Added keyboard navigation support 2026-03-16 22:43:45 +01:00
2fcc225414 Added some unit tests for the grid 2026-03-16 21:46:19 +01:00
ef9f269a49 I can edit a cell 2026-03-16 21:16:21 +01:00
0951680466 Fixed minor issues.
* highlighted cells not correctly rendered
* text selection not correctly working
2026-03-15 18:45:55 +01:00
0c9c8bc7fa Fixed FormattingRules not being applied 2026-03-15 16:50:21 +01:00
feb9da50b2 Implemented enable/disable for keyboard support 2026-03-15 08:33:39 +01:00
f773fd1611 Keyboard.py : ajout de enabled dans add(), nouveau render() retournant (Script,
control_div), et méthodes mk_enable / mk_disable
  - keyboard.js : nouvelle signature add_keyboard_support(elementId, controlDivId,
  combinationsJson), fonction isCombinationEnabled(), vérification avant déclenchement
  - test_Keyboard.py : 8 tests couvrant les comportements et le rendu
2026-03-14 23:29:18 +01:00
af83f4b6dc Added unit test 2026-03-14 22:16:20 +01:00
a4ebd6d61b Fixed bugs in DataGridFormattingManager 2026-03-14 22:01:44 +01:00
56fb3cf021 Working in DataGridFormattingManager 2026-03-13 22:13:01 +01:00
3d1a391cba Added Rules preset (on top of format and style presets) 2026-03-13 21:02:03 +01:00
3105b72ac2 Improved auto-completion engine for formatting parameters and added support for absolute value in number formatting. 2026-03-11 22:39:52 +01:00
e704dad62c Fixed unit tests 2026-03-11 20:59:07 +01:00
e01d2cd74b Reimplementing Columns Management 2026-03-08 12:03:07 +01:00
30a77d1171 Refactoring DataGrid to use DataService.py 2026-03-02 22:34:14 +01:00
0a766581ed Added DataServicesManager and DataService 2026-02-27 21:10:11 +01:00
efbc5a59ff Updated skill and documentation 2026-02-27 17:31:19 +01:00
c07b75ee72 I can add a new column and a new row 2026-02-26 22:44:35 +01:00
b383b1bc8b IconsHelper can support NotStr icons 2026-02-24 22:38:08 +01:00
5dc4fbae25 Fixed parameter issue in bound command execution 2026-02-24 22:36:35 +01:00
1add319a6e Updated SKILL.md 2026-02-24 22:35:49 +01:00
9fe511c97b Fixed double-click handling for column width reset 2026-02-23 22:56:48 +01:00
2af43f357d Added menu management 2026-02-22 22:01:26 +01:00
b5abb59332 Fixed transform issue and added reset 2026-02-22 19:06:40 +01:00
3715954222 Added documentation for HierarchicalCanvasGraph.py 2026-02-22 18:12:19 +01:00
0686103a8f Implemented new InstancesDebugger.py. Based on the HierarchicalCanvasGraph.py 2026-02-22 17:51:39 +01:00
8b8172231a updated css. Added orientation. Saving orientation, position and scale in state 2026-02-22 10:28:13 +01:00
44691be30f New hierarchical component, used for InstancesDebugger.py 2026-02-21 23:53:05 +01:00
9a25591edf Finish grid deletion 2026-02-21 22:03:16 +01:00
d447220eae Implemented Delete feature in DataGridsManager.py. There is still a bug as DBEngine.delete is not implemented
Improved readability for tests using matcher
2026-02-21 18:31:11 +01:00
730f55d65b Refactored DataGridsManager.py for better reading 2026-02-20 23:32:11 +01:00
c49f28da26 minor updates 2026-02-20 22:06:23 +01:00
40a90c7ff5 feat: implement new grid creation with inline rename in DataGridsManager
- Add new_grid() method to create empty DataGrid under selected folder/leaf parent
  - Generate unique sheet names (Sheet1, Sheet2, ...) with _generate_unique_sheet_name()
  - Auto-select and open new node in edit mode for immediate renaming
  - Fix TreeView to cancel edit mode when selecting any node
  - Wire "New grid" icon to new_grid() instead of clear_tree()
  - Add 14 unit tests covering new_grid() scenarios and TreeView behavior
2026-02-20 21:50:47 +01:00
8f3b2e795e Added skills 2026-02-20 20:53:02 +01:00
13f292fc9d Added IconsHelper and updated Keyboard to support require_inside flag 2026-02-20 20:35:09 +01:00
b09763b1eb Fixed truncate on column header 2026-02-18 21:16:11 +01:00
5724c96917 Fixed scrollbar behavior 2026-02-17 21:53:02 +01:00
70915b2691 I can add new columns 2026-02-16 21:57:39 +01:00
f3e19743c8 I can apply formulas 2026-02-15 19:55:22 +01:00
27f12b2c32 Fixed Syntax validation and autocompletion. Fixed unit tests 2026-02-15 18:32:34 +01:00
789c06b842 Integrating formula editor 2026-02-13 23:04:06 +01:00
e8443f07f9 Introducing columns formulas 2026-02-13 21:38:00 +01:00
0df78c0513 Added keyboard support 2026-02-11 22:32:15 +01:00
fe322300c1 Fixed performance issues by creating a dedicated store for dataframe and optimizing 2026-02-11 22:09:08 +01:00
520a8914fc I can select range with visual feedback 2026-02-10 23:00:45 +01:00
79c37493af Added mouse selection 2026-02-09 23:46:31 +01:00
b0d565589a Added natural colors presets 2026-02-08 23:19:47 +01:00
0119f54f11 Added columns values in suggestion + fixed commands key conflicts bug 2026-02-08 22:44:06 +01:00
d44e0a0c01 Fixed command id collision. Added class support in style preset 2026-02-08 19:50:10 +01:00
3ec994d6df Refactored assets serving 2026-02-08 16:31:38 +01:00
85f5d872c8 Removed deprecated doc 2026-02-08 11:15:02 +01:00
86b80b04f7 Updated documentation 2026-02-08 11:14:03 +01:00
8e059df68a Fixed rule conflict management. Added User guide for formatting 2026-02-08 00:12:24 +01:00
176 changed files with 30576 additions and 7879 deletions

View File

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

View 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

View 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

View 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

View 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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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
View 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`): 20100 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`

View File

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

View File

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

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

View 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">"&lt;"</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">"&gt;"</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">"&lt;"</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">"&gt;"</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">"&lt;"</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">"&gt;"</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>

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

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

View File

@@ -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&gt;mouseup actions:</strong></p>
<ul>
<li><code>click</code> - Simple click (coexists with mousedown&gt;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&gt;mouseup actions)</h2>
<div id="test-element-3" class="test-element" tabindex="0">
Try mousedown&gt;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>

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
function initLayout(elementId) {
initResizer(elementId);
bindTooltipsWithDelegation(elementId);
}

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
.mf-vis {
width: 100%;
height: 100%;
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View 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