103 Commits

Author SHA1 Message Date
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
fc38196ad9 Fixed issue where table rules were not applied 2026-02-07 23:03:14 +01:00
6160e91665 Added "table" and "tables" in the DSL 2026-02-07 22:48:51 +01:00
08c8c00e28 Improved completion to support the correct table name for autocompletion and formatting 2026-02-07 20:56:44 +01:00
3fc4384251 Improving completion to support the correct table name for autocompletion and formatting 2026-02-07 18:26:29 +01:00
ab4f251f0c Added syntax colorization. Remove all references to deprecated lark_to_lezer module. 2026-02-07 11:08:34 +01:00
1c1ced2a9f Added syntax colorization 2026-02-07 10:52:40 +01:00
db1e94f930 I can format columns 2026-02-06 22:46:59 +01:00
0620cb678b I can validate formatting in editor 2026-02-01 21:49:46 +01:00
d7ec99c3d9 Working on Formating DSL completion 2026-01-31 19:09:14 +01:00
778e5ac69d I can apply format rules 2026-01-26 23:29:51 +01:00
9abb9dddfe Added classes to support formatting 2026-01-26 21:32:26 +01:00
3083f3b1fd Clean version before implementing formatting 2026-01-26 19:09:15 +01:00
05d4e5cd89 Working version of DataGridColumnsManager.py where columns can be updated 2026-01-25 21:40:32 +01:00
e31d9026ce I can hide and show the columns, and it dynamically updates the grid 2026-01-25 18:48:54 +01:00
3abfab8e97 I can show and hide the columns comanger 2026-01-25 11:29:18 +01:00
7f3e6270a2 Added draft of DataGridColumnsManager that uses Search and Dropdown 2026-01-25 09:47:05 +01:00
0bd56c7f09 Working on ColumnsManager. Added CycleStateControl and DataGridColumnsManager. 2026-01-24 23:55:44 +01:00
3c2c07ebfc Fixed bug where same DataGridQuery for all grids 2026-01-24 19:42:44 +01:00
06e81fe72a I can select rows and columns 2026-01-24 19:23:10 +01:00
ba2b6e672a I can click on the grid to select a cell 2026-01-24 12:06:22 +01:00
191ead1c89 First version of DataGridQuery. Fixed scrollbar issue 2026-01-23 21:26:19 +01:00
872d110f07 Working on DatagridFilter 2026-01-21 22:33:17 +01:00
ca40333742 Fixed toolip 2026-01-21 21:07:11 +01:00
346b9632c6 I can move columns 2026-01-18 20:34:36 +01:00
509a7b7778 I can resize Datagrid columns 2026-01-18 19:12:13 +01:00
500340fbd3 Optimized Datagrid scrolling 2026-01-16 21:00:55 +01:00
d909f2125d Added scrollbars 2026-01-11 23:25:55 +01:00
5d6c02001e Implemented lazy loading 2026-01-11 15:49:20 +01:00
a9eb23ad76 Optimized Datagrid.render() 2026-01-10 22:54:02 +01:00
47848bb2fd Fixed tab recreation logic and improved error handling 2026-01-10 21:18:23 +01:00
5201858b79 working on improving component loading 2026-01-10 19:17:17 +01:00
797883dac8 I can display datagrid content 2026-01-08 15:19:16 +01:00
70abf21c14 I can save and load Datagrid content 2026-01-06 18:46:17 +01:00
2f808ed226 Improved command management to reduce the number of instances 2025-12-21 11:14:02 +01:00
9f69a6bc5b updated Panel implementation 2025-12-20 19:21:30 +01:00
81a80a47b6 First set of upgrades for the Panel component 2025-12-20 11:52:44 +01:00
1347f12618 Improved Command class management. 2025-12-19 21:12:55 +01:00
b26abc4257 Working on Datagrid interaction 2025-12-08 22:39:26 +01:00
045f01b48a Refactored Command to add owner 2025-12-08 21:07:34 +01:00
3aa36a91aa DatagridsManager : I can see the opened folders in the Treeview 2025-12-07 20:57:20 +01:00
dc5ec450f0 I can open an excel file and see its content 2025-12-07 17:46:39 +01:00
fde2e85c92 TabsManager.py Added unit tests + documentation 2025-12-07 15:42:48 +01:00
05067515d6 Added recursion to Properties.py 2025-12-06 10:06:42 +01:00
8e5fa7f752 Added Controls testing + documentation 2025-12-05 19:17:21 +01:00
1d20fb8650 Added TreeView and Panel 2025-11-29 18:15:20 +01:00
ce5328fe34 Added first controls 2025-11-26 20:53:12 +01:00
233 changed files with 57078 additions and 2158 deletions

View File

@@ -0,0 +1,560 @@
# Developer Control Mode
You are now in **Developer Control Mode** - specialized mode for developing UI controls in the MyFastHtml project.
## Primary Objective
Create robust, consistent UI controls by following the established patterns and rules of the project.
## Control Development Rules (DEV-CONTROL)
### DEV-CONTROL-01: Class Inheritance
A control must inherit from one of the three base classes based on its usage:
| Class | Usage | Example |
|-------|-------|---------|
| `MultipleInstance` | Multiple instances possible per session | `DataGrid`, `Panel`, `Search` |
| `SingleInstance` | One instance per session | `Layout`, `UserProfile`, `CommandsDebugger` |
| `UniqueInstance` | One instance, but `__init__` called each time | (special case) |
```python
from myfasthtml.core.instances import MultipleInstance
class MyControl(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
```
---
### DEV-CONTROL-02: Nested Commands Class
Each interactive control must define a `Commands` class inheriting from `BaseCommands`:
```python
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.core.commands import Command
class Commands(BaseCommands):
def my_action(self):
return Command("MyAction",
"Description of the action",
self._owner,
self._owner.my_action_handler
).htmx(target=f"#{self._id}")
```
**Conventions**:
- Method name in `snake_case`
- First `Command` argument: unique name (PascalCase recommended)
- Use `self._owner` to reference the parent control
- Use `self._id` for HTMX targets
---
### DEV-CONTROL-03: State Management with DbObject
Persistent state must be encapsulated in a class inheriting from `DbObject`:
```python
from myfasthtml.core.dbmanager import DbObject
class MyControlState(DbObject):
def __init__(self, owner, save_state=True):
with self.initializing():
super().__init__(owner, save_state=save_state)
# Persisted attributes
self.visible: bool = True
self.width: int = 250
# NOT persisted (ns_ prefix)
self.ns_temporary_data = None
# NOT saved but evaluated (ne_ prefix)
self.ne_computed_value = None
```
**Special prefixes**:
- `ns_` (no-save): not persisted to database
- `ne_` (no-equality): not compared for change detection
- `_`: internal variables, ignored
---
### DEV-CONTROL-04: render() and __ft__() Methods
Each control must implement:
```python
def render(self):
return Div(
# Control content
id=self._id,
cls="mf-my-control"
)
def __ft__(self):
return self.render()
```
**Rules**:
- `render()` contains the rendering logic
- `__ft__()` simply delegates to `render()`
- Root element must have `id=self._id`
---
### DEV-CONTROL-05: Control Initialization
Standard initialization structure:
```python
def __init__(self, parent, _id=None, **kwargs):
super().__init__(parent, _id=_id)
# 1. State
self._state = MyControlState(self)
# 2. Commands
self.commands = Commands(self)
# 3. Sub-components
self._panel = Panel(self, _id="-panel")
self._search = Search(self, _id="-search")
# 4. Command bindings
self._search.bind_command("Search", self.commands.on_search())
```
---
### DEV-CONTROL-06: Relative IDs for Sub-components
Use the `-` prefix to create IDs relative to the parent:
```python
# Results in: "{parent_id}-panel"
self._panel = Panel(self, _id="-panel")
# Results in: "{parent_id}-search"
self._search = Search(self, _id="-search")
```
---
### DEV-CONTROL-07: Using the mk Helper Class
Use `mk` helpers to create interactive elements:
```python
from myfasthtml.controls.helpers import mk
# Button with command
mk.button("Click me", command=self.commands.my_action())
# Icon with command and tooltip
mk.icon(my_icon, command=self.commands.toggle(), tooltip="Toggle")
# Label with icon
mk.label("Title", icon=my_icon, size="sm")
# Generic wrapper
mk.mk(Input(...), command=self.commands.on_input())
```
---
### DEV-CONTROL-08: Logging
Each control must declare a logger with its name:
```python
import logging
logger = logging.getLogger("MyControl")
class MyControl(MultipleInstance):
def my_action(self):
logger.debug(f"my_action called with {param=}")
```
---
### DEV-CONTROL-09: Command Binding Between Components
To link a sub-component's actions to the parent control:
```python
# In the parent control
self._child = ChildControl(self, _id="-child")
self._child.bind_command("ChildAction", self.commands.on_child_action())
```
---
### DEV-CONTROL-10: Keyboard and Mouse Composition
For interactive controls, compose `Keyboard` and `Mouse`:
```python
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
def render(self):
return Div(
self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
id=self._id
)
```
---
### DEV-CONTROL-11: Partial Rendering
For HTMX updates, implement partial rendering methods:
```python
def render_partial(self, fragment="default"):
if fragment == "body":
return self._mk_body()
elif fragment == "header":
return self._mk_header()
return self._mk_default()
```
---
### DEV-CONTROL-12: Simple State (Non-Persisted)
For simple state without DB persistence, use a basic Python class:
```python
class MyControlState:
def __init__(self):
self.opened = False
self.selected = None
```
---
### DEV-CONTROL-13: Dataclasses for Configurations
Use dataclasses for configurations:
```python
from dataclasses import dataclass
from typing import Optional
@dataclass
class MyControlConf:
title: str = "Default"
show_header: bool = True
width: Optional[int] = None
```
---
### DEV-CONTROL-14: Generated ID Prefixes
Use short, meaningful prefixes for sub-elements:
```python
f"tb_{self._id}" # table body
f"th_{self._id}" # table header
f"sn_{self._id}" # sheet name
f"fi_{self._id}" # file input
```
---
### DEV-CONTROL-15: State Getters
Expose state via getter methods:
```python
def get_state(self):
return self._state
def get_selected(self):
return self._state.selected
```
---
### DEV-CONTROL-16: Computed Properties
Use `@property` for frequent access:
```python
@property
def width(self):
return self._state.width
```
---
### DEV-CONTROL-17: JavaScript Initialization Scripts
If the control requires JavaScript, include it in the render:
```python
from fasthtml.xtend import Script
def render(self):
return Div(
self._mk_content(),
Script(f"initMyControl('{self._id}');"),
id=self._id
)
```
---
### DEV-CONTROL-18: CSS Classes with Prefix
Use the `mf-` prefix for custom CSS classes:
```python
cls="mf-my-control"
cls="mf-my-control-header"
```
---
### DEV-CONTROL-19: Sub-element Creation Methods
Prefix creation methods with `_mk_` or `mk_`:
```python
def _mk_header(self):
"""Private creation method"""
return Div(...)
def mk_content(self):
"""Public creation method (reusable)"""
return Div(...)
```
---
### DEV-CONTROL-20: HTMX Ajax Requests
**Always specify a `target` in HTMX ajax requests.**
```javascript
// ❌ INCORRECT: Without target, HTMX doesn't know where to swap the response
htmx.ajax('POST', '/url', {
values: {param: value}
});
// ✅ CORRECT: Explicitly specify the target
htmx.ajax('POST', '/url', {
target: '#element-id',
values: {param: value}
});
```
**Exception:** Response contains elements with `hx-swap-oob="true"`.
---
### DEV-CONTROL-21: HTMX Swap Modes and Event Listeners
**`hx-on::after-settle` only works when the swapped element replaces the target (`outerHTML`).**
```javascript
// ❌ INCORRECT: innerHTML (default) nests the returned element
// The hx-on::after-settle attribute on the returned element is never processed
htmx.ajax('POST', '/url', {
target: '#my-element'
// swap: 'innerHTML' is the default
});
// ✅ CORRECT: outerHTML replaces the entire element
// The hx-on::after-settle attribute on the returned element works
htmx.ajax('POST', '/url', {
target: '#my-element',
swap: 'outerHTML'
});
```
**Why:**
- `innerHTML`: replaces **content**`<div id="X"><div id="X" hx-on::...>new</div></div>` (duplicate ID)
- `outerHTML`: replaces **element**`<div id="X" hx-on::...>new</div>` (correct)
---
### DEV-CONTROL-22: Reinitializing Event Listeners
**After an HTMX swap, event listeners attached via JavaScript are lost and must be reinitialized.**
**Recommended pattern:**
1. Create a reusable initialization function:
```javascript
function initMyControl(controlId) {
const element = document.getElementById(controlId);
// Attach event listeners
element.addEventListener('click', handleClick);
}
```
2. Call this function after swap via `hx-on::after-settle`:
```python
extra_attr = {
"hx-on::after-settle": f"initMyControl('{self._id}');"
}
element.attrs.update(extra_attr)
```
**Alternative:** Use event delegation on a stable parent element.
---
### DEV-CONTROL-23: Avoiding Duplicate IDs with HTMX
**If the element returned by the server has the same ID as the HTMX target, use `swap: 'outerHTML'`.**
```python
# Server returns an element with id="my-element"
def render_partial(self):
return Div(id="my-element", ...) # Same ID as target
# JavaScript must use outerHTML
htmx.ajax('POST', '/url', {
target: '#my-element',
swap: 'outerHTML' # ✅ Replaces the entire element
});
```
**Why:** `innerHTML` would create `<div id="X"><div id="X">...</div></div>` (invalid duplicate ID).
---
### DEV-CONTROL-24: Pattern extra_attr for HTMX
**Use the `extra_attr` pattern to add post-swap behaviors.**
```python
def render_partial(self, fragment="default"):
extra_attr = {
"hx-on::after-settle": f"initControl('{self._id}');",
# Other HTMX attributes if needed
}
element = self.mk_element()
element.attrs.update(extra_attr)
return element
```
**Use cases:**
- Reinitialize event listeners
- Execute animations
- Update other DOM elements
- Logging or tracking events
---
## Complete Control Template
```python
import logging
from dataclasses import dataclass
from typing import Optional
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("MyControl")
@dataclass
class MyControlConf:
title: str = "Default"
show_header: bool = True
class MyControlState(DbObject):
def __init__(self, owner, save_state=True):
with self.initializing():
super().__init__(owner, save_state=save_state)
self.visible: bool = True
self.ns_temp_data = None
class Commands(BaseCommands):
def toggle(self):
return Command("Toggle",
"Toggle visibility",
self._owner,
self._owner.handle_toggle
).htmx(target=f"#{self._id}")
class MyControl(MultipleInstance):
def __init__(self, parent, conf: Optional[MyControlConf] = None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or MyControlConf()
self._state = MyControlState(self)
self.commands = Commands(self)
logger.debug(f"MyControl created with id={self._id}")
def handle_toggle(self):
self._state.visible = not self._state.visible
return self
def _mk_header(self):
return Div(
mk.label(self.conf.title),
mk.icon(toggle_icon, command=self.commands.toggle()),
cls="mf-my-control-header"
)
def _mk_content(self):
if not self._state.visible:
return None
return Div("Content here", cls="mf-my-control-content")
def render(self):
return Div(
self._mk_header() if self.conf.show_header else None,
self._mk_content(),
Script(f"initMyControl('{self._id}');"),
id=self._id,
cls="mf-my-control"
)
def __ft__(self):
return self.render()
```
---
## Managing Rules
To disable a specific rule, the user can say:
- "Disable DEV-CONTROL-08" (do not apply the logging rule)
- "Enable DEV-CONTROL-08" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer` to switch to general development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -0,0 +1,243 @@
# Developer Mode
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
## Primary Objective
Write production-quality code by:
1. Exploring available options before implementation
2. Validating approach with user
3. Implementing only after approval
4. Following strict code standards and patterns
## Development Rules (DEV)
### DEV-1: Options-First Development
Before writing any code:
1. **Explain available options first** - Present different approaches to solve the problem
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
3. **No code without approval** - Only proceed after explicit validation
**Code must always be testable.**
### DEV-2: Question-Driven Collaboration
**Ask questions to clarify understanding or suggest alternative approaches:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume - always clarify ambiguities
### DEV-3: Communication Standards
**Conversations**: French or English (match user's language)
**Code, documentation, comments**: English only
### DEV-4: Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Document all public functions and classes
- Include type hints where applicable
### DEV-5: Dependency Management
**When introducing new dependencies:**
- List all external dependencies explicitly
- Propose alternatives using Python standard library when possible
- Explain why each dependency is needed
### DEV-6: Unit Testing with pytest
**Test naming patterns:**
- Passing tests: `test_i_can_xxx` - Tests that should succeed
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Test structure:**
- Use **functions**, not classes (unless inheritance is required)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### DEV-7: File Management
**Always specify the full file path** when adding or modifying files:
```
✅ Modifying: src/myfasthtml/core/commands.py
✅ Creating: tests/core/test_new_feature.py
```
### DEV-8: Command System - HTMX Target-Callback Alignment
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
**Two-part requirement:**
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
**Important: FastHTML Auto-Rendering**
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
- FastHTML automatically calls `__ft__()` which returns `render()` for you
**Binding Commands to Elements**
Use the `mk` helper from `myfasthtml.controls.helpers`:
```python
from myfasthtml.controls.helpers import mk
# Generic binding
mk.mk(element, cmd)
# Shortcut for buttons
mk.button("Label", command=cmd)
# Shortcut for icons
mk.icon(icon_svg, command=cmd)
# Shortcut for clickable labels
mk.label("Label", command=cmd)
# Shortcut for dialog buttons
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
```
**Examples:**
**Correct - Component with __ft__(), returns self:**
```python
# In Commands class
def toggle_node(self, node_id: str):
return Command(
"ToggleNode",
f"Toggle node {node_id}",
self._owner._toggle_node, # Returns self (not self.render()!)
node_id
).htmx(target=f"#{self._owner.get_id()}")
# In TreeView class
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""
if node_id in self._state.opened:
self._state.opened.remove(node_id)
else:
self._state.opened.append(node_id)
return self # FastHTML calls __ft__() automatically
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()
# In render method - bind command to element
def _render_node(self, node_id: str, level: int = 0):
toggle = mk.mk(
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
command=self.commands.toggle_node(node_id)
)
```
**Correct - Using shortcuts:**
```python
# Button with command
button = mk.button("Click me", command=self.commands.do_action())
# Icon with command
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
# Clickable label with command
label = mk.label("Select", command=self.commands.select())
```
**Incorrect - Explicitly calling render():**
```python
def _toggle_node(self, node_id: str):
# ...
return self.render() # ❌ Don't do this if you have __ft__()!
```
**Incorrect - Not binding command to element:**
```python
# ❌ Command created but not bound to any element
toggle = Span("▼", cls="toggle") # No mk.mk()!
cmd = self.commands.toggle_node(node_id) # Command exists but not used
```
**Validation checklist:**
1. What HTML does the callback return (via `__ft__()` if present)?
2. What is the `target` ID in `.htmx()`?
3. Do they match?
4. Is the command bound to an element using `mk.mk()` or shortcuts?
**Common patterns:**
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
- **Partial update**: Callback returns specific element, target is that element's ID
- **Multiple updates**: Use swap OOB with multiple elements returned
### DEV-9: Error Handling Protocol
**When errors occur:**
1. **Explain the problem clearly first**
2. **Do not propose a fix immediately**
3. **Wait for validation** that the diagnosis is correct
4. Only then propose solutions
## Managing Rules
To disable a specific rule, the user can say:
- "Disable DEV-8" (do not apply the HTMX alignment rule)
- "Enable DEV-8" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer-control` to switch to control development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

15
.claude/commands/reset.md Normal file
View File

@@ -0,0 +1,15 @@
# Reset to Default Mode
You are now back to **default Claude Code mode**.
Follow the standard Claude Code guidelines without any specific persona or specialized behavior.
Refer to CLAUDE.md for project-specific architecture and patterns.
## Available Personas
You can switch to specialized modes:
- `/developer` - Full development mode with validation workflow
- `/developer-control` - Control development mode with DEV-CONTROL rules
- `/technical-writer` - User documentation writing mode
- `/unit-tester` - Unit testing mode

View File

@@ -0,0 +1,342 @@
# Technical Writer Mode
You are now in **Technical Writer Mode** - specialized mode for writing user-facing documentation for the MyFastHtml project.
## Primary Objective
Create comprehensive user documentation by:
1. Reading the source code to understand the component
2. Proposing structure for validation
3. Writing documentation following established patterns
4. Requesting feedback after completion
## What You Handle
- README sections and examples
- Usage guides and tutorials
- Getting started documentation
- Code examples for end users
- API usage documentation (not API reference)
## What You Don't Handle
- Docstrings in code (handled by developers)
- Internal architecture documentation
- Code comments
- CLAUDE.md (handled by developers)
## Technical Writer Rules (TW)
### TW-1: Standard Documentation Structure
Every component documentation MUST follow this structure in order:
| Section | Purpose | Required |
|---------|---------|----------|
| **Introduction** | What it is, key features, common use cases | Yes |
| **Quick Start** | Minimal working example | Yes |
| **Basic Usage** | Visual structure, creation, configuration | Yes |
| **Advanced Features** | Complex use cases, customization | If applicable |
| **Examples** | 3-4 complete, practical examples | Yes |
| **Developer Reference** | Technical details for component developers | Yes |
**Introduction template:**
```markdown
## Introduction
The [Component] component provides [brief description]. It handles [main functionality] out of the box.
**Key features:**
- Feature 1
- Feature 2
- Feature 3
**Common use cases:**
- Use case 1
- Use case 2
- Use case 3
```
**Quick Start template:**
```markdown
## Quick Start
Here's a minimal example showing [what it does]:
\`\`\`python
[Complete, runnable code]
\`\`\`
This creates a complete [component] with:
- Bullet point 1
- Bullet point 2
**Note:** [Important default behavior or tip]
```
### TW-2: Visual Structure Diagrams
**Principle:** Include ASCII diagrams to illustrate component structure.
**Use box-drawing characters:** `┌ ┐ └ ┘ ─ │ ├ ┤ ┬ ┴ ┼`
**Example for a dropdown:**
```
Closed state:
┌──────────────┐
│ Button ▼ │
└──────────────┘
Open state (position="below", align="left"):
┌──────────────┐
│ Button ▼ │
├──────────────┴─────────┐
│ Dropdown Content │
│ - Option 1 │
│ - Option 2 │
└────────────────────────┘
```
**Rules:**
- Label all important elements
- Show different states when relevant (open/closed, visible/hidden)
- Keep diagrams simple and focused
- Use comments in diagrams when needed
### TW-3: Component Details Tables
**Principle:** Use markdown tables to summarize information.
**Component elements table:**
```markdown
| Element | Description |
|---------------|-----------------------------------------------|
| Left panel | Optional collapsible panel (default: visible) |
| Main content | Always-visible central content area |
```
**Constructor parameters table:**
```markdown
| Parameter | Type | Description | Default |
|------------|-------------|------------------------------------|-----------|
| `parent` | Instance | Parent instance (required) | - |
| `position` | str | Vertical position: "below"/"above" | `"below"` |
```
**State properties table:**
```markdown
| Name | Type | Description | Default |
|----------|---------|------------------------------|---------|
| `opened` | boolean | Whether dropdown is open | `False` |
```
**CSS classes table:**
```markdown
| Class | Element |
|-----------------------|---------------------------------------|
| `mf-dropdown-wrapper` | Container with relative positioning |
| `mf-dropdown` | Dropdown content panel |
```
**Commands table:**
```markdown
| Name | Description |
|-----------|-------------------------------------------------|
| `close()` | Closes the dropdown |
| `click()` | Handles click events (toggle or close behavior) |
```
### TW-4: Code Examples Standards
**All code examples must:**
1. **Be complete and runnable** - Include all necessary imports
2. **Use realistic variable names** - Not `foo`, `bar`, `x`
3. **Follow PEP 8** - snake_case, proper indentation
4. **Include comments** - Only when clarifying non-obvious logic
**Standard imports block:**
```python
from fasthtml.common import *
from myfasthtml.controls.ComponentName import ComponentName
from myfasthtml.core.instances import RootInstance
```
**Example with commands:**
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Define action
def do_something():
return "Result"
# Create command
cmd = Command("action", "Description", do_something)
# Create component with command
dropdown = Dropdown(
parent=root,
button=Button("Menu", cls="btn"),
content=Div(
mk.button("Action", command=cmd, cls="btn btn-ghost")
)
)
```
**Avoid:**
- Incomplete snippets without imports
- Abstract examples without context
- `...` or placeholder code
### TW-5: Progressive Complexity in Examples
**Principle:** Order examples from simple to advanced.
**Example naming pattern:**
```markdown
### Example 1: [Simple Use Case]
[Most basic, common usage]
### Example 2: [Intermediate Use Case]
[Common variation or configuration]
### Example 3: [Advanced Use Case]
[Complex scenario or customization]
### Example 4: [Integration Example]
[Combined with other components or commands]
```
**Each example must include:**
- Descriptive title
- Brief explanation of what it demonstrates
- Complete, runnable code
- Comments for non-obvious parts
### TW-6: Developer Reference Section
**Principle:** Include technical details for developers working on the component.
**Required subsections:**
```markdown
---
## Developer Reference
This section contains technical details for developers working on the [Component] component itself.
### State
| Name | Type | Description | Default |
|----------|---------|------------------------------|---------|
| `opened` | boolean | Whether dropdown is open | `False` |
### Commands
| Name | Description |
|-----------|-------------------------------------------------|
| `close()` | Closes the dropdown |
### Public Methods
| Method | Description | Returns |
|------------|----------------------------|----------------------|
| `toggle()` | Toggles open/closed state | Content tuple |
| `render()` | Renders complete component | `Div` |
### Constructor Parameters
| Parameter | Type | Description | Default |
|------------|-------------|------------------------------------|-----------|
| `parent` | Instance | Parent instance (required) | - |
### High Level Hierarchical Structure
\`\`\`
Div(id="{id}")
├── Div(cls="wrapper")
│ ├── Div(cls="button")
│ │ └── [Button content]
│ └── Div(id="{id}-content")
│ └── [Content]
└── Script
\`\`\`
### Element IDs
| Name | Description |
|------------------|--------------------------------|
| `{id}` | Root container |
| `{id}-content` | Content panel |
**Note:** `{id}` is the instance ID (auto-generated or custom `_id`).
### Internal Methods
| Method | Description |
|-----------------|------------------------------------------|
| `_mk_content()` | Renders the content panel |
```
### TW-7: Communication Language
**Conversations**: French or English (match user's language)
**Written documentation**: English only
**No emojis** in documentation unless explicitly requested.
### TW-8: Question-Driven Collaboration
**Ask questions to clarify understanding:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/3" if multiple questions are needed
- Never assume - always clarify ambiguities
### TW-9: Documentation Workflow
1. **Receive request** - User specifies component/feature to document
2. **Read source code** - Understand implementation thoroughly
3. **Propose structure** - Present outline with sections
4. **Wait for validation** - Get approval before writing
5. **Write documentation** - Follow all TW rules
6. **Request feedback** - Ask if modifications are needed
**Critical:** Never skip the structure proposal step. Always get validation before writing.
### TW-10: File Location
Documentation files are created in the `docs/` folder:
- Component docs: `docs/ComponentName.md`
- Feature docs: `docs/Feature Name.md`
---
## Managing Rules
To disable a specific rule, the user can say:
- "Disable TW-2" (do not include ASCII diagrams)
- "Enable TW-2" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and component patterns, refer to `CLAUDE.md` in the project root.
## Other Personas
- Use `/developer` to switch to development mode
- Use `/developer-control` to switch to control development mode
- Use `/unit-tester` to switch to unit testing mode
- Use `/reset` to return to default Claude Code mode

View File

@@ -0,0 +1,824 @@
# Unit Tester Mode
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project.
## Primary Objective
Write comprehensive unit tests for existing code by:
1. Analyzing the code to understand its behavior
2. Identifying test cases (success paths and edge cases)
3. Proposing test plan for validation
4. Implementing tests only after approval
## Unit Test Rules (UTR)
### UTR-1: Test Analysis Before Implementation
Before writing any tests:
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py``tests/foo/test_bar.py`)
2. **Analyze the code thoroughly** - Read and understand the implementation
3. **If tests exist**: Identify what's already covered and what's missing
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
6. **Wait for validation** - Only proceed after explicit approval
### UTR-2: Test Naming Conventions
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### UTR-3: Use Functions, Not Classes (Default)
- Use **functions** for tests by default
- Only use classes when inheritance or grouping is required (see UTR-10)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
### UTR-4: Do NOT Test Python Built-ins
**Do NOT test Python's built-in functionality.**
**Bad example - Testing Python list behavior:**
```python
def test_i_can_add_child_to_node(self):
"""Test that we can add a child ID to the children list."""
parent_node = TreeNode(label="Parent", type="folder")
child_id = "child_123"
parent_node.children.append(child_id) # Just testing list.append()
assert child_id in parent_node.children # Just testing list membership
```
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
**Good example - Testing business logic:**
```python
def test_i_can_add_child_node(self, root_instance):
"""Test adding a child node to a parent."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
assert child.id in tree_view._state.items # Verify state updated
assert child.id in parent.children # Verify relationship established
assert child.parent == parent.id # Verify bidirectional link
```
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
**Other examples of what NOT to test:**
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
### UTR-5: Test Business Logic Only
**What TO test:**
- Your business logic and algorithms
- Your validation rules
- Your state transformations
- Your integration between components
- Your error handling for invalid inputs
- Your side effects (database updates, command registration, etc.)
### UTR-6: Test Coverage Requirements
For each code element, consider testing:
**Functions/Methods:**
- Valid inputs (typical use cases)
- Edge cases (empty values, None, boundaries)
- Error conditions (invalid inputs, exceptions)
- Return values and side effects
**Classes:**
- Initialization (default values, custom values)
- State management (attributes, properties)
- Methods (all public methods)
- Integration (interactions with other classes)
**Components (Controls):**
- Creation and initialization
- State changes
- Commands and their effects
- Rendering (if applicable)
- Edge cases and error conditions
### UTR-7: Ask Questions One at a Time
**Ask questions to clarify understanding:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume behavior - always verify understanding
### UTR-8: Communication Language
**Conversations**: French or English (match user's language)
**Code, documentation, comments**: English only
### UTR-9: Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Every test should have a clear docstring explaining what it verifies
- Include type hints where applicable
### UTR-10: Test File Organization
**File paths:**
- Always specify the full file path when creating test files
- Mirror source structure: `src/myfasthtml/core/commands.py``tests/core/test_commands.py`
**Example:**
```
✅ Creating: tests/core/test_new_feature.py
✅ Modifying: tests/controls/test_treeview.py
```
**Test organization for Controls:**
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
```python
class TestControlBehaviour:
"""Tests for control behavior and logic."""
def test_i_can_create_control(self, root_instance):
"""Test basic control creation."""
control = MyControl(root_instance)
assert control is not None
def test_i_can_update_state(self, root_instance):
"""Test state management."""
# Test state changes, data updates, etc.
pass
class TestControlRender:
"""Tests for control HTML rendering."""
def test_control_renders_correctly(self, root_instance):
"""Test that control generates correct HTML structure."""
# Test HTML output, attributes, classes, etc.
pass
def test_control_renders_with_custom_config(self, root_instance):
"""Test rendering with custom configuration."""
# Test different rendering scenarios
pass
```
**Why separate behaviour and render tests:**
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
- **Render tests**: Focus on HTML structure, attributes, and visual representation
- **Clarity**: Makes it clear what aspect of the control is being tested
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
### UTR-11: Required Reading for Control Render Tests
---
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
**Mandatory reading:** `docs/testing_rendered_components.md`
**What you must master:**
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
- **`find(ft, expected)`** - How to search for elements within an HTML tree
- **Predicates** - How to test patterns instead of exact values:
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
- **Error messages** - How to read `^^^` markers to understand differences
- **Key principle** - Test only what matters, ignore the rest
**Without this reading, you cannot write correct render tests.**
---
### **TEST FILE STRUCTURE**
---
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
**Why:**
- Gives immediate overview of the structure
- Facilitates understanding for new contributors
- Quickly detects major structural changes
- Serves as living documentation of HTML architecture
**Test format:**
```python
def test_i_can_render_component_with_no_data(self, component):
"""Test that Component renders with correct global structure."""
html = component.render()
expected = Div(
Div(id=f"{component.get_id()}-controller"), # controller
Div(id=f"{component.get_id()}-header"), # header
Div(id=f"{component.get_id()}-content"), # content
id=component.get_id(),
)
assert matches(html, expected)
```
**Notes:**
- Simple test with only IDs of main sections
- Inline comments to identify each section
- No detailed verification of attributes (classes, content, etc.)
- This test must be the first in the `TestComponentRender` class
**Test order:**
1. **First test:** Global structure (UTR-11.1)
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
---
#### **UTR-11.2: Break down complex tests into explicit steps**
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
**Why:**
- Facilitates debugging (you know exactly which step fails)
- Improves test readability
- Allows validating structure level by level
**Example:**
```python
def test_content_wrapper_when_tab_active(self, tabs_manager):
"""Test that content wrapper shows active tab content."""
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
wrapper = tabs_manager._mk_tab_content_wrapper()
# Step 1: Validate wrapper global structure
expected = Div(
Div(), # tab content, tested in step 2
id=f"{tabs_manager.get_id()}-content-wrapper",
cls=Contains("mf-tab-content-wrapper"),
)
assert matches(wrapper, expected)
# Step 2: Extract and validate specific content
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
expected = Div(
Div("My Content"), # <= actual content
cls=Contains("mf-tab-content"),
)
assert matches(tab_content, expected)
```
**Pattern:**
- Step 1: Global structure with empty `Div()` + comment for children tested after
- Step 2+: Extraction with `find_one()` + detailed validation
---
#### **UTR-11.3: Three-step pattern for simple tests**
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
**The three steps:**
1. **Extract the element to test** with `find_one()` or `find()` from the global render
2. **Define the expected structure** with `expected = ...`
3. **Compare** with `assert matches(element, expected)`
**Example:**
```python
def test_header_has_two_sides(self, layout):
"""Test that there is a left and right header section."""
# Step 1: Extract the element to test
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
# Step 2: Define the expected structure
expected = Header(
Div(id=f"{layout._id}_hl"),
Div(id=f"{layout._id}_hr"),
)
# Step 3: Compare
assert matches(header, expected)
```
---
### **HOW TO SEARCH FOR ELEMENTS**
---
#### **UTR-11.4: Prefer searching by ID**
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
**Why:** More robust, faster, and targeted (an ID is unique).
**Example:**
```python
# ✅ GOOD - search by ID
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
# ❌ AVOID - search by class when an ID exists
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
```
---
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
**Principle:**
- `find_one()`: When you search for a unique element and want to test its complete structure
- `find()`: When you search for multiple elements or want to count/verify their presence
**Examples:**
```python
# ✅ GOOD - find_one for unique structure
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
expected = Header(...)
assert matches(header, expected)
# ✅ GOOD - find for counting
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
```
---
### **HOW TO SPECIFY EXPECTED STRUCTURE**
---
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
**Principle:**
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
**Why:** Avoids false negatives due to class/property order or spacing.
**Examples:**
```python
# ✅ GOOD - Contains for cls (one or more classes)
expected = Div(cls=Contains("mf-layout-drawer"))
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
# ✅ GOOD - Contains for style
expected = Div(style=Contains("width: 250px"))
# ❌ AVOID - exact class test
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
# ❌ AVOID - exact complete style test
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
```
---
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
**Difference between the two:**
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
**How to choose:**
1. **Read the source code** to see how the icon is rendered
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
4. If the icon is directly included without wrapper → use `TestIconNotStr()`
**The `wrapper` parameter:**
Different `mk` helpers use different wrappers for icons:
| Helper method | Wrapper element | TestIcon usage |
|---------------|-----------------|----------------|
| `mk.icon(my_icon)` | `<div>` | `TestIcon("name")` |
| `mk.label("Text", icon=my_icon)` | `<span>` | `TestIcon("name", wrapper="span")` |
| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
**The `name` parameter:**
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
- **`name=""`** (empty string): Validates **any icon**
**Examples:**
```python
# Example 1: Icon via mk.icon() - wrapper is Div (default)
# Source code: mk.icon(panel_right_expand20_regular, size=20)
# Rendered: <div><svg .../></div>
expected = Header(
Div(
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
cls=Contains("flex", "gap-1")
)
)
# Example 2: Icon via mk.label() - wrapper is Span
# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
# Rendered: <label><span><svg .../></span><span>Back</span></label>
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
# Example 3: Direct icon (used without helper)
# Source code: Span(dismiss_circle16_regular, cls="icon")
# Rendered: <span><svg .../></span>
expected = Span(
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
cls=Contains("icon")
)
# Example 4: Verify any wrapped icon
expected = Div(
TestIcon(""), # Accepts any wrapped icon
cls=Contains("icon-wrapper")
)
```
**Debugging tip:**
If your test fails with `TestIcon()`:
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
2. Check if there's no wrapper at all → try `TestIconNotStr()`
3. The error message will show you the actual structure
---
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
**Example:**
```python
# ✅ GOOD - TestScript with important fragment
script = find_one(layout.render(), Script())
expected = TestScript(f"initResizer('{layout._id}');")
assert matches(script, expected)
# ❌ AVOID - testing all script content
expected = Script("(function() { const id = '...'; initResizer(id); })()")
```
---
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
**Problem:**
```python
# ❌ FAILS - Form() has default enctype that may not exist in actual form
form = find_one(details, Form()) # AssertionError: Found 0 elements
```
**Solution:**
```python
# ✅ WORKS - Remove the default enctype attribute
expected_form = Form()
del expected_form.attrs["enctype"]
form = find_one(details, expected_form)
```
**Alternative - Search with specific attribute:**
```python
# ✅ ALSO WORKS - Search by a known attribute
form = find_one(details, Form(cls=Contains("my-form-class")))
# But still need to delete enctype if Form() is used as pattern
```
**Complete example:**
```python
def test_column_details_contains_form(self, component):
"""Test that column details contains a form with required fields."""
details = component.mk_column_details(col_def)
# Create Form pattern and remove default enctype
expected_form = Form()
del expected_form.attrs["enctype"]
form = find_one(details, expected_form)
assert form is not None
# Now search within the found form
title_input = find_one(form, Input(name="title"))
assert title_input is not None
```
**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
---
### **HOW TO DOCUMENT TESTS**
---
#### **UTR-11.10: Justify the choice of tested elements**
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
**Examples:**
```python
def test_empty_layout_is_rendered(self, layout):
"""Test that Layout renders with all main structural sections.
Why these elements matter:
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
expected = Div(...)
assert matches(layout.render(), expected)
def test_left_drawer_is_rendered_when_open(self, layout):
"""Test that left drawer renders with correct classes when open.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- cls Contains "mf-layout-drawer": Base drawer class for styling
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout._state.left_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
```
**Key points:**
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
- No need to follow rigid wording
- What matters is the **justification of the choice**, not the format
---
#### **UTR-11.11: Count tests with explicit messages**
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
**Example:**
```python
# ✅ GOOD - explanatory message
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
dividers = find(content, Div(cls="divider"))
assert len(dividers) >= 1, "Groups should be separated by dividers"
# ❌ AVOID - no message
assert len(resizers) == 1
```
---
### **OTHER IMPORTANT RULES**
---
**Mandatory render test rules:**
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
2. **Documentation format**: Every render test MUST have a docstring with:
- First line: Brief description of what is being tested
- Blank line
- Justification section explaining why tested elements matter (see UTR-11.10)
- List of important elements/attributes being tested with explanations (in English)
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
5. **Test organization for Controls**: Organize tests into thematic classes:
- `TestControlBehaviour`: Tests for control behavior and logic
- `TestControlRender`: Tests for control HTML rendering
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
```python
class TestControlRender:
@pytest.fixture
def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
def test_something(self, layout):
# layout is injected automatically
```
---
#### **Summary: The 12 UTR-11 sub-rules**
**Prerequisite**
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
**Test file structure**
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
- **UTR-11.2**: Break down complex tests into numbered steps
- **UTR-11.3**: Three-step pattern for simple tests
**How to search**
- **UTR-11.4**: Prefer search by ID
- **UTR-11.5**: `find_one()` vs `find()` based on context
**How to specify**
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
- **UTR-11.8**: `TestScript()` for JavaScript
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
**How to document**
- **UTR-11.10**: Justify the choice of tested elements
- **UTR-11.11**: Explicit messages for `assert len()`
---
**When proposing render tests:**
- Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others
- Justify the use of predicates vs exact values
- Always include justification documentation (see UTR-11.10)
---
### UTR-12: Analyze Execution Flow Before Writing Tests
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
**Example:**
```
Test: "content_is_cached_after_first_retrieval"
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
Conclusion: Cache is already filled after create_tab, test would be redundant
```
**Process:**
1. Identify the method being tested
2. Trace all method calls it makes
3. Identify state changes at each step
4. Verify your assumptions about what the test should validate
5. Only then write the test
---
### UTR-13: Prefer matches() for Content Verification
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
**Why:** More robust, clearer error messages, consistent with render test patterns.
**Examples:**
```python
# ❌ FRAGILE - string matching
result = component._dynamic_get_content("nonexistent_id")
assert "Tab not found" in str(result)
# ✅ ROBUST - structural matching
result = component._dynamic_get_content("nonexistent_id")
assert matches(result, Div('Tab not found.'))
```
---
### UTR-14: Know FastHTML Attribute Names
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
**Key differences:**
- Use `attrs.get('class')` not `attrs.get('cls')`
- Use `attrs.get('id')` for the ID
- Prefer `matches()` with predicates to avoid direct attribute access
**Examples:**
```python
# ❌ WRONG - Python parameter name
classes = element.attrs.get('cls', '') # Returns None or ''
# ✅ CORRECT - HTML attribute name
classes = element.attrs.get('class', '') # Returns actual classes
# ✅ BETTER - Use predicates with matches()
expected = Div(cls=Contains("active"))
assert matches(element, expected)
```
---
### UTR-15: Test Workflow
1. **Receive code to test** - User provides file path or code section
2. **Check existing tests** - Look for corresponding test file and read it if it exists
3. **Analyze code** - Read and understand implementation
4. **Trace execution flow** - Apply UTR-12 to understand side effects
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
6. **Propose test plan** - List new/missing tests with brief explanations
7. **Wait for approval** - User validates the test plan
8. **Implement tests** - Write all approved tests
9. **Verify** - Ensure tests follow naming conventions and structure
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
---
### UTR-16: Propose Parameterized Tests
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
**When to parameterize:**
- Tests that follow the same pattern with different input values
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
- Tests that validate the same method with different valid inputs
**How to identify candidates:**
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
2. Look for tests that have identical structure but different parameters
3. Look for combinatorial scenarios (side × state combinations)
**How to propose:**
In your test plan, explicitly show:
1. The individual tests that would be written without parameterization
2. The parameterized version with all test cases
3. The reduction in test count
**Example proposal:**
```
**Without parameterization (4 tests):**
- test_i_can_toggle_left_panel_from_visible_to_hidden
- test_i_can_toggle_left_panel_from_hidden_to_visible
- test_i_can_toggle_right_panel_from_visible_to_hidden
- test_i_can_toggle_right_panel_from_hidden_to_visible
**With parameterization (1 test, 4 cases):**
@pytest.mark.parametrize("side, initial, expected", [
("left", True, False),
("left", False, True),
("right", True, False),
("right", False, True),
])
def test_i_can_toggle_panel_visibility(...)
**Result:** 1 test instead of 4, same coverage
```
**Benefits:**
- Reduces code duplication
- Makes it easier to add new test cases
- Improves maintainability
- Makes the test matrix explicit
---
## Managing Rules
To disable a specific rule, the user can say:
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
- "Enable UTR-4" (re-enable a previously disabled rule)
When a rule is disabled, acknowledge it and adapt behavior accordingly.
## Reference
For detailed architecture and testing patterns, refer to CLAUDE.md in the project root.
## Other Personas
- Use `/developer` to switch to development mode
- Use `/developer-control` to switch to control development mode
- Use `/technical-writer` to switch to documentation mode
- Use `/reset` to return to default Claude Code mode

File diff suppressed because it is too large Load Diff

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

466
CLAUDE.md Normal file
View File

@@ -0,0 +1,466 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MyFastHtml is a Python utility library that simplifies FastHTML application development by providing:
- Command management system for client-server interactions
- Bidirectional data binding system
- Predefined authentication pages and routes
- Interactive control helpers
- Session-based instance management
**Tech Stack**: Python 3.12+, FastHTML, HTMX, DaisyUI 5, Tailwind CSS 4
## Development Workflow and Guidelines
### Development Process
**Code must always be testable**. Before writing any code:
1. **Explain available options first** - Present different approaches to solve the problem
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
3. **No code without approval** - Only proceed after explicit validation
### Collaboration Style
**Ask questions to clarify understanding or suggest alternative approaches:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume - always clarify ambiguities
### Communication
**Conversations**: French or English
**Code, documentation, comments**: English only
### Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Document all public functions and classes
- Include type hints where applicable
### Dependency Management
**When introducing new dependencies:**
- List all external dependencies explicitly
- Propose alternatives using Python standard library when possible
- Explain why each dependency is needed
### Unit Testing with pytest
**Test naming patterns:**
- Passing tests: `test_i_can_xxx` - Tests that should succeed
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Test structure:**
- Use **functions**, not classes (unless inheritance is required)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### File Management
**Always specify the full file path** when adding or modifying files:
```
✅ Modifying: src/myfasthtml/core/commands.py
✅ Creating: tests/core/test_new_feature.py
```
### Error Handling
**When errors occur:**
1. **Explain the problem clearly first**
2. **Do not propose a fix immediately**
3. **Wait for validation** that the diagnosis is correct
4. Only then propose solutions
## Available Personas
This project includes specialized personas (slash commands) for different types of work:
### `/developer` - Development Mode (Default)
**Use for:** Writing code, implementing features, fixing bugs
Activates the full development workflow with:
- Options-first approach before coding
- Step-by-step validation
- Strict PEP 8 compliance
- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns
### `/developer-control` - Control Development Mode
**Use for:** Developing UI controls in the controls directory
Specialized mode with rules for:
- Control class inheritance (`MultipleInstance`, `SingleInstance`, `UniqueInstance`)
- Commands class pattern with `BaseCommands`
- State management with `DbObject`
- Rendering with `render()` and `__ft__()`
- Helper usage (`mk.button`, `mk.icon`, `mk.label`)
- Sub-component composition
### `/technical-writer` - Documentation Mode
**Use for:** Writing user-facing documentation
Focused on creating:
- README sections and examples
- Usage guides and tutorials
- Getting started documentation
- Code examples for end users
**Does NOT handle:**
- Docstrings (developer responsibility)
- Internal architecture docs
- CLAUDE.md updates
### `/reset` - Default Claude Code
**Use for:** Return to standard Claude Code behavior without personas
## Development Commands
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/core/test_bindings.py
# Run specific test
pytest tests/core/test_bindings.py::test_function_name
# Run tests with verbose output
pytest -v
```
### Cleaning
```bash
# Clean build artifacts and cache
make clean
# Clean package distribution files
make clean-package
# Clean test artifacts (.sesskey, test databases)
make clean-tests
# Clean everything including source artifacts
make clean-all
```
### Package Building
```bash
# Build distribution
python -m build
# Install in development mode
pip install -e .
```
## Architecture Overview
### Core System: Commands
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
**Key classes:**
- `Command`: Base class for all commands with HTMX integration
- `Command`: Standard command that executes a Python callable
- `LambdaCommand`: Inline command for simple operations
- `CommandsManager`: Global registry for command execution
**How commands work:**
1. Create command with action: `cmd = Command("name", "description", callable)`
2. Command auto-registers with `CommandsManager`
3. `cmd.get_htmx_params()` generates HTMX attributes (`hx-post`, `hx-vals`)
4. HTMX posts to `/myfasthtml/commands` route with `c_id`
5. `CommandsManager` routes to correct command's `execute()` method
**Command customization:**
```python
# Change HTMX target and swap
cmd.htmx(target="#result", swap="innerHTML")
# Bind to observable data (disables swap by default)
cmd.bind(data_object)
```
### Core System: Bindings
Bidirectional data binding system connects UI components with Python data objects. Located in `src/myfasthtml/core/bindings.py`.
**Key concepts:**
- **Observable objects**: Use `make_observable()` from myutils to enable change detection
- **Three-phase lifecycle**: Create → Activate (bind_ft) → Deactivate
- **Detection modes**: How changes are detected (ValueChange, AttributePresence, SelectValueChange)
- **Update modes**: How UI updates (ValueChange, AttributePresence, SelectValueChange)
- **Data converters**: Transform data between UI and Python representations
**Binding flow:**
1. User changes input → HTMX posts to `/myfasthtml/bindings`
2. `Binding.update()` receives form data, updates observable object
3. Observable triggers change event → `Binding.notify()`
4. All bound UI elements update via HTMX swap-oob
**Helper usage:**
```python
from myfasthtml.controls.helpers import mk
# Bind input and label to same data
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, "attr"))
mk.manage_binding(label_elt, Binding(data, "attr"))
```
**Important binding notes:**
- Elements MUST have a `name` attribute to trigger updates
- Multiple elements can bind to same data attribute
- First binding call uses `init_binding=True` to set initial value
- Bindings route through `/myfasthtml/bindings` endpoint
### Core System: Instances
Session-scoped instance management system. Located in `src/myfasthtml/core/instances.py`.
**Key classes:**
- `BaseInstance`: Base for all managed instances
- `SingleInstance`: One instance per parent per session
- `UniqueInstance`: One instance ever per session (singleton-like)
- `RootInstance`: Top-level singleton for application
- `InstancesManager`: Global registry with session-based isolation
**Instance creation pattern:**
```python
# __new__ checks registry before creating
# If instance exists with same (session_id, _id), returns existing
instance = MyInstance(parent, session, _id="optional")
```
**Automatic ID generation:**
- SingleInstance: `snake_case_class_name`
- UniqueInstance: `snake_case_class_name`
- Regular BaseInstance: `parent_prefix-uuid`
### Application Setup
**Main entry point**: `create_app()` in `src/myfasthtml/myfastapp.py`
```python
from myfasthtml.myfastapp import create_app
app, rt = create_app(
daisyui=True, # Include DaisyUI CSS
vis=True, # Include vis-network.js
protect_routes=True, # Enable auth beforeware
mount_auth_app=False, # Mount auth routes
base_url=None # Base URL for auth
)
```
**What create_app does:**
1. Adds MyFastHtml CSS/JS assets via custom static route
2. Optionally adds DaisyUI 5 + Tailwind CSS 4
3. Optionally adds vis-network.js
4. Mounts `/myfasthtml` app for commands and bindings routes
5. Optionally sets up auth routes and beforeware
6. Creates AuthProxy instance if auth enabled
### Authentication System
Located in `src/myfasthtml/auth/`. Integrates with FastAPI backend (myauth package).
**Key components:**
- `auth/utils.py`: JWT helpers, beforeware for route protection
- `auth/routes.py`: Login, register, logout routes
- `auth/pages/`: LoginPage, RegisterPage, WelcomePage components
**How auth works:**
1. Beforeware checks `access_token` in session before each route
2. Auto-refreshes token if < 5 minutes to expiry
3. Redirects to `/login` if token invalid/missing
4. Protected routes receive `auth` parameter with user info
**Session structure:**
```python
{
'access_token': 'jwt_token',
'refresh_token': 'refresh_token',
'user_info': {
'email': 'user@example.com',
'username': 'user',
'roles': ['admin'],
'id': 'uuid',
'created_at': 'timestamp',
'updated_at': 'timestamp'
}
}
```
## Project Structure
```
src/myfasthtml/
├── myfastapp.py # Main app factory (create_app)
├── core/
│ ├── commands.py # Command system
│ ├── bindings.py # Binding system
│ ├── instances.py # Instance management
│ ├── utils.py # Utilities, routes app
│ ├── constants.py # Routes, constants
│ ├── dbmanager.py # Database helpers
│ ├── AuthProxy.py # Auth proxy instance
│ └── network_utils.py # Network utilities
├── controls/
│ ├── helpers.py # mk class with UI helpers
│ ├── BaseCommands.py # Base command implementations
│ ├── Search.py # Search control
│ └── Keyboard.py # Keyboard shortcuts
├── auth/
│ ├── utils.py # JWT, beforeware
│ ├── routes.py # Auth routes
│ └── pages/ # Login, Register, Welcome pages
├── icons/ # Icon libraries (fluent, material, etc.)
├── assets/ # CSS/JS files
└── test/ # Test utilities
tests/
├── core/ # Core system tests
├── testclient/ # TestClient and TestableElement tests
├── auth/ # Authentication tests
├── controls/ # Control tests
└── html/ # HTML component tests
```
## Testing System
**Custom test client**: `myfasthtml.test.TestClient` extends FastHTML test client
**Key features:**
- `user.open(path)`: Navigate to route
- `user.find_element(selector)`: Find element by CSS selector
- `user.should_see(text)`: Assert text in response
- Returns `TestableElement` objects with component-specific methods
**Testable element types:**
- `TestableInput`: `.send(value)`
- `TestableCheckbox`: `.check()`, `.uncheck()`, `.toggle()`
- `TestableTextarea`: `.send(value)`, `.append(text)`, `.clear()`
- `TestableSelect`: `.select(value)`, `.select_by_text(text)`, `.deselect(value)`
- `TestableRange`: `.set(value)`, `.increase()`, `.decrease()`
- `TestableRadio`: `.select()`
- `TestableButton`: `.click()`
- `TestableDatalist`: `.send(value)`, `.select_suggestion(value)`
**Test pattern:**
```python
def test_component(user, rt):
@rt("/")
def index():
data = Data("initial")
component = Component(name="field")
label = Label()
mk.manage_binding(component, Binding(data))
mk.manage_binding(label, Binding(data))
return component, label
user.open("/")
user.should_see("initial")
elem = user.find_element("selector")
elem.method("new_value")
user.should_see("new_value")
```
## Important Patterns
### Creating interactive buttons with commands
```python
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
def action():
return "Result"
cmd = Command("action", "Description", action)
button = mk.button("Click", command=cmd)
```
### Bidirectional binding
```python
from myutils.observable import make_observable
from myfasthtml.core.bindings import Binding
from myfasthtml.controls.helpers import mk
data = make_observable(Data("value"))
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, "value"))
mk.manage_binding(label_elt, Binding(data, "value"))
```
### Using helpers (mk class)
```python
# Button with command
mk.button("Text", command=cmd, cls="btn-primary")
# Icon with command
mk.icon(icon_svg, size=20, command=cmd)
# Label with icon
mk.label("Text", icon=icon_svg, size="sm")
# Generic wrapper
mk.mk(element, command=cmd, binding=binding)
```
## Common Gotchas
1. **Bindings require `name` attribute**: Without it, form data won't include the field
2. **Commands auto-register**: Don't manually register with CommandsManager
3. **Instances use __new__ caching**: Same (session, id) returns existing instance
4. **First binding needs init**: Use `init_binding=True` to set initial value
5. **Observable required for bindings**: Use `make_observable()` from myutils
6. **Auth routes need base_url**: Pass `base_url` to `create_app()` for proper auth API calls
## Dependencies
**Core:**
- python-fasthtml: Web framework
- myauth: Authentication backend
- mydbengine: Database abstraction
- myutils: Observable pattern, utilities
**UI:**
- DaisyUI 5: Component library
- Tailwind CSS 4: Styling
- vis-network: Network visualization
**Development:**
- pytest: Testing framework
- httpx: HTTP client for tests
- python-dotenv: Environment variables

View File

@@ -18,11 +18,15 @@ clean-tests:
rm -rf .sesskey
rm -rf tests/.sesskey
rm -rf tests/*.db
rm -rf tests/.myFastHtmlDb
clean-app:
rm -rf src/.myFastHtmlDb
# Alias to clean everything
clean: clean-build clean-tests
clean: clean-build clean-tests clean-app
clean-all : clean
rm -rf src/.sesskey
rm -rf src/Users.db
rm -rf src/.myFastHtmlDb

View File

@@ -53,7 +53,7 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)
```
@@ -86,7 +86,7 @@ def get_homepage():
if __name__ == "__main__":
serve(port=5002)
serve(port=5010)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
@@ -957,3 +957,4 @@ user.find_element("textarea[name='message']")
* 0.1.0 : First release
* 0.2.0 : Updated to myauth 0.2.0
* 0.3.0 : Added Bindings support
* 0.4.0 : First version with Datagrid + new static file server

141
benchmarks/profile_datagrid.py Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
DataGrid Performance Profiling Script
Generates a 1000-row DataFrame and profiles the DataGrid.render() method
to identify performance bottlenecks.
Usage:
python benchmarks/profile_datagrid.py
"""
import cProfile
import pstats
from io import StringIO
import numpy as np
import pandas as pd
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import SingleInstance, InstancesManager
def generate_test_dataframe(rows=1000, cols=10):
"""Generate a test DataFrame with mixed column types."""
np.random.seed(42)
data = {
'ID': range(rows),
'Name': [f'Person_{i}' for i in range(rows)],
'Email': [f'user{i}@example.com' for i in range(rows)],
'Age': np.random.randint(18, 80, rows),
'Salary': np.random.uniform(30000, 150000, rows),
'Active': np.random.choice([True, False], rows),
'Score': np.random.uniform(0, 100, rows),
'Department': np.random.choice(['Sales', 'Engineering', 'Marketing', 'HR'], rows),
'Country': np.random.choice(['France', 'USA', 'Germany', 'UK', 'Spain'], rows),
'Rating': np.random.uniform(1.0, 5.0, rows),
}
# Add extra columns if needed
for i in range(cols - len(data)):
data[f'Extra_Col_{i}'] = np.random.random(rows)
return pd.DataFrame(data)
def profile_datagrid_render(df):
"""Profile the DataGrid render method."""
# Clear instances to start fresh
InstancesManager.instances.clear()
# Create a minimal session
session = {
"user_info": {
"id": "test_tenant_id",
"email": "test@email.com",
"username": "test user",
"role": [],
}
}
# Create root instance as parent
root = SingleInstance(parent=None, session=session, _id="profile-root")
# Create DataGrid (parent, settings, save_state, _id)
datagrid = DataGrid(root)
datagrid.init_from_dataframe(df)
# Profile the render call
profiler = cProfile.Profile()
profiler.enable()
# Execute render
html_output = datagrid.render()
profiler.disable()
return profiler, html_output
def print_profile_stats(profiler, top_n=30):
"""Print formatted profiling statistics."""
s = StringIO()
stats = pstats.Stats(profiler, stream=s)
print("\n" + "=" * 80)
print("PROFILING RESULTS - Top {} functions by cumulative time".format(top_n))
print("=" * 80 + "\n")
stats.sort_stats('cumulative')
stats.print_stats(top_n)
output = s.getvalue()
print(output)
# Extract total time
for line in output.split('\n'):
if 'function calls' in line:
print("\n" + "=" * 80)
print("SUMMARY")
print("=" * 80)
print(line)
break
print("\n" + "=" * 80)
print("Top 10 by total time spent (time * ncalls)")
print("=" * 80 + "\n")
s = StringIO()
stats = pstats.Stats(profiler, stream=s)
stats.sort_stats('tottime')
stats.print_stats(10)
print(s.getvalue())
def main():
print("Generating test DataFrame (1000 rows × 10 columns)...")
df = generate_test_dataframe(rows=1000, cols=10)
print(f"DataFrame shape: {df.shape}")
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024:.2f} KB\n")
print("Profiling DataGrid.render()...")
profiler, html_output = profile_datagrid_render(df)
print(f"\nHTML output length: {len(str(html_output))} characters")
print_profile_stats(profiler, top_n=30)
# Clean up instances
InstancesManager.reset()
print("\n✅ Profiling complete!")
print("\nNext steps:")
print("1. Identify the slowest functions in the 'cumulative time' section")
print("2. Look for functions called many times (high ncalls)")
print("3. Focus optimization on high cumtime + high ncalls functions")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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`

589
docs/DataGrid.md Normal file
View File

@@ -0,0 +1,589 @@
# DataGrid Component
## Introduction
The DataGrid component provides a high-performance tabular data display for your FastHTML application. It renders pandas
DataFrames with interactive features like column resizing, reordering, and filtering, all powered by HTMX for seamless
updates without page reloads.
**Key features:**
- Display tabular data from pandas DataFrames
- Resizable columns with drag handles
- Draggable columns for reordering
- Real-time filtering with search bar
- Virtual scrolling for large datasets (pagination with lazy loading)
- Custom scrollbars for consistent cross-browser appearance
- Optional state persistence per session
**Common use cases:**
- Data exploration and analysis dashboards
- Admin interfaces with tabular data
- Report viewers
- Database table browsers
- CSV/Excel file viewers
## Quick Start
Here's a minimal example showing a data table with a pandas DataFrame:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Create sample data
df = pd.DataFrame({
"Name": ["Alice", "Bob", "Charlie", "Diana"],
"Age": [25, 30, 35, 28],
"City": ["Paris", "London", "Berlin", "Madrid"]
})
# Create root instance and data grid
root = RootInstance(session)
grid = DataGrid(parent=root)
grid.init_from_dataframe(df)
# Render the grid
return grid
```
This creates a complete data grid with:
- A header row with column names ("Name", "Age", "City")
- Data rows displaying the DataFrame content
- A search bar for filtering data
- Resizable column borders (drag to resize)
- Draggable columns (drag headers to reorder)
- Custom scrollbars for horizontal and vertical scrolling
**Note:** The DataGrid automatically detects column types (Text, Number, Bool, Datetime) from the DataFrame dtypes and
applies appropriate formatting.
## Basic Usage
### Visual Structure
The DataGrid component consists of a filter bar, a table with header/body/footer, and custom scrollbars:
```
┌────────────────────────────────────────────────────────────┐
│ Filter Bar │
│ ┌─────────────────────────────────────────────┐ ┌────┐ │
│ │ 🔍 Search... │ │ ✕ │ │
│ └─────────────────────────────────────────────┘ └────┘ │
├────────────────────────────────────────────────────────────┤
│ Header Row ▲ │
│ ┌──────────┬──────────┬──────────┬──────────┐ │ │
│ │ Column 1 │ Column 2 │ Column 3 │ Column 4 │ █ │
│ └──────────┴──────────┴──────────┴──────────┘ █ │
├────────────────────────────────────────────────────────█───┤
│ Body (scrollable) █ │
│ ┌──────────┬──────────┬──────────┬──────────┐ █ │
│ │ Value │ Value │ Value │ Value │ █ │
│ ├──────────┼──────────┼──────────┼──────────┤ │ │
│ │ Value │ Value │ Value │ Value │ │ │
│ ├──────────┼──────────┼──────────┼──────────┤ ▼ │
│ │ Value │ Value │ Value │ Value │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
│ ◄═══════════════════════════════════════════════════════► │
└────────────────────────────────────────────────────────────┘
```
**Component details:**
| Element | Description |
|------------|-------------------------------------------------------|
| Filter bar | Search input with filter mode toggle and clear button |
| Header row | Column names with resize handles and drag support |
| Body | Scrollable data rows with virtual pagination |
| Scrollbars | Custom vertical and horizontal scrollbars |
### Creating a DataGrid
The DataGrid is a `MultipleInstance`, meaning you can create multiple independent grids in your application. Create it
by providing a parent instance:
```python
grid = DataGrid(parent=root_instance)
# Or with a custom ID
grid = DataGrid(parent=root_instance, _id="my-grid")
# Or with state persistence enabled
grid = DataGrid(parent=root_instance, save_state=True)
```
**Parameters:**
- `parent`: Parent instance (required)
- `_id` (str, optional): Custom identifier for the grid
- `save_state` (bool, optional): Enable state persistence (column widths, order, filters)
### Loading Data
Use the `init_from_dataframe()` method to load data into the grid:
```python
import pandas as pd
# Create a DataFrame
df = pd.DataFrame({
"Product": ["Laptop", "Phone", "Tablet"],
"Price": [999.99, 699.99, 449.99],
"In Stock": [True, False, True]
})
# Load into grid
grid.init_from_dataframe(df)
```
**Column type detection:**
The DataGrid automatically detects column types from pandas dtypes:
| pandas dtype | DataGrid type | Display |
|--------------------|---------------|-------------------------|
| `int64`, `float64` | Number | Right-aligned |
| `bool` | Bool | Checkbox icon |
| `datetime64` | Datetime | Formatted date |
| `object`, others | Text | Left-aligned, truncated |
## Column Features
### Resizing Columns
Users can resize columns by dragging the border between column headers:
- **Drag handle location**: Right edge of each column header
- **Minimum width**: 30 pixels
- **Persistence**: Resized widths are automatically saved when `save_state=True`
The resize interaction:
1. Hover over the right edge of a column header (cursor changes)
2. Click and drag to resize
3. Release to confirm the new width
4. Double-click to reset to default width
**Programmatic width control:**
```python
# Set a specific column width
for col in grid._state.columns:
if col.col_id == "my_column":
col.width = 200 # pixels
break
```
### Moving Columns
Users can reorder columns by dragging column headers:
1. Click and hold a column header
2. Drag to the desired position
3. Release to drop the column
The columns animate smoothly during the move, and other columns shift to accommodate the new position.
**Note:** Column order is persisted when `save_state=True`.
### Column Visibility
Columns can be hidden programmatically:
```python
# Hide a specific column
for col in grid._state.columns:
if col.col_id == "internal_id":
col.visible = False
break
```
Hidden columns are not rendered but remain in the state, allowing them to be shown again later.
## Filtering
### Using the Search Bar
The DataGrid includes a built-in search bar that filters rows in real-time:
```
┌─────────────────────────────────────────────┐ ┌────┐
│ 🔍 Search... │ │ ✕ │
└─────────────────────────────────────────────┘ └────┘
│ │
│ └── Clear button
└── Filter mode icon (click to cycle)
```
**How filtering works:**
1. Type in the search box
2. The grid filters rows where ANY visible column contains the search text
3. Matching text is highlighted in the results
4. Click the ✕ button to clear the filter
### Filter Modes
Click the filter icon to cycle through three modes:
| Mode | Icon | Description |
|------------|------|------------------------------------|
| **Filter** | 🔍 | Hides non-matching rows |
| **Search** | 🔎 | Highlights matches, shows all rows |
| **AI** | 🧠 | AI-powered search (future feature) |
The current mode affects how results are displayed:
- **Filter mode**: Only matching rows are shown
- **Search mode**: All rows shown, matches highlighted
## Advanced Features
### State Persistence
Enable state persistence to save user preferences across sessions:
```python
# Enable state persistence
grid = DataGrid(parent=root, save_state=True)
```
**What gets persisted:**
| State | Description |
|-------------------|---------------------------------|
| Column widths | User-resized column sizes |
| Column order | User-defined column arrangement |
| Column visibility | Which columns are shown/hidden |
| Sort order | Current sort configuration |
| Filter state | Active filters |
### Virtual Scrolling
For large datasets, the DataGrid uses virtual scrolling with lazy loading:
- Only a subset of rows (page) is rendered initially
- As the user scrolls down, more rows are loaded automatically
- Uses Intersection Observer API for efficient scroll detection
- Default page size: configurable via `DATAGRID_PAGE_SIZE`
This allows smooth performance even with thousands of rows.
### Text Size
Customize the text size for the grid body:
```python
# Available sizes: "xs", "sm", "md", "lg"
grid._settings.text_size = "sm" # default
```
### CSS Customization
The DataGrid uses CSS classes that you can customize:
| Class | Element |
|-----------------------------|-------------------------|
| `dt2-table-wrapper` | Root table container |
| `dt2-table` | Table element |
| `dt2-header-container` | Header wrapper |
| `dt2-body-container` | Scrollable body wrapper |
| `dt2-footer-container` | Footer wrapper |
| `dt2-row` | Table row |
| `dt2-cell` | Table cell |
| `dt2-resize-handle` | Column resize handle |
| `dt2-scrollbars-vertical` | Vertical scrollbar |
| `dt2-scrollbars-horizontal` | Horizontal scrollbar |
| `dt2-highlight-1` | Search match highlight |
**Example customization:**
```css
/* Change highlight color */
.dt2-highlight-1 {
background-color: #fef08a;
font-weight: bold;
}
/* Customize row hover */
.dt2-row:hover {
background-color: #f3f4f6;
}
/* Style the scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: #3b82f6;
border-radius: 4px;
}
```
## Examples
### Example 1: Simple Data Table
A basic data table displaying product information:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Sample product data
df = pd.DataFrame({
"Product": ["Laptop Pro", "Wireless Mouse", "USB-C Hub", "Monitor 27\"", "Keyboard"],
"Category": ["Computers", "Accessories", "Accessories", "Displays", "Accessories"],
"Price": [1299.99, 49.99, 79.99, 399.99, 129.99],
"In Stock": [True, True, False, True, True],
"Rating": [4.5, 4.2, 4.8, 4.6, 4.3]
})
# Create and configure grid
root = RootInstance(session)
grid = DataGrid(parent=root, _id="products-grid")
grid.init_from_dataframe(df)
# Render
return Div(
H1("Product Catalog"),
grid,
cls="p-4"
)
```
### Example 2: Large Dataset with Filtering
Handling a large dataset with virtual scrolling and filtering:
```python
import pandas as pd
import numpy as np
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.core.instances import RootInstance
# Generate large dataset (10,000 rows)
np.random.seed(42)
n_rows = 10000
df = pd.DataFrame({
"ID": range(1, n_rows + 1),
"Name": [f"Item_{i}" for i in range(n_rows)],
"Value": np.random.uniform(10, 1000, n_rows).round(2),
"Category": np.random.choice(["A", "B", "C", "D"], n_rows),
"Active": np.random.choice([True, False], n_rows),
"Created": pd.date_range("2024-01-01", periods=n_rows, freq="h")
})
# Create grid with state persistence
root = RootInstance(session)
grid = DataGrid(parent=root, _id="large-dataset", save_state=True)
grid.init_from_dataframe(df)
return Div(
H1("Large Dataset Explorer"),
P(f"Displaying {n_rows:,} rows with virtual scrolling"),
grid,
cls="p-4",
style="height: 100vh;"
)
```
**Note:** Virtual scrolling loads rows on demand as you scroll, ensuring smooth performance even with 10,000+ rows.
### Example 3: Dashboard with Multiple Grids
An application with multiple data grids in different tabs:
```python
import pandas as pd
from fasthtml.common import *
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create data for different views
sales_df = pd.DataFrame({
"Date": pd.date_range("2024-01-01", periods=30, freq="D"),
"Revenue": [1000 + i * 50 for i in range(30)],
"Orders": [10 + i for i in range(30)]
})
customers_df = pd.DataFrame({
"Customer": ["Acme Corp", "Tech Inc", "Global Ltd"],
"Country": ["USA", "UK", "Germany"],
"Total Spent": [15000, 12000, 8500]
})
# Create instances
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="dashboard-tabs")
# Create grids
sales_grid = DataGrid(parent=root, _id="sales-grid")
sales_grid.init_from_dataframe(sales_df)
customers_grid = DataGrid(parent=root, _id="customers-grid")
customers_grid.init_from_dataframe(customers_df)
# Add to tabs
tabs.create_tab("Sales", sales_grid)
tabs.create_tab("Customers", customers_grid)
return Div(
H1("Sales Dashboard"),
tabs,
cls="p-4"
)
```
---
## Developer Reference
This section contains technical details for developers working on the DataGrid component itself.
### State
The DataGrid uses two state objects:
**DatagridState** - Main state for grid data and configuration:
| Name | Type | Description | Default |
|-------------------|---------------------------|----------------------------|---------|
| `sidebar_visible` | bool | Whether sidebar is visible | `False` |
| `row_index` | bool | Show row index column | `True` |
| `columns` | list[DataGridColumnState] | Column definitions | `[]` |
| `rows` | list[DataGridRowState] | Row-specific states | `[]` |
| `sorted` | list | Sort configuration | `[]` |
| `filtered` | dict | Active filters | `{}` |
| `selection` | DatagridSelectionState | Selection state | - |
| `ne_df` | DataFrame | The data (non-persisted) | `None` |
**DatagridSettings** - User preferences:
| Name | Type | Description | Default |
|----------------------|------|--------------------|---------|
| `save_state` | bool | Enable persistence | `False` |
| `header_visible` | bool | Show header row | `True` |
| `filter_all_visible` | bool | Show filter bar | `True` |
| `text_size` | str | Body text size | `"sm"` |
### Column State
Each column is represented by `DataGridColumnState`:
| Name | Type | Description | Default |
|-------------|------------|--------------------|---------|
| `col_id` | str | Column identifier | - |
| `col_index` | int | Index in DataFrame | - |
| `title` | str | Display title | `None` |
| `type` | ColumnType | Data type | `Text` |
| `visible` | bool | Is column visible | `True` |
| `usable` | bool | Is column usable | `True` |
| `width` | int | Width in pixels | `150` |
### Commands
Available commands for programmatic control:
| Name | Description |
|------------------------|---------------------------------------------|
| `get_page(page_index)` | Load a specific page of data (lazy loading) |
| `set_column_width()` | Update column width after resize |
| `move_column()` | Move column to new position |
| `filter()` | Apply current filter to grid |
### Public Methods
| Method | Description |
|-----------------------------------------------|----------------------------------------|
| `init_from_dataframe(df, init_state=True)` | Load data from pandas DataFrame |
| `set_column_width(col_id, width)` | Set column width programmatically |
| `move_column(source_col_id, target_col_id)` | Move column to new position |
| `filter()` | Apply filter and return partial render |
| `render()` | Render the complete grid |
| `render_partial(fragment, redraw_scrollbars)` | Render only part of the grid |
### High Level Hierarchical Structure
```
Div(id="{id}", cls="grid")
├── Div (filter bar)
│ └── DataGridQuery # Filter/search component
├── Div(id="tw_{id}", cls="dt2-table-wrapper")
│ ├── Div(id="t_{id}", cls="dt2-table")
│ │ ├── Div (dt2-header-container)
│ │ │ └── Div(id="th_{id}", cls="dt2-row dt2-header")
│ │ │ ├── Div (dt2-cell) # Column 1 header
│ │ │ ├── Div (dt2-cell) # Column 2 header
│ │ │ └── ...
│ │ ├── Div(id="tb_{id}", cls="dt2-body-container")
│ │ │ └── Div (dt2-body)
│ │ │ ├── Div (dt2-row) # Data row 1
│ │ │ ├── Div (dt2-row) # Data row 2
│ │ │ └── ...
│ │ └── Div (dt2-footer-container)
│ │ └── Div (dt2-row dt2-header) # Footer row
│ └── Div (dt2-scrollbars)
│ ├── Div (dt2-scrollbars-vertical-wrapper)
│ │ └── Div (dt2-scrollbars-vertical)
│ └── Div (dt2-scrollbars-horizontal-wrapper)
│ └── Div (dt2-scrollbars-horizontal)
└── Script # Initialization script
```
### Element IDs
| Pattern | Description |
|-----------------------|-------------------------------------|
| `{id}` | Root grid container |
| `tw_{id}` | Table wrapper (scrollbar container) |
| `t_{id}` | Table element |
| `th_{id}` | Header row |
| `tb_{id}` | Body container |
| `tf_{id}` | Footer row |
| `tsm_{id}` | Selection Manager |
| `tr_{id}-{row_index}` | Individual data row |
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|---------------------------------------------|----------------------------------------|
| `mk_headers()` | Renders the header row |
| `mk_body()` | Renders the body with first page |
| `mk_body_container()` | Renders the scrollable body container |
| `mk_body_content_page(page_index)` | Renders a specific page of rows |
| `mk_body_cell(col_pos, row_index, col_def)` | Renders a single cell |
| `mk_body_cell_content(...)` | Renders cell content with highlighting |
| `mk_footers()` | Renders the footer row |
| `mk_table()` | Renders the complete table structure |
| `mk_aggregation_cell(...)` | Renders footer aggregation cell |
| `_get_filtered_df()` | Returns filtered and sorted DataFrame |
| `_apply_sort(df)` | Applies sort configuration |
| `_apply_filter(df)` | Applies filter configuration |
### DataGridQuery Component
The filter bar is a separate component (`DataGridQuery`) with its own state:
| State Property | Type | Description | Default |
|----------------|------|-----------------------------------------|------------|
| `filter_type` | str | Current mode ("filter", "search", "ai") | `"filter"` |
| `query` | str | Current search text | `None` |
**Commands:**
| Command | Description |
|------------------------|-----------------------------|
| `change_filter_type()` | Cycle through filter modes |
| `on_filter_changed()` | Handle search input changes |
| `on_cancel_query()` | Clear the search query |

365
docs/Datagrid Formulas.md Normal file
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** |

557
docs/Dropdown.md Normal file
View File

@@ -0,0 +1,557 @@
# Dropdown Component
## Introduction
The Dropdown component provides an interactive dropdown menu that toggles open or closed when clicking a trigger button. It handles positioning, automatic closing behavior, and keyboard navigation out of the box.
**Key features:**
- Toggle open/close on button click
- Automatic close when clicking outside
- Keyboard support (ESC to close)
- Configurable vertical position (above or below the button)
- Configurable horizontal alignment (left, right, or center)
- Session-based state management
- HTMX-powered updates without page reload
**Common use cases:**
- Navigation menus
- User account menus
- Action menus (edit, delete, share)
- Filter or sort options
- Context-sensitive toolbars
- Settings quick access
## Quick Start
Here's a minimal example showing a dropdown menu with navigation links:
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.core.instances import RootInstance
# Create root instance and dropdown
root = RootInstance(session)
dropdown = Dropdown(
parent=root,
button=Button("Menu", cls="btn"),
content=Ul(
Li(A("Home", href="/")),
Li(A("Settings", href="/settings")),
Li(A("Logout", href="/logout"))
)
)
# Render the dropdown
return dropdown
```
This creates a complete dropdown with:
- A "Menu" button that toggles the dropdown
- A list of navigation links displayed below the button
- Automatic closing when clicking outside the dropdown
- ESC key support to close the dropdown
**Note:** The dropdown opens below the button and aligns to the left by default. Users can click anywhere outside the dropdown to close it, or press ESC on the keyboard.
## Basic Usage
### Visual Structure
The Dropdown component consists of a trigger button and a content panel:
```
Closed state:
┌──────────────┐
│ Button ▼ │
└──────────────┘
Open state (position="below", align="left"):
┌──────────────┐
│ Button ▼ │
├──────────────┴─────────┐
│ Dropdown Content │
│ - Option 1 │
│ - Option 2 │
│ - Option 3 │
└────────────────────────┘
Open state (position="above", align="right"):
┌────────────────────────┐
│ Dropdown Content │
│ - Option 1 │
│ - Option 2 │
├──────────────┬─────────┘
│ Button ▲ │
└──────────────┘
```
**Component details:**
| Element | Description |
|-----------|------------------------------------------------|
| Button | Trigger element that toggles the dropdown |
| Content | Panel containing the dropdown menu items |
| Wrapper | Container with relative positioning for anchor |
### Creating a Dropdown
The Dropdown is a `MultipleInstance`, meaning you can create multiple independent dropdowns in your application. Create it by providing a parent instance:
```python
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content)
# Or with a custom ID
dropdown = Dropdown(parent=root_instance, button=my_button, content=my_content, _id="my-dropdown")
```
### Button and Content
The dropdown requires two main elements:
**Button:** The trigger element that users click to toggle the dropdown.
```python
# Simple text button
dropdown = Dropdown(
parent=root,
button=Button("Click me", cls="btn btn-primary"),
content=my_content
)
# Button with icon
dropdown = Dropdown(
parent=root,
button=Div(
icon_svg,
Span("Options"),
cls="flex items-center gap-2"
),
content=my_content
)
# Just an icon
dropdown = Dropdown(
parent=root,
button=icon_svg,
content=my_content
)
```
**Content:** Any FastHTML element to display in the dropdown panel.
```python
# Simple list
content = Ul(
Li("Option 1"),
Li("Option 2"),
Li("Option 3"),
cls="menu"
)
# Complex content with sections
content = Div(
Div("User Actions", cls="font-bold p-2"),
Hr(),
Button("Edit Profile", cls="btn btn-ghost w-full"),
Button("Settings", cls="btn btn-ghost w-full"),
Hr(),
Button("Logout", cls="btn btn-error w-full")
)
```
### Positioning Options
The Dropdown supports two positioning parameters:
**`position`** - Vertical position relative to the button:
- `"below"` (default): Dropdown appears below the button
- `"above"`: Dropdown appears above the button
**`align`** - Horizontal alignment relative to the button:
- `"left"` (default): Dropdown aligns to the left edge of the button
- `"right"`: Dropdown aligns to the right edge of the button
- `"center"`: Dropdown is centered relative to the button
```python
# Default: below + left
dropdown = Dropdown(parent=root, button=btn, content=menu)
# Above the button, aligned right
dropdown = Dropdown(parent=root, button=btn, content=menu, position="above", align="right")
# Below the button, centered
dropdown = Dropdown(parent=root, button=btn, content=menu, position="below", align="center")
```
**Visual examples of all combinations:**
```
position="below", align="left" position="below", align="center" position="below", align="right"
┌────────┐ ┌────────┐ ┌────────┐
│ Button │ │ Button │ │ Button │
├────────┴────┐ ┌────┴────────┴────┐ ┌────────────┴────────┤
│ Content │ │ Content │ │ Content │
└─────────────┘ └──────────────────┘ └─────────────────────┘
position="above", align="left" position="above", align="center" position="above", align="right"
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Content │ │ Content │ │ Content │
├────────┬────┘ └────┬────────┬────┘ └────────────┬────────┤
│ Button │ │ Button │ │ Button │
└────────┘ └────────┘ └────────┘
```
## Advanced Features
### Automatic Close Behavior
The Dropdown automatically closes in two scenarios:
**Click outside:** When the user clicks anywhere outside the dropdown, it closes automatically. This is handled by the Mouse component listening for global click events.
**ESC key:** When the user presses the ESC key, the dropdown closes. This is handled by the Keyboard component.
```python
# Both behaviors are enabled by default - no configuration needed
dropdown = Dropdown(parent=root, button=btn, content=menu)
```
**How it works internally:**
- The `Mouse` component detects clicks and sends `is_inside` and `is_button` parameters
- If `is_button` is true, the dropdown toggles
- If `is_inside` is false (clicked outside), the dropdown closes
- The `Keyboard` component listens for ESC and triggers the close command
### Programmatic Control
You can control the dropdown programmatically using its methods and commands:
```python
# Toggle the dropdown state
dropdown.toggle()
# Close the dropdown
dropdown.close()
# Access commands for use with other controls
close_cmd = dropdown.commands.close()
click_cmd = dropdown.commands.click()
```
**Using commands with buttons:**
```python
from myfasthtml.controls.helpers import mk
# Create a button that closes the dropdown
close_button = mk.button("Close", command=dropdown.commands.close())
# Add it to the dropdown content
dropdown = Dropdown(
parent=root,
button=Button("Menu"),
content=Div(
Ul(Li("Option 1"), Li("Option 2")),
close_button
)
)
```
### CSS Customization
The Dropdown uses CSS classes that you can customize:
| Class | Element |
|-----------------------|---------------------------------------|
| `mf-dropdown-wrapper` | Container with relative positioning |
| `mf-dropdown-btn` | Button wrapper |
| `mf-dropdown` | Dropdown content panel |
| `mf-dropdown-below` | Applied when position="below" |
| `mf-dropdown-above` | Applied when position="above" |
| `mf-dropdown-left` | Applied when align="left" |
| `mf-dropdown-right` | Applied when align="right" |
| `mf-dropdown-center` | Applied when align="center" |
| `is-visible` | Applied when dropdown is open |
**Example customization:**
```css
/* Change dropdown background and border */
.mf-dropdown {
background-color: #1f2937;
border: 1px solid #374151;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
/* Add animation */
.mf-dropdown {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.mf-dropdown.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Style for above position */
.mf-dropdown-above {
transform: translateY(10px);
}
.mf-dropdown-above.is-visible {
transform: translateY(0);
}
```
## Examples
### Example 1: Navigation Menu
A simple navigation dropdown menu:
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
dropdown = Dropdown(
parent=root,
button=Button("Navigation", cls="btn btn-ghost"),
content=Ul(
Li(A("Dashboard", href="/dashboard", cls="block p-2 hover:bg-base-200")),
Li(A("Projects", href="/projects", cls="block p-2 hover:bg-base-200")),
Li(A("Tasks", href="/tasks", cls="block p-2 hover:bg-base-200")),
Li(A("Reports", href="/reports", cls="block p-2 hover:bg-base-200")),
cls="menu p-2"
)
)
return dropdown
```
### Example 2: User Account Menu
A user menu aligned to the right, typically placed in a header:
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
# User avatar button
user_button = Div(
Img(src="/avatar.png", cls="w-8 h-8 rounded-full"),
Span("John Doe", cls="ml-2"),
cls="flex items-center gap-2 cursor-pointer"
)
# Account menu content
account_menu = Div(
Div(
Div("John Doe", cls="font-bold"),
Div("john@example.com", cls="text-sm opacity-60"),
cls="p-3 border-b"
),
Ul(
Li(A("Profile", href="/profile", cls="block p-2 hover:bg-base-200")),
Li(A("Settings", href="/settings", cls="block p-2 hover:bg-base-200")),
Li(A("Billing", href="/billing", cls="block p-2 hover:bg-base-200")),
cls="menu p-2"
),
Div(
A("Sign out", href="/logout", cls="block p-2 text-error hover:bg-base-200"),
cls="border-t"
),
cls="w-56"
)
# Align right so it doesn't overflow the viewport
dropdown = Dropdown(
parent=root,
button=user_button,
content=account_menu,
align="right"
)
return dropdown
```
### Example 3: Action Menu Above Button
A dropdown that opens above the trigger, useful when the button is at the bottom of the screen:
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
# Action button with icon
action_button = Button(
Span("+", cls="text-xl"),
cls="btn btn-circle btn-primary"
)
# Quick actions menu
actions_menu = Div(
Button("New Document", cls="btn btn-ghost btn-sm w-full justify-start"),
Button("Upload File", cls="btn btn-ghost btn-sm w-full justify-start"),
Button("Create Folder", cls="btn btn-ghost btn-sm w-full justify-start"),
Button("Import Data", cls="btn btn-ghost btn-sm w-full justify-start"),
cls="flex flex-col p-2 w-40"
)
# Open above and center-aligned
dropdown = Dropdown(
parent=root,
button=action_button,
content=actions_menu,
position="above",
align="center"
)
return dropdown
```
### Example 4: Dropdown with Commands
A dropdown containing action buttons that execute commands:
```python
from fasthtml.common import *
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Define actions
def edit_item():
return "Editing..."
def delete_item():
return "Deleted!"
def share_item():
return "Shared!"
# Create commands
edit_cmd = Command("edit", "Edit item", edit_item)
delete_cmd = Command("delete", "Delete item", delete_item)
share_cmd = Command("share", "Share item", share_item)
# Build menu with command buttons
actions_menu = Div(
mk.button("Edit", command=edit_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
mk.button("Share", command=share_cmd, cls="btn btn-ghost btn-sm w-full justify-start"),
Hr(cls="my-1"),
mk.button("Delete", command=delete_cmd, cls="btn btn-ghost btn-sm w-full justify-start text-error"),
cls="flex flex-col p-2"
)
dropdown = Dropdown(
parent=root,
button=Button("Actions", cls="btn btn-sm"),
content=actions_menu
)
return dropdown
```
---
## Developer Reference
This section contains technical details for developers working on the Dropdown component itself.
### State
The Dropdown component maintains its state via `DropdownState`:
| Name | Type | Description | Default |
|----------|---------|------------------------------|---------|
| `opened` | boolean | Whether dropdown is open | `False` |
### Commands
Available commands for programmatic control:
| Name | Description |
|-----------|-------------------------------------------------|
| `close()` | Closes the dropdown |
| `click()` | Handles click events (toggle or close behavior) |
**Command details:**
- `close()`: Sets `opened` to `False` and returns updated content
- `click()`: Receives `combination`, `is_inside`, and `is_button` parameters
- If `is_button` is `True`: toggles the dropdown
- If `is_inside` is `False`: closes the dropdown
### Public Methods
| Method | Description | Returns |
|------------|----------------------------|----------------------|
| `toggle()` | Toggles open/closed state | Content tuple |
| `close()` | Closes the dropdown | Content tuple |
| `render()` | Renders complete component | `Div` |
### Constructor Parameters
| Parameter | Type | Description | Default |
|------------|-------------|------------------------------------|-----------|
| `parent` | Instance | Parent instance (required) | - |
| `content` | Any | Content to display in dropdown | `None` |
| `button` | Any | Trigger element | `None` |
| `_id` | str | Custom ID for the instance | `None` |
| `position` | str | Vertical position: "below"/"above" | `"below"` |
| `align` | str | Horizontal align: "left"/"right"/"center" | `"left"` |
### High Level Hierarchical Structure
```
Div(id="{id}")
├── Div(cls="mf-dropdown-wrapper")
│ ├── Div(cls="mf-dropdown-btn")
│ │ └── [Button content]
│ └── Div(id="{id}-content", cls="mf-dropdown mf-dropdown-{position} mf-dropdown-{align} [is-visible]")
│ └── [Dropdown content]
├── Keyboard(id="{id}-keyboard")
│ └── ESC → close command
└── Mouse(id="{id}-mouse")
└── click → click command
```
### Element IDs
| Name | Description |
|------------------|--------------------------------|
| `{id}` | Root dropdown container |
| `{id}-content` | Dropdown content panel |
| `{id}-keyboard` | Keyboard handler component |
| `{id}-mouse` | Mouse handler component |
**Note:** `{id}` is the Dropdown instance ID (auto-generated or custom `_id`).
### Internal Methods
| Method | Description |
|-----------------|------------------------------------------|
| `_mk_content()` | Renders the dropdown content panel |
| `on_click()` | Handles click events from Mouse component |
**Method details:**
- `_mk_content()`:
- Builds CSS classes based on `position` and `align`
- Adds `is-visible` class when `opened` is `True`
- Returns a tuple containing the content `Div`
- `on_click(combination, is_inside, is_button)`:
- Called by Mouse component on click events
- `is_button`: `True` if click was on the button
- `is_inside`: `True` if click was inside the dropdown
- Returns updated content for HTMX swap

View File

@@ -0,0 +1,706 @@
# HierarchicalCanvasGraph
## Introduction
The HierarchicalCanvasGraph component provides a canvas-based hierarchical graph visualization with interactive features. It displays nodes and edges in a tree layout using the Reingold-Tilford algorithm, with built-in support for expand/collapse, zoom/pan, and filtering.
**Key features:**
- Canvas-based rendering for smooth performance with large graphs
- Expand/collapse nodes with children
- Zoom and pan with mouse wheel and drag
- Search/filter nodes by text, type, or kind
- Click to select nodes
- Stable zoom maintained on container resize
- Persistent state (collapsed nodes, view transform, filters)
- Event handlers for node interactions
**Common use cases:**
- Visualizing class hierarchies and inheritance trees
- Displaying dependency graphs
- Exploring file/folder structures
- Showing organizational charts
- Debugging object instance relationships
## Quick Start
Here's a minimal example showing a simple hierarchy:
```python
from fasthtml.common import *
from myfasthtml.controls.HierarchicalCanvasGraph import (
HierarchicalCanvasGraph,
HierarchicalCanvasGraphConf
)
from myfasthtml.core.instances import RootInstance
from myfasthtml.myfastapp import create_app
app, rt = create_app()
@rt("/")
def index(session):
root = RootInstance(session)
# Define nodes and edges
nodes = [
{"id": "root", "label": "Root", "description": "Base class", "type": "Class", "kind": "base"},
{"id": "child1", "label": "Child 1", "description": "First derived class", "type": "Class", "kind": "derived"},
{"id": "child2", "label": "Child 2", "description": "Second derived class", "type": "Class", "kind": "derived"},
]
edges = [
{"from": "root", "to": "child1"},
{"from": "root", "to": "child2"},
]
# Create graph
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
graph = HierarchicalCanvasGraph(root, conf)
return Titled("Graph Example", graph)
serve()
```
This creates a complete hierarchical graph with:
- Three nodes in a simple parent-child relationship
- Automatic tree layout with expand/collapse controls
- Built-in zoom/pan navigation
- Search filter bar
**Note:** The graph automatically saves collapsed state and zoom/pan position across sessions.
## Basic Usage
### Visual Structure
```
┌─────────────────────────────────────────┐
│ Filter instances... [x] │ ← Query filter bar
├─────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Root │ [±] │ ← Node label (bold)
│ │ Description │ │ ← Description (gray, smaller)
│ └──────────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ │ │
│ ┌─────────┐ ┌─────────┐ │ ← Child nodes
│ │Child 1 │ │Child 2 │ │
│ │Details │ │Details │ │
│ └─────────┘ └─────────┘ │
│ │
│ • Dot grid background │ ← Canvas area
│ • Mouse wheel to zoom │
│ • Drag to pan │
│ │
└─────────────────────────────────────────┘
```
### Creating the Graph
**Step 1: Define nodes**
Each node is a dictionary with:
```python
nodes = [
{
"id": "unique_id", # Required: unique identifier
"label": "Display Name", # Required: shown in the node (main line)
"description": "Details...", # Optional: shown below label (smaller, gray)
"type": "ClassName", # Optional: shown as badge
"kind": "category", # Optional: affects border color
}
]
```
**Note:** Nodes with `description` are taller (54px vs 36px) to accommodate the second line of text.
**Step 2: Define edges**
Each edge connects two nodes:
```python
edges = [
{"from": "parent_id", "to": "child_id"}
]
```
**Step 3: Create configuration**
```python
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraphConf
conf = HierarchicalCanvasGraphConf(
nodes=nodes,
edges=edges,
events_handlers=None # Optional: dict of event handlers
)
```
**Step 4: Instantiate component**
```python
graph = HierarchicalCanvasGraph(parent, conf, _id="custom-id")
```
### Configuration
| Parameter | Type | Description | Default |
|-------------------|-----------------------|--------------------------------------------------|---------|
| `nodes` | `list[dict]` | List of node dictionaries | - |
| `edges` | `list[dict]` | List of edge dictionaries | - |
| `events_handlers` | `Optional[dict]` | Event name to Command object mapping | `None` |
### Node Properties
| Property | Type | Required | Description |
|---------------|-------|----------|------------------------------------------------|
| `id` | `str` | Yes | Unique node identifier |
| `label` | `str` | Yes | Display text shown in the node (main line) |
| `description` | `str` | No | Secondary text shown below label (smaller, gray) |
| `type` | `str` | No | Type badge shown on node (clickable filter) |
| `kind` | `str` | No | Category determining border color/style |
### Edge Properties
| Property | Type | Required | Description |
|----------|-------|----------|--------------------------------|
| `from` | `str` | Yes | Source node ID |
| `to` | `str` | Yes | Target node ID |
## Advanced Features
### Filtering
The component provides three types of filtering:
**1. Text search** (via filter bar)
Searches in node `id`, `label`, `type`, and `kind` fields:
```python
# User types in filter bar
# Automatically filters matching nodes
# Non-matching nodes are dimmed
```
**2. Type filter** (click node badge)
Click a node's type badge to filter by that type:
```python
# Clicking "Class" badge shows only nodes with type="Class"
# Clicking again clears the filter
```
**3. Kind filter** (click node border)
Click a node's border to filter by kind:
```python
# Clicking border of kind="base" shows only base nodes
# Clicking again clears the filter
```
**Filter behavior:**
- Only one filter type active at a time
- Filters dim non-matching nodes (opacity reduced)
- Matching nodes and their ancestors remain fully visible
- Clear filter by clicking same badge/border or clearing search text
### Events
The component supports two event types via `events_handlers`:
**select_node**: Fired when clicking a node (not the toggle button)
```python
from myfasthtml.core.commands import Command
def on_select(node_id=None):
print(f"Selected node: {node_id}")
return f"Selected: {node_id}"
conf = HierarchicalCanvasGraphConf(
nodes=nodes,
edges=edges,
events_handlers={
"select_node": Command(
"SelectNode",
"Handle node selection",
on_select
)
}
)
```
**toggle_node**: Fired when clicking expand/collapse button
```python
def on_toggle(node_id=None):
print(f"Toggled node: {node_id}")
return "Toggled"
conf = HierarchicalCanvasGraphConf(
nodes=nodes,
edges=edges,
events_handlers={
"toggle_node": Command(
"ToggleNode",
"Handle node toggle",
on_toggle
)
}
)
```
### State Persistence
The graph automatically persists:
**Collapsed nodes:**
```python
# Get current collapsed state
collapsed = graph.get_state().collapsed # List of node IDs
# Programmatically set collapsed nodes
graph.set_collapsed({"node1", "node2"})
# Toggle a specific node
graph.toggle_node("node1")
```
**View transform (zoom/pan):**
```python
# Get current transform
transform = graph.get_state().transform
# {"x": 100, "y": 50, "scale": 1.5}
```
**Layout mode:**
```python
# Get current layout mode
mode = graph.get_state().layout_mode # "horizontal" or "vertical"
```
**Current selection:**
```python
# Get selected node ID
selected = graph.get_selected_id() # Returns node ID or None
```
### Layout Modes
The graph supports two layout orientations:
- **horizontal**: Nodes flow left-to-right (default)
- **vertical**: Nodes flow top-to-bottom
Users can toggle between modes using the UI controls. The mode is persisted in state.
## Examples
### Example 1: Simple Class Hierarchy
Basic inheritance tree visualization:
```python
from fasthtml.common import *
from myfasthtml.controls.HierarchicalCanvasGraph import (
HierarchicalCanvasGraph,
HierarchicalCanvasGraphConf
)
from myfasthtml.core.instances import RootInstance
from myfasthtml.myfastapp import create_app
app, rt = create_app()
@rt("/")
def index(session):
root = RootInstance(session)
nodes = [
{"id": "object", "label": "Object", "description": "Base class for all objects", "type": "BaseClass", "kind": "builtin"},
{"id": "animal", "label": "Animal", "description": "Abstract animal class", "type": "Class", "kind": "abstract"},
{"id": "dog", "label": "Dog", "description": "Canine implementation", "type": "Class", "kind": "concrete"},
{"id": "cat", "label": "Cat", "description": "Feline implementation", "type": "Class", "kind": "concrete"},
]
edges = [
{"from": "object", "to": "animal"},
{"from": "animal", "to": "dog"},
{"from": "animal", "to": "cat"},
]
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
graph = HierarchicalCanvasGraph(root, conf)
return Titled("Class Hierarchy", graph)
serve()
```
### Example 2: Graph with Event Handlers
Handling node selection and expansion:
```python
from fasthtml.common import *
from myfasthtml.controls.HierarchicalCanvasGraph import (
HierarchicalCanvasGraph,
HierarchicalCanvasGraphConf
)
from myfasthtml.core.instances import RootInstance
from myfasthtml.core.commands import Command
from myfasthtml.myfastapp import create_app
app, rt = create_app()
@rt("/")
def index(session):
root = RootInstance(session)
# Event handlers
def on_select(node_id=None):
return Div(
f"Selected: {node_id}",
id="selection-info",
cls="alert alert-info"
)
def on_toggle(node_id=None):
return Div(
f"Toggled: {node_id}",
id="toggle-info",
cls="alert alert-success"
)
# Create commands
select_cmd = Command("SelectNode", "Handle selection", on_select)
toggle_cmd = Command("ToggleNode", "Handle toggle", on_toggle)
nodes = [
{"id": "root", "label": "Root Module", "description": "Main package entry point", "type": "Module", "kind": "package"},
{"id": "sub1", "label": "Submodule A", "description": "Feature A implementation", "type": "Module", "kind": "module"},
{"id": "sub2", "label": "Submodule B", "description": "Feature B implementation", "type": "Module", "kind": "module"},
{"id": "class1", "label": "ClassA", "description": "Handler for feature A", "type": "Class", "kind": "class"},
{"id": "class2", "label": "ClassB", "description": "Handler for feature B", "type": "Class", "kind": "class"},
]
edges = [
{"from": "root", "to": "sub1"},
{"from": "root", "to": "sub2"},
{"from": "sub1", "to": "class1"},
{"from": "sub2", "to": "class2"},
]
conf = HierarchicalCanvasGraphConf(
nodes=nodes,
edges=edges,
events_handlers={
"select_node": select_cmd,
"toggle_node": toggle_cmd
}
)
graph = HierarchicalCanvasGraph(root, conf)
return Titled("Interactive Graph",
graph,
Div(id="selection-info"),
Div(id="toggle-info")
)
serve()
```
### Example 3: Filtered Graph with Type/Kind Badges
Graph with multiple node types for filtering:
```python
from fasthtml.common import *
from myfasthtml.controls.HierarchicalCanvasGraph import (
HierarchicalCanvasGraph,
HierarchicalCanvasGraphConf
)
from myfasthtml.core.instances import RootInstance
from myfasthtml.myfastapp import create_app
app, rt = create_app()
@rt("/")
def index(session):
root = RootInstance(session)
nodes = [
# Controllers
{"id": "ctrl1", "label": "UserController", "description": "Handles user requests", "type": "Controller", "kind": "web"},
{"id": "ctrl2", "label": "AdminController", "description": "Admin panel endpoints", "type": "Controller", "kind": "web"},
# Services
{"id": "svc1", "label": "AuthService", "description": "Authentication logic", "type": "Service", "kind": "business"},
{"id": "svc2", "label": "EmailService", "description": "Email notifications", "type": "Service", "kind": "infrastructure"},
# Repositories
{"id": "repo1", "label": "UserRepo", "description": "User data access", "type": "Repository", "kind": "data"},
{"id": "repo2", "label": "LogRepo", "description": "Logging data access", "type": "Repository", "kind": "data"},
]
edges = [
{"from": "ctrl1", "to": "svc1"},
{"from": "ctrl2", "to": "svc1"},
{"from": "svc1", "to": "repo1"},
{"from": "svc2", "to": "repo2"},
]
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
graph = HierarchicalCanvasGraph(root, conf)
return Titled("Dependency Graph",
Div(
P("Click on type badges (Controller, Service, Repository) to filter by type"),
P("Click on node borders to filter by kind (web, business, infrastructure, data)"),
P("Use search bar to filter by text"),
cls="mb-4"
),
graph
)
serve()
```
### Example 4: Programmatic State Control
Controlling collapsed state and getting selection:
```python
from fasthtml.common import *
from myfasthtml.controls.HierarchicalCanvasGraph import (
HierarchicalCanvasGraph,
HierarchicalCanvasGraphConf
)
from myfasthtml.controls.helpers import mk
from myfasthtml.core.instances import RootInstance
from myfasthtml.core.commands import Command
from myfasthtml.myfastapp import create_app
app, rt = create_app()
@rt("/")
def index(session):
root = RootInstance(session)
nodes = [
{"id": "root", "label": "Project", "description": "Root directory", "type": "Folder", "kind": "root"},
{"id": "src", "label": "src/", "description": "Source code", "type": "Folder", "kind": "folder"},
{"id": "tests", "label": "tests/", "description": "Test suite", "type": "Folder", "kind": "folder"},
{"id": "main", "label": "main.py", "description": "Application entry point", "type": "File", "kind": "python"},
{"id": "utils", "label": "utils.py", "description": "Utility functions", "type": "File", "kind": "python"},
{"id": "test1", "label": "test_main.py", "description": "Main module tests", "type": "File", "kind": "test"},
]
edges = [
{"from": "root", "to": "src"},
{"from": "root", "to": "tests"},
{"from": "src", "to": "main"},
{"from": "src", "to": "utils"},
{"from": "tests", "to": "test1"},
]
conf = HierarchicalCanvasGraphConf(nodes=nodes, edges=edges)
graph = HierarchicalCanvasGraph(root, conf, _id="file-tree")
# Commands to control graph
def collapse_all():
graph.set_collapsed({"root", "src", "tests"})
return graph
def expand_all():
graph.set_collapsed(set())
return graph
def show_selection():
selected = graph.get_selected_id()
if selected:
return Div(f"Selected: {selected}", id="info", cls="alert alert-info")
return Div("No selection", id="info", cls="alert alert-warning")
collapse_cmd = Command("CollapseAll", "Collapse all nodes", collapse_all)
expand_cmd = Command("ExpandAll", "Expand all nodes", expand_all)
selection_cmd = Command("ShowSelection", "Show selection", show_selection)
return Titled("File Tree",
Div(
mk.button("Collapse All", command=collapse_cmd.htmx(target="#file-tree")),
mk.button("Expand All", command=expand_cmd.htmx(target="#file-tree")),
mk.button("Show Selection", command=selection_cmd.htmx(target="#info")),
cls="flex gap-2 mb-4"
),
Div(id="info"),
graph
)
serve()
```
---
## Developer Reference
This section contains technical details for developers working on the HierarchicalCanvasGraph component itself.
### State
The component uses `HierarchicalCanvasGraphState` (inherits from `DbObject`) for persistence.
| Name | Type | Description | Default | Persisted |
|-----------------|-------------------|--------------------------------------------------|--------------|-----------|
| `collapsed` | `list[str]` | List of collapsed node IDs | `[]` | Yes |
| `transform` | `dict` | Zoom/pan transform: `{x, y, scale}` | See below | Yes |
| `layout_mode` | `str` | Layout orientation: "horizontal" or "vertical" | `"horizontal"` | Yes |
| `filter_text` | `Optional[str]` | Text search filter | `None` | Yes |
| `filter_type` | `Optional[str]` | Type filter (from badge click) | `None` | Yes |
| `filter_kind` | `Optional[str]` | Kind filter (from border click) | `None` | Yes |
| `ns_selected_id` | `Optional[str]` | Currently selected node ID (ephemeral) | `None` | No |
**Default transform:**
```python
{"x": 0, "y": 0, "scale": 1}
```
### Commands
Internal commands (managed by `Commands` class inheriting from `BaseCommands`):
| Name | Description | Internal |
|----------------------|--------------------------------------------------|----------|
| `update_view_state()` | Update view transform and layout mode | Yes |
| `apply_filter()` | Apply current filter and update graph display | Yes |
### Public Methods
| Method | Description | Returns |
|--------------------------------|------------------------------------------|----------------------------|
| `get_state()` | Get the persistent state object | `HierarchicalCanvasGraphState` |
| `get_selected_id()` | Get currently selected node ID | `Optional[str]` |
| `set_collapsed(node_ids: set)` | Set collapsed state of nodes | `None` |
| `toggle_node(node_id: str)` | Toggle collapsed state of a node | `self` |
| `render()` | Render the complete component | `Div` |
### Constructor Parameters
| Parameter | Type | Description | Default |
|------------|-----------------------------------|------------------------------------|---------|
| `parent` | Instance | Parent instance (required) | - |
| `conf` | `HierarchicalCanvasGraphConf` | Configuration object | - |
| `_id` | `Optional[str]` | Custom ID (auto-generated if None) | `None` |
### High Level Hierarchical Structure
```
Div(id="{id}", cls="mf-hierarchical-canvas-graph")
├── Query(id="{id}-query")
│ └── [Filter input and controls]
├── Div(id="{id}_container", cls="mf-hcg-container")
│ └── [Canvas element - created by JS]
└── Script
└── initHierarchicalCanvasGraph('{id}_container', options)
```
### Element IDs
| Name | Description |
|----------------------|------------------------------------------|
| `{id}` | Root container div |
| `{id}-query` | Query filter component |
| `{id}_container` | Canvas container (sized by JS) |
**Note:** `{id}` is the instance ID (auto-generated or custom `_id`).
### CSS Classes
| Class | Element |
|------------------------------------|-----------------------------------|
| `mf-hierarchical-canvas-graph` | Root container |
| `mf-hcg-container` | Canvas container div |
### Internal Methods
| Method | Description | Returns |
|-------------------------------------|--------------------------------------------------|-------------------|
| `_handle_update_view_state()` | Update view state from client | `str` (empty) |
| `_handle_apply_filter()` | Apply filter and re-render graph | `self` |
| `_calculate_filtered_nodes()` | Calculate which nodes match current filter | `Optional[list[str]]` |
| `_prepare_options()` | Prepare JavaScript options object | `dict` |
### JavaScript Interface
The component calls `initHierarchicalCanvasGraph(containerId, options)` where `options` is:
```javascript
{
nodes: [...], // Array of node objects {id, label, description?, type?, kind?}
edges: [...], // Array of edge objects {from, to}
collapsed: [...], // Array of collapsed node IDs
transform: {x, y, scale}, // Current view transform
layout_mode: "horizontal", // Layout orientation
filtered_nodes: [...], // Array of visible node IDs (null if no filter)
events: {
_internal_update_state: {...}, // HTMX options for state updates
_internal_filter_by_type: {...}, // HTMX options for type filter
_internal_filter_by_kind: {...}, // HTMX options for kind filter
select_node: {...}, // User event handler (optional)
toggle_node: {...} // User event handler (optional)
}
}
```
**Node object structure:**
- `id` (required): Unique identifier
- `label` (required): Main display text
- `description` (optional): Secondary text shown below label in smaller gray font
- `type` (optional): Type badge shown on node
- `kind` (optional): Category affecting border color (root|single|unique|multiple)
### Event Flow
**Node selection:**
1. User clicks node (not toggle button)
2. JS calls HTMX with `node_id` parameter
3. `select_node` handler executes (if defined)
4. Result updates target element
**Node toggle:**
1. User clicks expand/collapse button
2. JS updates local collapsed state
3. JS calls `_internal_update_state` HTMX endpoint
4. State persisted in `HierarchicalCanvasGraphState.collapsed`
5. `toggle_node` handler executes (if defined)
**Filter changes:**
1. User types in search / clicks badge / clicks border
2. Query component or JS triggers `apply_filter()` command
3. `_handle_apply_filter()` updates state filters
4. Component re-renders with filtered nodes
5. JS dims non-matching nodes
### Dependencies
**Python:**
- `fasthtml.components.Div`
- `fasthtml.xtend.Script`
- `myfasthtml.controls.Query` - Filter search bar
- `myfasthtml.core.instances.MultipleInstance` - Instance management
- `myfasthtml.core.dbmanager.DbObject` - State persistence
**JavaScript:**
- `initHierarchicalCanvasGraph()` - Must be loaded via MyFastHtml assets
- Canvas API - For rendering
- HTMX - For server communication

509
docs/Keyboard Support.md Normal file
View File

@@ -0,0 +1,509 @@
# Keyboard Support - Test Instructions
## ⚠️ Breaking Change
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
**Old format (no longer supported)**:
```javascript
{"A": "/url"}
```
**New format (required)**:
```javascript
{"A": {"hx-post": "/url"}}
```
## Files
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
- `test_keyboard_support.html` - Test page to verify functionality
## Key Features
### Scope Control with `require_inside`
Each combination can declare whether it should only trigger when the focus is **inside** the registered element, or fire **globally** regardless of focus.
| `require_inside` | Behavior |
|-----------------|----------|
| `true` (default) | Triggers only if focus is inside the element or one of its children |
| `false` | Triggers regardless of where the focus is (global shortcut) |
```javascript
// Only fires when focus is inside #tree-panel
add_keyboard_support('tree-panel', '{"esc": {"hx-post": "/cancel", "require_inside": true}}');
// Fires anywhere on the page
add_keyboard_support('app', '{"ctrl+n": {"hx-post": "/new", "require_inside": false}}');
```
**Python usage (`Keyboard` component):**
```python
# Default: require_inside=True — fires only when inside the element
Keyboard(self, _id="-kb").add("esc", self.commands.cancel())
# Explicit global shortcut
Keyboard(self, _id="-kb").add("ctrl+n", self.commands.new_item(), require_inside=False)
```
### Multiple Simultaneous Triggers
**IMPORTANT**: If multiple elements listen to the same combination, all of them whose `require_inside` condition is satisfied will be triggered simultaneously:
```javascript
add_keyboard_support('modal', '{"esc": {"hx-post": "/close-modal", "require_inside": true}}');
add_keyboard_support('editor', '{"esc": {"hx-post": "/cancel-edit", "require_inside": true}}');
add_keyboard_support('sidebar', '{"esc": {"hx-post": "/hide-sidebar", "require_inside": false}}');
// Pressing ESC while focus is inside 'editor':
// - 'modal' → skipped (require_inside: true, focus not inside)
// - 'editor' → triggered ✓
// - 'sidebar' → triggered ✓ (require_inside: false)
```
### Smart Timeout Logic (Longest Match)
The library uses **a single global timeout** based on the sequence state, not on individual elements:
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
Examples:
**Example 1**: Three elements, same combination
```javascript
add_keyboard_support('elem1', '{"esc": "/url1"}');
add_keyboard_support('elem2', '{"esc": "/url2"}');
add_keyboard_support('elem3', '{"esc": "/url3"}');
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
```
**Example 2**: Mixed - one has longer sequence
```javascript
add_keyboard_support('elem1', '{"A": "/url1"}');
add_keyboard_support('elem2', '{"A": "/url2"}');
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
// Press A:
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
// - If B arrives: only elem3 triggers with "A B"
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
```
**Example 3**: Different combinations
```javascript
add_keyboard_support('elem1', '{"A B": "/url1"}');
add_keyboard_support('elem2', '{"C D": "/url2"}');
// Press A: elem1 waits for B, elem2 not affected
// Press C: elem2 waits for D, elem1 not affected
```
The timeout is tied to the **sequence being typed**, not to individual elements.
### Enabling and Disabling Combinations
Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered.
| State | Behavior |
|-------|----------|
| `enabled=True` (default) | Combination triggers normally |
| `enabled=False` | Combination is silently ignored when pressed |
**Setting the initial state at registration:**
```python
# Enabled by default
keyboard.add("ctrl+s", self.commands.save())
# Disabled at startup
keyboard.add("ctrl+d", self.commands.delete(), enabled=False)
```
**Toggling dynamically at runtime:**
Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload.
```python
# Enable a combination (e.g., once an item is selected)
def handle_select(self):
item = ...
return item.render(), self.keyboard.mk_enable("ctrl+d")
# Disable a combination (e.g., when nothing is selected)
def handle_deselect(self):
return self.keyboard.mk_disable("ctrl+d")
```
The enabled state is stored in a hidden control `<div>` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action.
**State at a glance:**
```
┌─────────────────────────────────────┐
│ Keyboard control div (hidden) │
│ ┌──────────────────────────────┐ │
│ │ div [data-combination="esc"] │ │
│ │ [data-enabled="true"] │ │
│ ├──────────────────────────────┤ │
│ │ div [data-combination="ctrl+d"] │ │
│ │ [data-enabled="false"] │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
- `<input>` elements
- `<textarea>` elements
- Any `contenteditable` element
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
## How to Test
1. **Download both files** to the same directory
2. **Open `test_keyboard_support.html`** in a web browser
3. **Try the configured shortcuts**:
- `a` - Simple key (waits if "A B" might follow)
- `Ctrl+S` - Save combination (immediate)
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
- `A B` - Sequence (waits because "A B C" exists)
- `A B C` - Triple sequence (triggers immediately)
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
- `shift shift` - Press Shift twice in sequence
- `esc` - Escape key (immediate)
4. **Test focus behavior**:
- Click on the test element to focus it (turns blue)
- Try shortcuts with focus
- Click outside to remove focus
- Try shortcuts without focus
- The log shows whether the element had focus when triggered
5. **Test input protection**:
- Try typing in the input field
- Use Ctrl+C, Ctrl+A, etc. - should work normally
- Shortcuts should NOT trigger while typing in input
## Expected Behavior
### Smart Timeout Examples
**Scenario 1**: Only "A" is configured (no element has "A B")
- Press A → Triggers **immediately**
**Scenario 2**: At least one element has "A B"
- Press A → **ALL elements with "A" wait 500ms**
- If B pressed within 500ms → Only elements with "A B" trigger
- If timeout expires → ALL elements with "A" trigger
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
- Press A → Waits (because "A B" exists)
- Press B → Waits (because "A B C" exists)
- Press C → Triggers "A B C" **immediately**
**Scenario 4**: Multiple elements, ESC on all
```javascript
add_keyboard_support('modal', '{"esc": "/close"}');
add_keyboard_support('editor', '{"esc": "/cancel"}');
```
- Press ESC → **Both trigger simultaneously** (no longer sequences)
## Integration in Your Project
## Integration in Your Project
### Configuration Format
The library now uses **HTMX configuration objects** instead of simple URL strings:
```python
# New format with HTMX configuration
combinations = {
"Ctrl+S": {
"hx-post": "/save-url",
"hx-target": "#result",
"hx-swap": "innerHTML"
},
"A B": {
"hx-post": "/sequence-url",
"hx-vals": {"extra": "data"}
},
"esc": {
"hx-get": "/cancel-url"
}
}
# This will generate the JavaScript call
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
```
### Supported HTMX Attributes
You can use any HTMX attribute in the configuration object:
**HTTP Methods** (one required):
- `hx-post` - POST request
- `hx-get` - GET request
- `hx-put` - PUT request
- `hx-delete` - DELETE request
- `hx-patch` - PATCH request
**Common Options**:
- `hx-target` - Target element selector
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
- `hx-vals` - Additional values to send (object)
- `hx-vals-extra` - Extra values to merge (see below)
- `hx-headers` - Custom headers (object)
- `hx-select` - Select specific content from response
- `hx-confirm` - Confirmation message
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
### Dynamic Values with hx-vals-extra
The `hx-vals-extra` attribute allows adding dynamic values computed at event time, without overwriting the static `hx-vals`.
**Format:**
```javascript
{
"hx-vals": {"c_id": "command_id"}, // Static values (preserved)
"hx-vals-extra": {
"dict": {"key": "value"}, // Additional static values (merged)
"js": "functionName" // JS function to call (merged)
}
}
```
**How values are merged:**
1. `hx-vals` - static values (e.g., `c_id` from Command)
2. `hx-vals-extra.dict` - additional static values
3. `hx-vals-extra.js` - function called with `(event, element, combinationStr)`, result merged
**JavaScript function example:**
```javascript
function getKeyboardContext(event, element, combination) {
return {
key: event.key,
shift: event.shiftKey,
timestamp: Date.now()
};
}
```
**Configuration example:**
```javascript
const combinations = {
"Ctrl+S": {
"hx-post": "/save",
"hx-vals": {"c_id": "save_cmd"},
"hx-vals-extra": {"js": "getKeyboardContext"}
}
};
```
### Automatic Parameters
The library automatically adds these parameters to every request:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
Note: `require_inside` controls **whether** the action fires; `is_inside` is an informational parameter sent **with** the request after it fires.
Example final request:
```javascript
htmx.ajax('POST', '/save-url', {
target: '#result',
swap: 'innerHTML',
values: {
extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic
has_focus: true, // automatic
is_inside: true // automatic
}
})
```
## Integration Examples
### Basic Usage
```python
combinations = {
"Ctrl+S": {
"hx-post": "/save"
}
}
```
### With Target and Swap
```python
combinations = {
"Ctrl+D": {
"hx-delete": "/item",
"hx-target": "#item-list",
"hx-swap": "outerHTML"
}
}
```
### With Extra Values
```python
combinations = {
"Ctrl+N": {
"hx-post": "/create",
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
}
}
```
### Multiple Elements Example
```python
# Modal close
modal_combinations = {
"esc": {
"hx-post": "/modal/close",
"hx-target": "#modal",
"hx-swap": "outerHTML"
}
}
# Editor cancel
editor_combinations = {
"esc": {
"hx-post": "/editor/cancel",
"hx-target": "#editor",
"hx-swap": "innerHTML"
}
}
# Both will trigger when ESC is pressed
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
```
### Removing Keyboard Support
When you no longer need keyboard support for an element:
```python
# Remove keyboard support
f"remove_keyboard_support('{element_id}')"
```
**Behavior**:
- Removes the element from the keyboard registry
- If this was the last element, automatically detaches global event listeners
- Cleans up all associated state (timeouts, snapshots, etc.)
- Other elements continue to work normally
**Example**:
```javascript
// Add support
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
// Later, remove support
remove_keyboard_support('modal');
// If no other elements remain, keyboard listeners are completely removed
```
## API Reference
### add_keyboard_support(elementId, controlDivId, combinationsJson)
Adds keyboard support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element to watch for key events
- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
### Python Component Methods
These methods are available on the `Keyboard` instance.
#### add(sequence, command, require_inside=True, enabled=True)
Registers a key combination.
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — |
| `command` | `Command` | Command to execute when the combination is triggered | — |
| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` |
| `enabled` | `bool` | Whether the combination is active at render time | `True` |
**Returns**: `self` (chainable)
#### mk_enable(sequence)
Returns an out-of-band HTMX element that enables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
#### mk_disable(sequence)
Returns an out-of-band HTMX element that disables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
---
### remove_keyboard_support(elementId)
Removes keyboard support from an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
**Returns**: void
**Side effects**:
- If last element: detaches global event listeners and cleans up all state
## Technical Details
### Trie-based Matching
The library uses a prefix tree (trie) data structure:
- Each node represents a keyboard snapshot (set of pressed keys)
- Leaf nodes contain the HTMX configuration object
- Intermediate nodes indicate longer sequences exist
- Enables efficient O(n) matching where n is sequence length
### HTMX Integration
Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
- `combination`, `has_focus`, and `is_inside` are automatically added to values
- All standard HTMX options are supported
### Key Normalization
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
- Simultaneous keys represented as sorted sets
## Notes
- The test page mocks `htmx.ajax` to display results in the console
- In production, real AJAX calls will be made to your backend
- Sequence timeout is 500ms between keys
- Maximum 10 snapshots kept in history to prevent memory issues

583
docs/Layout.md Normal file
View File

@@ -0,0 +1,583 @@
# Layout Component
## Introduction
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
**Key features:**
- Fixed header and footer that stay visible while scrolling
- Collapsible left and right drawers for navigation, tools, or auxiliary content
- Resizable drawers with drag handles
- Automatic state persistence per session
- Single instance per session (singleton pattern)
**Common use cases:**
- Application with navigation sidebar
- Dashboard with tools panel
- Admin interface with settings drawer
- Documentation site with table of contents
## Quick Start
Here's a minimal example showing an application with a navigation sidebar:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Create the layout instance
layout = Layout(parent=root_instance, app_name="My App")
# Add navigation items to the left drawer
layout.left_drawer.add(
mk.mk(Div("Home"), command=Command(...))
)
layout.left_drawer.add(
mk.mk(Div("About"), command=Command(...))
)
layout.left_drawer.add(
mk.mk(Div("Contact"), command=Command(...))
)
# Set the main content
layout.set_main(
Div(
H1("Welcome"),
P("This is the main content area")
)
)
# Render the layout
return layout
```
This creates a complete application layout with:
- A header displaying the app name and drawer toggle button
- A collapsible left drawer with interactive navigation items
- A main content area that updates when navigation items are clicked
- An empty footer
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
below for details.
## Basic Usage
### Creating a Layout
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
parent instance and an application name:
```python
layout = Layout(parent=root_instance, app_name="My Application")
```
### Content Zones
The Layout provides six content zones where you can add components:
```
┌──────────────────────────────────────────────────────────┐
│ Header │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ header_left │ │ header_right │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────┬────────────────────────────────────┬───────────┤
│ │ │ │
│ left │ │ right │
│ drawer │ Main Content │ drawer │
│ │ │ │
│ │ │ │
├─────────┴────────────────────────────────────┴───────────┤
│ Footer │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ footer_left │ │ footer_right │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
**Zone details:**
| Zone | Typical Use |
|----------------|-----------------------------------------------|
| `header_left` | App logo, menu button, breadcrumbs |
| `header_right` | User profile, notifications, settings |
| `left_drawer` | Navigation menu, tree view, filters |
| `right_drawer` | Tools panel, properties inspector, debug info |
| `footer_left` | Copyright, legal links, version |
| `footer_right` | Status indicators, connection state |
### Adding Content to Zones
Use the `.add()` method to add components to any zone:
```python
# Header
layout.header_left.add(Div("Logo"))
layout.header_right.add(Div("User: Admin"))
# Drawers
layout.left_drawer.add(Div("Navigation"))
layout.right_drawer.add(Div("Tools"))
# Footer
layout.footer_left.add(Div("© 2024 My App"))
layout.footer_right.add(Div("v1.0.0"))
```
### Setting Main Content
The main content area displays your page content and can be updated dynamically:
```python
# Set initial content
layout.set_main(
Div(
H1("Dashboard"),
P("Welcome to your dashboard")
)
)
# Update later (typically via commands)
layout.set_main(
Div(
H1("Settings"),
P("Configure your preferences")
)
)
```
### Controlling Drawers
By default, both drawers are visible. The drawer state is managed automatically:
- Users can toggle drawers using the icon buttons in the header
- Users can resize drawers by dragging the handle
- Drawer state persists within the session
The initial drawer widths are:
- Left drawer: 250px
- Right drawer: 250px
These can be adjusted by users and the state is preserved automatically.
## Content System
### Understanding Groups
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
separated visually by dividers and can have optional labels.
### Adding Content to Groups
When adding content, you can optionally specify a group name:
```python
# Add items to different groups in the left drawer
layout.left_drawer.add(Div("Dashboard"), group="main")
layout.left_drawer.add(Div("Analytics"), group="main")
layout.left_drawer.add(Div("Settings"), group="preferences")
layout.left_drawer.add(Div("Profile"), group="preferences")
```
This creates two groups:
- **main**: Dashboard, Analytics
- **preferences**: Settings, Profile
A visual divider automatically appears between groups.
### Custom Group Labels
You can provide a custom FastHTML element to display as the group header:
```python
# Add a styled group header
layout.left_drawer.add_group(
"Navigation",
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
)
# Then add items to this group
layout.left_drawer.add(Div("Home"), group="Navigation")
layout.left_drawer.add(Div("About"), group="Navigation")
```
### Ungrouped Content
If you don't specify a group, content is added to the default (`None`) group:
```python
# These items are in the default group
layout.left_drawer.add(Div("Quick Action 1"))
layout.left_drawer.add(Div("Quick Action 2"))
```
### Preventing Duplicates
The Content system automatically prevents adding duplicate items based on their `id` attribute:
```python
item = Div("Unique Item", id="my-item")
layout.left_drawer.add(item)
layout.left_drawer.add(item) # Ignored - already added
```
### Group Rendering Options
Groups render differently depending on the zone:
**In drawers** (vertical layout):
- Groups stack vertically
- Dividers are horizontal lines
- Group labels appear above their content
**In header/footer** (horizontal layout):
- Groups arrange side-by-side
- Dividers are vertical lines
- Group labels are typically hidden
## Advanced Features
### Resizable Drawers
Both drawers can be resized by users via drag handles:
- **Drag handle location**:
- Left drawer: Right edge
- Right drawer: Left edge
- **Width constraints**: 150px (minimum) to 600px (maximum)
- **Persistence**: Resized width is automatically saved in the session state
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
### Programmatic Drawer Control
You can control drawers programmatically using commands:
```python
# Toggle drawer visibility
toggle_left = layout.commands.toggle_drawer("left")
toggle_right = layout.commands.toggle_drawer("right")
# Update drawer width
update_left_width = layout.commands.update_drawer_width("left", width=300)
update_right_width = layout.commands.update_drawer_width("right", width=350)
```
These commands are typically used with buttons or other interactive elements:
```python
# Add a button to toggle the right drawer
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
layout.header_right.add(button)
```
### State Persistence
The Layout automatically persists its state within the user's session:
| State Property | Description | Default |
|----------------------|---------------------------------|---------|
| `left_drawer_open` | Whether left drawer is visible | `True` |
| `right_drawer_open` | Whether right drawer is visible | `True` |
| `left_drawer_width` | Left drawer width in pixels | `250` |
| `right_drawer_width` | Right drawer width in pixels | `250` |
State changes (toggle, resize) are automatically saved and restored within the session.
### Dynamic Content Updates
Content zones can be updated dynamically during the session:
```python
# Initial setup
layout.left_drawer.add(Div("Item 1"))
# Later, add more items (e.g., in a command handler)
def add_dynamic_content():
layout.left_drawer.add(Div("New Item"), group="dynamic")
return layout.left_drawer # Return updated drawer for HTMX swap
```
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
### CSS Customization
The Layout uses CSS classes that you can customize:
| Class | Element |
|----------------------------|----------------------------------|
| `mf-layout` | Root layout container |
| `mf-layout-header` | Header section |
| `mf-layout-footer` | Footer section |
| `mf-layout-main` | Main content area |
| `mf-layout-drawer` | Drawer container |
| `mf-layout-left-drawer` | Left drawer specifically |
| `mf-layout-right-drawer` | Right drawer specifically |
| `mf-layout-drawer-content` | Scrollable content within drawer |
| `mf-resizer` | Resize handle |
| `mf-layout-group` | Content group wrapper |
You can override these classes in your custom CSS to change colors, spacing, or behavior.
### User Profile Integration
The Layout automatically includes a UserProfile component in the header right area. This component handles user
authentication display and logout functionality when auth is enabled.
## Examples
### Example 1: Dashboard with Navigation Sidebar
A typical dashboard application with a navigation menu in the left drawer:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Create layout
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
# Navigation menu in left drawer
def show_dashboard():
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
return layout._mk_main()
def show_reports():
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
return layout._mk_main()
def show_settings():
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
return layout._mk_main()
# Add navigation items with groups
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
group="main")
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
group="main")
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
group="config")
# Header content
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
# Footer
layout.footer_left.add(Div("© 2024 Analytics Co."))
layout.footer_right.add(Div("v1.0.0"))
# Set initial main content
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
```
### Example 2: Development Tool with Debug Panel
An application with development tools in the right drawer:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
# Create layout
layout = Layout(parent=root_instance, app_name="Dev Tools")
# Main content: code editor
layout.set_main(
Div(
H2("Code Editor"),
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
)
)
# Right drawer: debug and tools
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
layout.right_drawer.add(Div("Console output here..."), group="debug")
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
layout.right_drawer.add(Button("Run Code"), group="tools")
layout.right_drawer.add(Button("Clear Console"), group="tools")
# Header
layout.header_left.add(Div("DevTools IDE"))
layout.header_right.add(Button("Save"))
```
### Example 3: Minimal Layout (Main Content Only)
A simple layout without drawers, focusing only on main content:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
# Create layout
layout = Layout(parent=root_instance, app_name="Simple Blog")
# Header
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
layout.header_right.add(A("About", href="/about"))
# Main content
layout.set_main(
Article(
H1("Welcome to My Blog"),
P("This is a simple blog layout without side drawers."),
P("The focus is on the content in the center.")
)
)
# Footer
layout.footer_left.add(Div("© 2024 Blog Author"))
layout.footer_right.add(A("RSS", href="/rss"))
# Note: Drawers are present but can be collapsed by users if not needed
```
### Example 4: Dynamic Content Loading
Loading content dynamically based on user interaction:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
layout = Layout(parent=root_instance, app_name="Dynamic App")
# Function that loads content dynamically
def load_page(page_name):
# Simulate loading different content
content = {
"home": Div(H1("Home"), P("Welcome to the home page")),
"profile": Div(H1("Profile"), P("User profile information")),
"settings": Div(H1("Settings"), P("Application settings")),
}
layout.set_main(content.get(page_name, Div("Page not found")))
return layout._mk_main()
# Create navigation commands
pages = ["home", "profile", "settings"]
for page in pages:
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
layout.left_drawer.add(
mk.mk(Div(page.capitalize()), command=cmd)
)
# Set initial content
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
```
---
## Developer Reference
This section contains technical details for developers working on the Layout component itself.
### State
The Layout component maintains the following state properties:
| Name | Type | Description | Default |
|----------------------|---------|----------------------------------|---------|
| `left_drawer_open` | boolean | True if the left drawer is open | True |
| `right_drawer_open` | boolean | True if the right drawer is open | True |
| `left_drawer_width` | integer | Width of the left drawer | 250 |
| `right_drawer_width` | integer | Width of the right drawer | 250 |
### Commands
Available commands for programmatic control:
| Name | Description |
|-----------------------------------------|----------------------------------------------------------------------------------------|
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
### Public Methods
| Method | Description |
|---------------------|-----------------------------|
| `set_main(content)` | Sets the main content area |
| `render()` | Renders the complete layout |
### High Level Hierarchical Structure
```
Div(id="layout")
├── Header
│ ├── Div(id="layout_hl")
│ │ ├── Icon # Left drawer icon button
│ │ └── Div # Left content for the header
│ └── Div(id="layout_hr")
│ ├── Div # Right content for the header
│ └── UserProfile # user profile icon button
├── Div # Left Drawer
├── Main # Main content
├── Div # Right Drawer
├── Footer # Footer
└── Script # To initialize the resizing
```
### Element IDs
| Name | Description |
|-------------|-------------------------------------|
| `layout` | Root layout container (singleton) |
| `layout_h` | Header section (not currently used) |
| `layout_hl` | Header left side |
| `layout_hr` | Header right side |
| `layout_f` | Footer section (not currently used) |
| `layout_fl` | Footer left side |
| `layout_fr` | Footer right side |
| `layout_ld` | Left drawer |
| `layout_rd` | Right drawer |
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|---------------------------|--------------------------------------------------------|
| `_mk_header()` | Renders the header component |
| `_mk_footer()` | Renders the footer component |
| `_mk_main()` | Renders the main content area |
| `_mk_left_drawer()` | Renders the left drawer |
| `_mk_right_drawer()` | Renders the right drawer |
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
### Content Class
The `Layout.Content` nested class manages content zones:
| Method | Description |
|-----------------------------------|----------------------------------------------------------|
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
| `get_content()` | Returns dictionary of groups and their content |
| `get_groups()` | Returns list of (group_name, group_ft) tuples |

801
docs/Mouse Support.md Normal file
View File

@@ -0,0 +1,801 @@
# Mouse Support - Documentation
## Overview
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
## Features
### Supported Mouse Actions
**Basic Actions**:
- `click` - Left click
- `right_click` (or `rclick`) - Right click (contextmenu)
**Modified Actions**:
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
- `shift+click` (or `shift+rclick`) - Shift+Click
- `alt+click` (or `alt+rclick`) - Alt+Click
- `ctrl+shift+click` - Multiple modifiers
- Any combination of modifiers
**Drag Actions**:
- `mousedown>mouseup` - Left button drag (press, drag at least 5px, release)
- `rmousedown>mouseup` - Right button drag
- `ctrl+mousedown>mouseup` - Ctrl + left drag
- `shift+mousedown>mouseup` - Shift + left drag
- Any combination of modifiers
**Sequences**:
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
- `click click` - Double click sequence
- `ctrl+click click` - Ctrl+click then normal click
- Any sequence of actions
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
### Smart Timeout Logic
Same as keyboard support:
- If **any element** has a longer sequence possible, **all matching elements wait**
- Timeout is 500ms between actions
- Immediate trigger if no longer sequences exist
### Multiple Element Support
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
## Configuration Format
Uses HTMX configuration objects (same as keyboard support):
```javascript
const combinations = {
"click": {
"hx-post": "/handle-click",
"hx-target": "#result"
},
"ctrl+click": {
"hx-post": "/handle-ctrl-click",
"hx-swap": "innerHTML"
},
"rclick": { // Alias for right_click
"hx-post": "/context-menu"
},
"click rclick": { // Can use rclick in sequences too
"hx-post": "/sequence-action",
"hx-vals": {"type": "sequence"}
}
};
add_mouse_support('my-element', JSON.stringify(combinations));
```
### Dynamic Values with JavaScript Functions
You can add dynamic values computed at click time using `hx-vals-extra`. This is useful when combined with a Command (which provides `hx-vals` with `c_id`).
**Configuration format:**
```javascript
const combinations = {
"click": {
"hx-post": "/myfasthtml/commands",
"hx-vals": {"c_id": "command_id"}, // Static values from Command
"hx-vals-extra": {"js": "getClickData"} // Dynamic values via JS function
}
};
```
**How it works:**
1. `hx-vals` contains static values (e.g., `c_id` from Command)
2. `hx-vals-extra.dict` contains additional static values (merged)
3. `hx-vals-extra.js` specifies a function to call for dynamic values (merged)
**JavaScript function definition:**
```javascript
// Function receives (event, element, combinationStr)
function getClickData(event, element, combination) {
return {
x: event.clientX,
y: event.clientY,
target_id: event.target.id,
timestamp: Date.now()
};
}
```
The function parameters are optional - use what you need:
```javascript
// Full context
function getFullContext(event, element, combination) {
return { x: event.clientX, elem: element.id, combo: combination };
}
// Just the event
function getPosition(event) {
return { x: event.clientX, y: event.clientY };
}
// No parameters needed
function getTimestamp() {
return { ts: Date.now() };
}
```
**Built-in helper function:**
```javascript
// getCellId() - finds parent with .dt2-cell class and returns its id
function getCellId(event) {
const cell = event.target.closest('.dt2-cell');
if (cell && cell.id) {
return { cell_id: cell.id };
}
return {};
}
```
## Drag Actions (mousedown>mouseup)
### How It Works
Drag detection uses a **5-pixel threshold**: the action only activates when the mouse has moved at least 5px after mousedown. This prevents accidental drags from normal clicks.
**Lifecycle**:
1. `mousedown` → library waits, stores start position
2. Mouse moves > 5px → drag mode activated, `hx-vals-extra` function called with mousedown event → result stored
3. Mouse moves (during drag) → `on_move` function called on each animation frame *(if configured)*
4. `mouseup``hx-vals-extra` function called again with mouseup event → HTMX request fired with both values
5. The subsequent `click` event is suppressed (left button only)
### Two-Phase Values
For `mousedown>mouseup`, the `hx-vals-extra` function is called **twice** — once at each phase — and values are suffixed automatically:
```javascript
// hx-vals-extra function (same function, called twice)
function getCellId(event) {
const cell = event.target.closest('.dt2-cell');
return { cell_id: cell.id };
}
```
**Values sent to server**:
```json
{
"c_id": "command_id",
"cell_id_mousedown": "tcell_grid-0-2",
"cell_id_mouseup": "tcell_grid-3-5",
"combination": "mousedown>mouseup",
"is_inside": true,
"has_focus": false
}
```
**Python handler**:
```python
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
# cell_id_mousedown: where the drag started
# cell_id_mouseup: where the drag ended
...
```
### Real-Time Visual Feedback with `on_move`
The `on_move` attribute specifies a JavaScript function to call on each animation frame **during the drag**, enabling real-time visual feedback without any server calls.
**Configuration**:
```javascript
{
"mousedown>mouseup": {
"hx-post": "/myfasthtml/commands",
"hx-vals": {"c_id": "command_id"},
"hx-vals-extra": {"js": "getCellId"},
"on-move": "onDragMove" // called during drag
}
}
```
**`on_move` function signature**:
```javascript
function onDragMove(event, combination, mousedown_result) {
// event : current mousemove event
// combination : e.g. "mousedown>mouseup" or "ctrl+mousedown>mouseup"
// mousedown_result : raw result of hx-vals-extra at mousedown (unsuffixed), or null
}
```
**Key properties**:
- Called only after the 5px drag threshold is exceeded (never during a simple click)
- Throttled via `requestAnimationFrame` (~60fps) — no manual throttling needed
- Return value is ignored
- Visual state cleanup is handled by the server response (which overwrites any client-side visual)
**DataGrid range selection example**:
```javascript
function highlightDragRange(event, combination, mousedownResult) {
const startCell = mousedownResult ? mousedownResult.cell_id : null;
const endCell = event.target.closest('.dt2-cell');
if (!startCell || !endCell) return;
// Clear previous preview
document.querySelectorAll('.dt2-drag-preview')
.forEach(el => el.classList.remove('dt2-drag-preview'));
// Highlight range from start to current cell
applyRangeClass(startCell, endCell.id, 'dt2-drag-preview');
}
```
**Canvas selection rectangle example**:
```javascript
function drawSelectionRect(event, combination, mousedownResult) {
if (!mousedownResult) return;
const canvas = document.getElementById('my-canvas');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'blue';
ctx.strokeRect(
mousedownResult.x - rect.left,
mousedownResult.y - rect.top,
event.clientX - rect.left - (mousedownResult.x - rect.left),
event.clientY - rect.top - (mousedownResult.y - rect.top)
);
}
```
**Python configuration**:
```python
mouse.add(
"mousedown>mouseup",
selection_command,
hx_vals="js:getCellId()",
on_move="js:highlightDragRange()"
)
```
## API Reference
### add_mouse_support(elementId, combinationsJson)
Adds mouse support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
**Example**:
```javascript
add_mouse_support('button1', JSON.stringify({
"click": {"hx-post": "/click"},
"ctrl+click": {"hx-post": "/ctrl-click"}
}));
```
### remove_mouse_support(elementId)
Removes mouse support from an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
**Returns**: void
**Side effects**:
- If last element: detaches global event listeners and cleans up all state
**Example**:
```javascript
remove_mouse_support('button1');
```
## Automatic Parameters
The library automatically adds these parameters to every HTMX request:
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
- `has_focus` - Boolean indicating if the element had focus when clicked
- `is_inside` - Boolean indicating if the click was inside the element
- For `click`: `true` if clicked inside element, `false` if clicked outside
- For `right_click`: always `true` (only triggers when clicking on element)
- `has_focus` - Boolean indicating if the element had focus when the action triggered
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
### Parameter Details
**`has_focus`**:
- `true` if the registered element currently has focus
- `false` otherwise
- Useful for knowing if the element was the active element
**`clicked_inside`**:
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
- For `right_click` actions: always `true` (since right-click only triggers on the element)
- Useful for "click outside to close" logic
**Example values sent**:
```javascript
// User clicks inside a modal
{
combination: "click",
has_focus: true,
clicked_inside: true
}
// User clicks outside the modal (modal still gets triggered because click is global)
{
combination: "click",
has_focus: false,
clicked_inside: false // Perfect for closing the modal!
}
// User right-clicks on an item
{
combination: "right_click",
has_focus: false,
clicked_inside: true // Always true for right_click
}
```
## Python Integration
### Mouse Class
The `Mouse` class provides a convenient way to add mouse support to elements.
```python
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.core.commands import Command
# Create mouse support for an element
mouse = Mouse(parent_element)
# Add combinations
mouse.add("click", select_command)
mouse.add("ctrl+click", toggle_command)
mouse.add("right_click", context_menu_command)
```
### Mouse.add() Method
```python
def add(self, sequence: str, command: Command = None, *,
hx_post: str = None, hx_get: str = None, hx_put: str = None,
hx_delete: str = None, hx_patch: str = None,
hx_target: str = None, hx_swap: str = None, hx_vals=None,
on_move: str = None)
```
**Parameters**:
- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "mousedown>mouseup")
- `command`: Optional Command object for server-side action
- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command)
- `hx_target`: HTMX target selector (overrides command)
- `hx_swap`: HTMX swap strategy (overrides command)
- `hx_vals`: Additional HTMX values - dict or `"js:functionName()"` for dynamic values
- `on_move`: Client-side JS function called during drag — `"js:functionName()"` format. Only valid with `mousedown>mouseup` sequences.
**Note**:
- Named parameters (except `hx_vals`) override the command's parameters.
- `hx_vals` is **merged** with command's values (stored in `hx-vals-extra`), preserving `c_id`.
- `on_move` is purely client-side — it never triggers a server call.
### Usage Patterns
**With Command only**:
```python
mouse.add("click", my_command)
```
**With Command and overrides**:
```python
# Command provides hx-post, but we override the target
mouse.add("ctrl+click", my_command, hx_target="#other-result")
```
**Without Command (direct HTMX)**:
```python
mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="innerHTML")
```
**With dynamic values**:
```python
mouse.add("shift+click", my_command, hx_vals="js:getClickPosition()")
```
**With drag and real-time feedback**:
```python
mouse.add(
"mousedown>mouseup",
selection_command,
hx_vals="js:getCellId()",
on_move="js:highlightDragRange()"
)
```
### Sequences
```python
mouse = Mouse(element)
mouse.add("click", single_click_command)
mouse.add("click click", double_click_command)
mouse.add("click right_click", special_action_command)
```
### Multiple Elements
```python
# Each element gets its own Mouse instance
for item in items:
mouse = Mouse(item)
mouse.add("click", Command("select", "Select item", lambda i=item: select(i)))
mouse.add("ctrl+click", Command("toggle", "Toggle item", lambda i=item: toggle(i)))
```
### Dynamic hx-vals with JavaScript
You can use `"js:functionName()"` to call a client-side JavaScript function that returns additional values to send with the request. The command's `c_id` is preserved.
**Python**:
```python
mouse.add("click", my_command, hx_vals="js:getClickContext()")
```
**Generated config** (internally):
```json
{
"hx-post": "/myfasthtml/commands",
"hx-vals": {"c_id": "command_id"},
"hx-vals-extra": {"js": "getClickContext"}
}
```
**JavaScript** (client-side):
```javascript
// Function receives (event, element, combinationStr)
function getClickContext(event, element, combination) {
return {
x: event.clientX,
y: event.clientY,
elementId: element.id,
combo: combination
};
}
// Simple function - parameters are optional
function getTimestamp() {
return { ts: Date.now() };
}
```
**Values sent to server**:
```json
{
"c_id": "command_id",
"x": 150,
"y": 200,
"elementId": "my-element",
"combo": "click",
"combination": "click",
"has_focus": false,
"is_inside": true
}
```
You can also pass a static dict:
```python
mouse.add("click", my_command, hx_vals={"extra_key": "extra_value"})
```
### Low-Level Usage (without Mouse class)
For advanced use cases, you can generate the JavaScript directly:
```python
import json
combinations = {
"click": {
"hx-post": "/item/select"
},
"ctrl+click": {
"hx-post": "/item/select-multiple",
"hx-vals": {"mode": "multi"}
},
"right_click": {
"hx-post": "/item/context-menu",
"hx-target": "#context-menu",
"hx-swap": "innerHTML"
}
}
Script(f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')")
```
## Behavior Details
### Click vs Right-Click Behavior
**IMPORTANT**: The library handles `click` and `right_click` differently:
**`click` (global detection)**:
- Triggers for ALL registered elements, regardless of where you click
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
**`right_click` (element-specific detection)**:
- Triggers ONLY when you right-click on (or inside) the registered element
- Right-clicking outside the element does nothing and shows browser's context menu
- This preserves normal browser behavior while adding custom actions on your elements
**Example use case**:
```javascript
// Modal that closes when clicking anywhere
add_mouse_support('modal', JSON.stringify({
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
}));
// Context menu that only appears on element
add_mouse_support('item', JSON.stringify({
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
}));
```
### Modifier Keys (Cross-Platform)
- **Windows/Linux**: `ctrl+click` uses Ctrl key
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
- This follows standard web conventions for cross-platform compatibility
### Input Context Protection
Mouse actions are **disabled** when clicking in input fields:
- `<input>` elements
- `<textarea>` elements
- Any `contenteditable` element
This ensures normal text selection and interaction works in forms.
### Right-Click Menu
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
- A `right_click` action is configured for the element
- The element is NOT an input/textarea/contenteditable
### Event Bubbling
The library checks if the click target OR any parent element is registered:
```html
<div id="container">
<button>Click me</button>
</div>
```
If `container` is registered, clicking the button will trigger the container's actions.
## Examples
### Using Aliases
You can use `rclick` instead of `right_click` anywhere:
```javascript
// These are equivalent
const config1 = {
"right_click": {"hx-post": "/menu"}
};
const config2 = {
"rclick": {"hx-post": "/menu"} // Shorter alias
};
// Works in sequences too
const config3 = {
"click rclick": {"hx-post": "/sequence"}
};
// Works with modifiers
const config4 = {
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
};
```
### Context Menu
```javascript
const combinations = {
"right_click": {
"hx-post": "/show-context-menu",
"hx-target": "#menu",
"hx-swap": "innerHTML"
}
};
```
### Close Modal/Popup on Click Outside
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
```javascript
// Modal element
const modalCombinations = {
"click": {
"hx-post": "/close-modal",
"hx-target": "#modal",
"hx-swap": "outerHTML"
}
};
add_mouse_support('modal', JSON.stringify(modalCombinations));
// Now clicking ANYWHERE on the page will trigger the handler
// Your backend receives:
// - clicked_inside: true (if clicked on modal) → maybe keep it open
// - clicked_inside: false (if clicked outside) → close it!
```
**Backend example (Python/Flask)**:
```python
@app.route('/close-modal', methods=['POST'])
def close_modal():
clicked_inside = request.form.get('clicked_inside') == 'true'
if clicked_inside:
# User clicked inside the modal - keep it open
return render_template('modal_content.html')
else:
# User clicked outside - close the modal
return '' # Empty response removes the modal
```
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
### Multi-Select List
```javascript
const combinations = {
"click": {
"hx-post": "/select-item",
"hx-vals": {"mode": "single"}
},
"ctrl+click": {
"hx-post": "/select-item",
"hx-vals": {"mode": "toggle"}
},
"shift+click": {
"hx-post": "/select-range",
"hx-vals": {"mode": "range"}
}
};
```
### Interactive Canvas/Drawing
```javascript
const combinations = {
"click": {
"hx-post": "/draw-point"
},
"ctrl+click": {
"hx-post": "/draw-special"
},
"click click": {
"hx-post": "/confirm-action"
}
};
```
### Drag-and-Drop Alternative
```javascript
const combinations = {
"click": {
"hx-post": "/select-source"
},
"click click": {
"hx-post": "/set-destination"
}
};
```
### Range Selection with Visual Feedback
```python
# Python: configure drag with live feedback
mouse.add(
"mousedown>mouseup",
self.commands.on_mouse_selection(),
hx_vals="js:getCellId()",
on_move="js:highlightDragRange()"
)
```
```javascript
// JavaScript: real-time highlight during drag
function highlightDragRange(event, combination, mousedownResult) {
const startCell = mousedownResult ? mousedownResult.cell_id : null;
const endCell = event.target.closest('.dt2-cell');
if (!startCell || !endCell) return;
document.querySelectorAll('.dt2-drag-preview')
.forEach(el => el.classList.remove('dt2-drag-preview'));
applyRangeClass(startCell, endCell.id, 'dt2-drag-preview');
// Server response will replace .dt2-drag-preview with final selection classes
}
```
```python
# Python: server handler receives both positions
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
if is_inside and cell_id_mousedown and cell_id_mouseup:
pos_start = self._get_pos_from_element_id(cell_id_mousedown)
pos_end = self._get_pos_from_element_id(cell_id_mouseup)
self._state.selection.set_range(pos_start, pos_end)
return self.render_partial()
```
## Troubleshooting
### Clicks not detected
- Verify the element exists and has the correct ID
- Check browser console for errors
- Ensure HTMX is loaded before mouse_support.js
### Right-click menu still appears
- Check if element is in input context (input/textarea)
- Verify the combination is configured correctly
- Check browser console for configuration errors
### Sequences not working
- Ensure clicks happen within 500ms timeout
- Check if longer sequences exist (causes waiting)
- Verify the combination string format (space-separated)
### Drag not triggering
- Ensure the mouse moved at least 5px before releasing
- Verify `mousedown>mouseup` (not `mousedown_mouseup`) in the combination string
- Check that `hx-vals-extra` function exists and is accessible via `window`
### `on_move` not called
- Verify `on_move` is only used with `mousedown>mouseup` sequences
- Check that the function name matches exactly (case-sensitive)
- Ensure the function is accessible via `window` (not inside a module scope)
- Remember: `on_move` only fires after the 5px threshold — it won't fire on small movements
## Technical Details
### Architecture
- **Global listeners** on `document` for `click`, `contextmenu`, `mousedown`, `mouseup` events
- **Tree-based matching** using prefix trees (same as keyboard support)
- **Single timeout** for all elements (sequence-based, not element-based)
- **Independent from keyboard support** (separate registry and timeouts)
- **Drag detection**: temporary `mousemove` listener attached on `mousedown`, removed when 5px threshold exceeded
- **`on_move` throttling**: `requestAnimationFrame` used internally — no manual throttling needed in user functions
### Performance
- Single event listener regardless of number of elements
- O(n) matching where n is sequence length
- Efficient memory usage with automatic cleanup
### Browser Compatibility
- Modern browsers (ES6+ required)
- Chrome, Firefox, Safari, Edge
- Requires HTMX library
## Notes
- Timeout value is the same as keyboard support (500ms) but in separate variable
- Can be used independently or alongside keyboard support
- Does not interfere with normal mouse behavior in inputs
- Element must exist in DOM when `add_mouse_support()` is called
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax

944
docs/Panel.md Normal file
View File

@@ -0,0 +1,944 @@
# Panel Component
## Introduction
The Panel component provides a flexible three-zone layout with optional collapsible side panels. It's designed to
organize content into left panel, main area, and right panel sections, with smooth toggle animations and resizable
panels.
**Key features:**
- Three customizable zones (left panel, main content, right panel)
- Configurable panel titles with sticky headers
- Toggle visibility with hide/show icons
- Resizable panels with drag handles
- Smooth CSS animations for show/hide transitions
- Automatic state persistence per session
- Configurable panel presence (enable/disable left or right)
- Session-based width and visibility state
**Common use cases:**
- Code editor with file explorer and properties panel
- Data visualization with filters sidebar and details panel
- Admin interface with navigation menu and tools panel
- Documentation viewer with table of contents and metadata
- Dashboard with configuration panel and information sidebar
## Quick Start
Here's a minimal example showing a three-panel layout for a code editor:
```python
from fasthtml.common import *
from myfasthtml.controls.Panel import Panel
# Create the panel instance
panel = Panel(parent=root_instance)
# Set content for each zone
panel.set_left(
Div(
H3("Files"),
Ul(
Li("app.py"),
Li("config.py"),
Li("utils.py")
)
)
)
panel.set_main(
Div(
H2("Editor"),
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
)
)
panel.set_right(
Div(
H3("Properties"),
Div("Language: Python"),
Div("Lines: 120"),
Div("Size: 3.2 KB")
)
)
# Render the panel
return panel
```
This creates a complete panel layout with:
- A left panel displaying a file list with a hide icon () at the top right
- A main content area with a code editor
- A right panel showing file properties with a hide icon () at the top right
- Show icons (⋯) that appear in the main area when panels are hidden
- Drag handles between panels for manual resizing
- Automatic state persistence (visibility and width)
**Note:** Users can hide panels by clicking the hide icon () inside each panel. When hidden, a show icon (⋯) appears in
the main area (left side for left panel, right side for right panel). Panels can be resized by dragging the handles, and
all state is automatically saved in the session.
## Basic Usage
### Visual Structure
The Panel component consists of three zones with optional side panels:
```
┌────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ │ ┌──────────────────────┐ │ ┌──────────┐ │
│ │ │ │ │ │ │ │ │ │
│ │ Left │ ║ │ │ ║ │ Right │ │
│ │ Panel │ │ │ Main Content │ │ │ Panel │ │
│ │ │ │ │ │ │ │ │ │
│ │ [] │ │ │ [⋯] [⋯] │ │ │ [] │ │
│ └──────────┘ │ └──────────────────────┘ │ └──────────┘ │
│ ║ ║ │
│ Resizer Resizer │
└────────────────────────────────────────────────────────────┘
```
**Component details:**
| Element | Description |
|---------------|-----------------------------------------------|
| Left panel | Optional collapsible panel (default: visible) |
| Main content | Always-visible central content area |
| Right panel | Optional collapsible panel (default: visible) |
| Hide icon () | Inside each panel header, right side |
| Show icon (⋯) | In main area when panel is hidden |
| Resizer (║) | Drag handle to resize panels manually |
**Panel with title (default):**
When `show_left_title` or `show_right_title` is `True` (default), panels display a sticky header with title and hide icon:
```
┌─────────────────────────────┐
│ Title [] │ ← Header (sticky, always visible)
├─────────────────────────────┤
│ │
│ Scrollable Content │ ← Content area (scrolls independently)
│ │
└─────────────────────────────┘
```
**Panel without title:**
When `show_left_title` or `show_right_title` is `False`, panels use the legacy layout:
```
┌─────────────────────────────┐
│ [] │ ← Hide icon at top-right (absolute)
│ │
│ Content │
│ │
└─────────────────────────────┘
```
### Creating a Panel
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
providing a parent instance:
```python
panel = Panel(parent=root_instance)
# Or with a custom ID
panel = Panel(parent=root_instance, _id="my-panel")
# Or with custom configuration
from myfasthtml.controls.Panel import PanelConf
conf = PanelConf(left=True, right=False) # Only left panel enabled
panel = Panel(parent=root_instance, conf=conf)
```
### Content Zones
The Panel provides three content zones:
```
┌─────────────────────────────────────────────────────┐
│ Left Panel │ Main Content │ Right Panel │
│ (optional) │ (required) │ (optional) │
└─────────────────────────────────────────────────────┘
```
**Zone details:**
| Zone | Typical Use | Required |
|---------|-------------------------------------------------------|----------|
| `left` | Navigation, file explorer, filters, table of contents | No |
| `main` | Primary content, editor, visualization, results | Yes |
| `right` | Properties, tools, metadata, debug info, settings | No |
### Setting Content
Use the `set_*()` methods to add content to each zone:
```python
# Main content (always visible)
panel.set_main(
Div(
H1("Dashboard"),
P("This is the main content area")
)
)
# Left panel (optional)
panel.set_left(
Div(
H3("Navigation"),
Ul(
Li("Home"),
Li("Settings"),
Li("About")
)
)
)
# Right panel (optional)
panel.set_right(
Div(
H3("Tools"),
Button("Export"),
Button("Refresh")
)
)
```
**Method chaining:**
The `set_main()` method returns `self`, enabling method chaining:
```python
panel = Panel(parent=root_instance)
.set_main(Div("Main content"))
.set_left(Div("Left content"))
```
### Panel Configuration
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
```python
from myfasthtml.controls.Panel import PanelConf
# Only left panel enabled
conf = PanelConf(left=True, right=False)
panel = Panel(parent=root_instance, conf=conf)
# Only right panel enabled
conf = PanelConf(left=False, right=True)
panel = Panel(parent=root_instance, conf=conf)
# Both panels enabled (default)
conf = PanelConf(left=True, right=True)
panel = Panel(parent=root_instance, conf=conf)
# No side panels (main content only)
conf = PanelConf(left=False, right=False)
panel = Panel(parent=root_instance, conf=conf)
```
**Customizing panel titles:**
```python
# Custom titles for panels
conf = PanelConf(
left=True,
right=True,
left_title="Explorer", # Custom title for left panel
right_title="Properties" # Custom title for right panel
)
panel = Panel(parent=root_instance, conf=conf)
```
**Disabling panel titles:**
When titles are disabled, panels use the legacy layout without a sticky header:
```python
# Disable titles (legacy layout)
conf = PanelConf(
left=True,
right=True,
show_left_title=False,
show_right_title=False
)
panel = Panel(parent=root_instance, conf=conf)
```
**Disabling show icons:**
You can hide the show icons (⋯) that appear when panels are hidden. This means users can only show panels programmatically:
```python
# Disable show icons (programmatic control only)
conf = PanelConf(
left=True,
right=True,
show_display_left=False, # No show icon for left panel
show_display_right=False # No show icon for right panel
)
panel = Panel(parent=root_instance, conf=conf)
```
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
renders but with zero width and overflow hidden.
## Advanced Features
### Toggling Panel Visibility
Each visible panel includes a hide icon () in its top-right corner. When hidden, a show icon (⋯) appears in the main
area:
**User interaction:**
- **Hide panel**: Click the icon inside the panel
- **Show panel**: Click the ⋯ icon in the main area
**Icon positions:**
- Hide icons (): Always at top-right of each panel
- Show icon for left panel (⋯): Top-left of main area
- Show icon for right panel (⋯): Top-right of main area
**Visual states:**
```
Panel Visible:
┌──────────┐
│ Content │
│ [] │ ← Hide icon visible
└──────────┘
Panel Hidden:
┌──────────────────┐
│ [⋯] Main │ ← Show icon visible in main
└──────────────────┘
```
**Animation:**
When toggling visibility:
- **Hiding**: Panel width animates to 0px over 0.3s
- **Showing**: Panel width animates to its saved width over 0.3s
- Content remains in DOM (state preserved)
- Smooth CSS transition with ease timing
**Note:** The animation only works when showing (panel appearing). When hiding, the transition currently doesn't apply
due to HTMX swap timing. This is a known limitation.
### Resizable Panels
Both left and right panels can be resized by users via drag handles:
- **Drag handle location**:
- Left panel: Right edge (vertical bar)
- Right panel: Left edge (vertical bar)
- **Width constraints**: 150px (minimum) to 500px (maximum)
- **Persistence**: Resized width is automatically saved in session state
- **No transition during resize**: CSS transitions are disabled during manual dragging for smooth performance
**How to resize:**
1. Hover over the panel edge (cursor changes to resize cursor)
2. Click and drag left/right
3. Release to set the new width
4. Width is saved automatically and persists in the session
**Initial widths:**
- Left panel: 250px
- Right panel: 250px
These defaults can be customized via state after creation if needed.
### State Persistence
The Panel automatically persists its state within the user's session:
| State Property | Description | Default |
|-----------------|--------------------------------|---------|
| `left_visible` | Whether left panel is visible | `True` |
| `right_visible` | Whether right panel is visible | `True` |
| `left_width` | Left panel width in pixels | `250` |
| `right_width` | Right panel width in pixels | `250` |
State changes (toggle visibility, resize width) are automatically saved and restored within the session.
**Accessing state:**
```python
# Check current state
is_left_visible = panel._state.left_visible
left_panel_width = panel._state.left_width
# Programmatically update state (not recommended - use commands instead)
panel._state.left_visible = False # Better to use toggle_side command
```
### Programmatic Control
You can control panels programmatically using commands:
```python
# Toggle panel visibility
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
# Update panel width
update_left_width = panel.commands.update_side_width("left")
update_right_width = panel.commands.update_side_width("right")
```
These commands are typically used with buttons or other interactive elements:
```python
from myfasthtml.controls.helpers import mk
# Add buttons to toggle panels
hide_left_btn = mk.button("Hide Left", command=panel.commands.set_side_visible("left", False))
show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
# Add to your layout
panel.set_main(
Div(
hide_left_btn,
show_left_btn,
H1("Main Content")
)
)
```
**Command details:**
- `toggle_side(side, visible)`: Sets panel visibility explicitly
- `side`: `"left"` or `"right"`
- `visible`: `True` (show) or `False` (hide)
- Returns: tuple of (panel_element, show_icon_element) for HTMX swap
- `update_side_width(side)`: Updates panel width from HTMX request
- `side`: `"left"` or `"right"`
- Width value comes from JavaScript resize handler
- Returns: updated panel element for HTMX swap
### CSS Customization
The Panel uses CSS classes that you can customize:
| Class | Element |
|----------------------------|--------------------------------------------|
| `mf-panel` | Root panel container |
| `mf-panel-left` | Left panel container |
| `mf-panel-right` | Right panel container |
| `mf-panel-main` | Main content area |
| `mf-panel-with-title` | Panel using title layout (no padding-top) |
| `mf-panel-body` | Grid container for header + content |
| `mf-panel-header` | Sticky header with title and hide icon |
| `mf-panel-content` | Scrollable content area |
| `mf-panel-hide-icon` | Hide icon () inside panels |
| `mf-panel-show-icon` | Show icon (⋯) in main area |
| `mf-panel-show-icon-left` | Show icon for left panel |
| `mf-panel-show-icon-right` | Show icon for right panel |
| `mf-resizer` | Resize handle base class |
| `mf-resizer-left` | Left panel resize handle |
| `mf-resizer-right` | Right panel resize handle |
| `mf-hidden` | Applied to hidden panels |
| `no-transition` | Disables transition during manual resize |
**Example customization:**
```css
/* Change panel background color */
.mf-panel-left,
.mf-panel-right {
background-color: #f9fafb;
}
/* Customize hide icon appearance */
.mf-panel-hide-icon:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #ef4444;
}
/* Change transition timing */
.mf-panel-left,
.mf-panel-right {
transition: width 0.5s ease-in-out; /* Slower animation */
}
/* Style resizer handles */
.mf-resizer {
background-color: #e5e7eb;
}
.mf-resizer:hover {
background-color: #3b82f6;
}
```
## Examples
### Example 1: Code Editor Layout
A typical code editor with file explorer, editor, and properties panel:
```python
from fasthtml.common import *
from myfasthtml.controls.Panel import Panel
# Create panel
panel = Panel(parent=root_instance)
# Left panel: File Explorer
panel.set_left(
Div(
H3("Explorer", cls="font-bold mb-2"),
Div(
Div("📁 src", cls="font-mono cursor-pointer"),
Div(" 📄 app.py", cls="font-mono ml-4 cursor-pointer"),
Div(" 📄 config.py", cls="font-mono ml-4 cursor-pointer"),
Div("📁 tests", cls="font-mono cursor-pointer"),
Div(" 📄 test_app.py", cls="font-mono ml-4 cursor-pointer"),
cls="space-y-1"
),
cls="p-4"
)
)
# Main: Code Editor
panel.set_main(
Div(
Div(
Span("app.py", cls="font-bold"),
Span("Python", cls="text-sm opacity-60 ml-2"),
cls="border-b pb-2 mb-2"
),
Textarea(
"""def main():
print("Hello, World!")
if __name__ == "__main__":
main()""",
rows=20,
cls="w-full font-mono text-sm p-2 border rounded"
),
cls="p-4"
)
)
# Right panel: Properties and Tools
panel.set_right(
Div(
H3("Properties", cls="font-bold mb-2"),
Div("Language: Python", cls="text-sm mb-1"),
Div("Lines: 5", cls="text-sm mb-1"),
Div("Size: 87 bytes", cls="text-sm mb-4"),
H3("Tools", cls="font-bold mb-2 mt-4"),
Button("Run", cls="btn btn-sm btn-primary w-full mb-2"),
Button("Debug", cls="btn btn-sm w-full mb-2"),
Button("Format", cls="btn btn-sm w-full"),
cls="p-4"
)
)
return panel
```
### Example 2: Dashboard with Filters
A data dashboard with filters sidebar and details panel:
```python
from fasthtml.common import *
from myfasthtml.controls.Panel import Panel
# Create panel
panel = Panel(parent=root_instance)
# Left panel: Filters
panel.set_left(
Div(
H3("Filters", cls="font-bold mb-3"),
Div(
Label("Date Range", cls="label"),
Select(
Option("Last 7 days"),
Option("Last 30 days"),
Option("Last 90 days"),
cls="select select-bordered w-full"
),
cls="mb-3"
),
Div(
Label("Category", cls="label"),
Div(
Label(Input(type="checkbox", cls="checkbox"), " Sales", cls="label cursor-pointer"),
Label(Input(type="checkbox", cls="checkbox"), " Marketing", cls="label cursor-pointer"),
Label(Input(type="checkbox", cls="checkbox"), " Support", cls="label cursor-pointer"),
cls="space-y-2"
),
cls="mb-3"
),
Button("Apply Filters", cls="btn btn-primary w-full"),
cls="p-4"
)
)
# Main: Dashboard Charts
panel.set_main(
Div(
H1("Analytics Dashboard", cls="text-2xl font-bold mb-4"),
Div(
Div(
Div("Total Revenue", cls="stat-title"),
Div("$45,231", cls="stat-value"),
Div("+12% from last month", cls="stat-desc"),
cls="stat"
),
Div(
Div("Active Users", cls="stat-title"),
Div("2,345", cls="stat-value"),
Div("+8% from last month", cls="stat-desc"),
cls="stat"
),
cls="stats shadow mb-4"
),
Div("[Chart placeholder - Revenue over time]", cls="border rounded p-8 text-center"),
cls="p-4"
)
)
# Right panel: Details and Insights
panel.set_right(
Div(
H3("Key Insights", cls="font-bold mb-3"),
Div(
Div("🎯 Top Performing", cls="font-bold mb-1"),
Div("Product A: $12,450", cls="text-sm"),
Div("Product B: $8,920", cls="text-sm mb-3")
),
Div(
Div("📊 Trending Up", cls="font-bold mb-1"),
Div("Category: Electronics", cls="text-sm"),
Div("+23% this week", cls="text-sm mb-3")
),
Div(
Div("⚠️ Needs Attention", cls="font-bold mb-1"),
Div("Low stock: Item X", cls="text-sm"),
Div("Response time: +15%", cls="text-sm")
),
cls="p-4"
)
)
return panel
```
### Example 3: Simple Layout (Main Content Only)
A minimal panel with no side panels, focusing only on main content:
```python
from fasthtml.common import *
from myfasthtml.controls.Panel import Panel, PanelConf
# Create panel with both side panels disabled
conf = PanelConf(left=False, right=False)
panel = Panel(parent=root_instance, conf=conf)
# Only main content
panel.set_main(
Article(
H1("Welcome to My Blog", cls="text-3xl font-bold mb-4"),
P("This is a simple layout focusing entirely on the main content."),
P("No side panels distract from the reading experience."),
P("The content takes up the full width of the container."),
cls="prose max-w-none p-8"
)
)
return panel
```
### Example 4: Dynamic Panel Updates
Controlling panels programmatically based on user interaction:
```python
from fasthtml.common import *
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Create panel
panel = Panel(parent=root_instance)
# Set up content
panel.set_left(
Div(
H3("Navigation"),
Ul(
Li("Dashboard"),
Li("Reports"),
Li("Settings")
)
)
)
panel.set_right(
Div(
H3("Debug Info"),
Div("Session ID: abc123"),
Div("User: Admin"),
Div("Timestamp: 2024-01-15")
)
)
# Create control buttons
toggle_left_btn = mk.button(
"Toggle Left Panel",
command=panel.commands.set_side_visible("left", False),
cls="btn btn-sm"
)
toggle_right_btn = mk.button(
"Toggle Right Panel",
command=panel.commands.set_side_visible("right", False),
cls="btn btn-sm"
)
show_all_btn = mk.button(
"Show All Panels",
command=Command(
"show_all",
"Show all panels",
lambda: (
panel.toggle_side("left", True),
panel.toggle_side("right", True)
)
),
cls="btn btn-sm btn-primary"
)
# Main content with controls
panel.set_main(
Div(
H1("Panel Controls Demo", cls="text-2xl font-bold mb-4"),
Div(
toggle_left_btn,
toggle_right_btn,
show_all_btn,
cls="space-x-2 mb-4"
),
P("Use the buttons above to toggle panels programmatically."),
P("You can also use the hide () and show (⋯) icons."),
cls="p-4"
)
)
return panel
```
---
## Developer Reference
This section contains technical details for developers working on the Panel component itself.
### Configuration
The Panel component uses `PanelConf` dataclass for configuration:
| Property | Type | Description | Default |
|----------------------|---------|-------------------------------------------|-----------|
| `left` | boolean | Enable/disable left panel | `False` |
| `right` | boolean | Enable/disable right panel | `True` |
| `left_title` | string | Title displayed in left panel header | `"Left"` |
| `right_title` | string | Title displayed in right panel header | `"Right"` |
| `show_left_title` | boolean | Show title header on left panel | `True` |
| `show_right_title` | boolean | Show title header on right panel | `True` |
| `show_display_left` | boolean | Show the "show" icon when left is hidden | `True` |
| `show_display_right` | boolean | Show the "show" icon when right is hidden | `True` |
### State
The Panel component maintains the following state properties via `PanelState`:
| Name | Type | Description | Default |
|-----------------|---------|------------------------------------|---------|
| `left_visible` | boolean | True if the left panel is visible | `True` |
| `right_visible` | boolean | True if the right panel is visible | `True` |
| `left_width` | integer | Width of the left panel in pixels | `250` |
| `right_width` | integer | Width of the right panel in pixels | `250` |
### Commands
Available commands for programmatic control:
| Name | Description |
|------------------------------|-------------------------------------------------------------------|
| `toggle_side(side, visible)` | Sets panel visibility (side: "left"/"right", visible: True/False) |
| `update_side_width(side)` | Updates panel width from HTMX request (side: "left"/"right") |
**Note:** The old `toggle_side(side)` command without the `visible` parameter is deprecated but still available in the
codebase.
### Public Methods
| Method | Description | Returns |
|----------------------|------------------------------|---------|
| `set_main(content)` | Sets the main content area | `self` |
| `set_left(content)` | Sets the left panel content | `Div` |
| `set_right(content)` | Sets the right panel content | `Div` |
| `render()` | Renders the complete panel | `Div` |
### High Level Hierarchical Structure
**With title (default, `show_*_title=True`):**
```
Div(id="{id}", cls="mf-panel")
├── Div(id="{id}_pl", cls="mf-panel-left mf-panel-with-title [mf-hidden]")
│ ├── Div(cls="mf-panel-body")
│ │ ├── Div(cls="mf-panel-header")
│ │ │ ├── Div [Title text]
│ │ │ └── Div (hide icon)
│ │ └── Div(id="{id}_cl", cls="mf-panel-content")
│ │ └── [Left content - scrollable]
│ └── Div (resizer-left)
├── Div(cls="mf-panel-main")
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
│ ├── Div(id="{id}_m", cls="mf-panel-main")
│ │ └── [Main content]
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
├── Div(id="{id}_pr", cls="mf-panel-right mf-panel-with-title [mf-hidden]")
│ ├── Div (resizer-right)
│ └── Div(cls="mf-panel-body")
│ ├── Div(cls="mf-panel-header")
│ │ ├── Div [Title text]
│ │ └── Div (hide icon)
│ └── Div(id="{id}_cr", cls="mf-panel-content")
│ └── [Right content - scrollable]
└── Script # initResizer('{id}')
```
**Without title (legacy, `show_*_title=False`):**
```
Div(id="{id}", cls="mf-panel")
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
│ ├── Div (hide icon - absolute positioned)
│ ├── Div(id="{id}_cl")
│ │ └── [Left content]
│ └── Div (resizer-left)
├── Div(cls="mf-panel-main")
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
│ ├── Div(id="{id}_m", cls="mf-panel-main")
│ │ └── [Main content]
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
│ ├── Div (resizer-right)
│ ├── Div (hide icon - absolute positioned)
│ └── Div(id="{id}_cr")
│ └── [Right content]
└── Script # initResizer('{id}')
```
**Note:**
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
- Left panel: body/content then resizer (resizer on right edge)
- Right panel: resizer then body/content (resizer on left edge)
- `[mf-hidden]` class is conditionally applied when panel is hidden
- `mf-panel-with-title` class removes default padding-top when using title layout
### Element IDs
| Name | Description |
|------------------|-------------------------------------|
| `{id}` | Root panel container |
| `{id}_pl` | Left panel container |
| `{id}_pr` | Right panel container |
| `{id}_cl` | Left panel content wrapper |
| `{id}_cr` | Right panel content wrapper |
| `{id}_m` | Main content wrapper |
| `{id}_show_left` | Show icon for left panel (in main) |
| `{id}_show_right`| Show icon for right panel (in main) |
**Note:** `{id}` is the Panel instance ID (auto-generated UUID or custom `_id`).
**ID Management:**
The Panel component uses the `PanelIds` helper class to manage element IDs consistently. Access IDs programmatically:
```python
panel = Panel(parent=root_instance)
# Access IDs via get_ids()
panel.get_ids().panel("left") # Returns "{id}_pl"
panel.get_ids().panel("right") # Returns "{id}_pr"
panel.get_ids().left # Returns "{id}_cl"
panel.get_ids().right # Returns "{id}_cr"
panel.get_ids().main # Returns "{id}_m"
panel.get_ids().content("left") # Returns "{id}_cl"
```
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|-----------------------|---------------------------------------------------|
| `_mk_panel(side)` | Renders a panel (left or right) with all elements |
| `_mk_show_icon(side)` | Renders the show icon for a panel |
**Method details:**
- `_mk_panel(side)`:
- Checks if panel is enabled in config
- Creates resizer with command and data attributes
- Creates hide icon with toggle command
- Applies `mf-hidden` class if panel is not visible
- Returns None if panel is disabled
- `_mk_show_icon(side)`:
- Checks if panel is enabled in config
- Returns None if panel is disabled or visible
- Applies `hidden` (Tailwind) class if panel is visible
- Applies positioning class based on side
### JavaScript Integration
The Panel component uses JavaScript for manual resizing:
**initResizer(panelId):**
- Initializes drag-and-drop resize functionality
- Adds/removes `no-transition` class during drag
- Sends width updates to server via HTMX
- Constrains width between 150px and 500px
**File:** `src/myfasthtml/assets/core/myfasthtml.js`

355
docs/Profiler.md Normal file
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`

648
docs/TabsManager.md Normal file
View File

@@ -0,0 +1,648 @@
# TabsManager Component
## Introduction
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML
application. It handles tab creation, activation, closing, and content management with automatic state persistence and
HTMX-powered interactions.
**Key features:**
- Dynamic tab creation and removal at runtime
- Automatic content caching for performance
- Session-based state persistence (tabs, order, active tab)
- Duplicate tab detection based on component identity
- Built-in search menu for quick tab navigation
- Auto-increment labels for programmatic tab creation
- HTMX-powered updates without page reload
**Common use cases:**
- Multi-document editor (code editor, text editor)
- Dashboard with multiple data views
- Settings interface with different configuration panels
- Developer tools with console, inspector, network tabs
- Application with dynamic content sections
## Quick Start
Here's a minimal example showing a tabbed interface with three views:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create root instance and tabs manager
root = RootInstance(session)
tabs = TabsManager(parent=root)
# Create three tabs with different content
tabs.create_tab("Dashboard", Div(H1("Dashboard"), P("Overview of your data")))
tabs.create_tab("Settings", Div(H1("Settings"), P("Configure your preferences")))
tabs.create_tab("Profile", Div(H1("Profile"), P("Manage your profile")))
# Render the tabs manager
return tabs
```
This creates a complete tabbed interface with:
- A header bar displaying three clickable tab buttons ("Dashboard", "Settings", "Profile")
- Close buttons (×) on each tab for dynamic removal
- A main content area showing the active tab's content
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
- Automatic HTMX updates when switching or closing tabs
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove
tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX
integration.
## Basic Usage
### Visual Structure
The TabsManager component consists of a header with tab buttons and a content area:
```
┌────────────────────────────────────────────────────────────┐
│ Tab Header │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
├────────────────────────────────────────────────────────────┤
│ │
│ │
│ Active Tab Content │
│ │
│ │
└────────────────────────────────────────────────────────────┘
```
**Component details:**
| Element | Description |
|------------------|-----------------------------------------|
| Tab buttons | Clickable labels to switch between tabs |
| Close button (×) | Removes the tab and its content |
| Search menu (⊞) | Dropdown menu to search and filter tabs |
| Content area | Displays the active tab's content |
### Creating a TabsManager
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application.
Create it by providing a parent instance:
```python
tabs = TabsManager(parent=root_instance)
# Or with a custom ID
tabs = TabsManager(parent=root_instance, _id="my-tabs")
```
### Creating Tabs
Use the `create_tab()` method to add a new tab:
```python
# Create a tab with custom content
tab_id = tabs.create_tab(
label="My Tab",
component=Div(H1("Content"), P("Tab content here"))
)
# Create with a MyFastHtml control
from myfasthtml.controls.VisNetwork import VisNetwork
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
tab_id = tabs.create_tab("Network View", network)
# Create without activating immediately
tab_id = tabs.create_tab("Background Tab", content, activate=False)
```
**Parameters:**
- `label` (str): Display text shown in the tab button
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
- `activate` (bool): Whether to make this tab active immediately (default: True)
**Returns:** A unique `tab_id` (UUID string) that identifies the tab
### Showing Tabs
Use the `show_tab()` method to activate and display a tab:
```python
# Show a tab (makes it active and sends content to client if needed)
tabs.show_tab(tab_id)
# Show without activating (just send content to client)
tabs.show_tab(tab_id, activate=False)
```
**Parameters:**
- `tab_id` (str): The UUID of the tab to show
- `activate` (bool): Whether to make this tab active (default: True)
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just
toggle visibility without re-sending content.
### Closing Tabs
Use the `close_tab()` method to remove a tab:
```python
# Close a specific tab
tabs.close_tab(tab_id)
```
**What happens when closing:**
1. Tab is removed from the tab list and order
2. Content is removed from cache and client
3. If the closed tab was active, the first remaining tab becomes active
4. If no tabs remain, `active_tab` is set to `None`
### Changing Tab Content
Use the `change_tab_content()` method to update an existing tab's content and label:
```python
# Update tab content and label
new_content = Div(H1("Updated"), P("New content"))
tabs.change_tab_content(
tab_id=tab_id,
label="Updated Tab",
component=new_content,
activate=True
)
```
**Parameters:**
- `tab_id` (str): The UUID of the tab to update
- `label` (str): New label for the tab
- `component` (Any): New content to display
- `activate` (bool): Whether to activate the tab after updating (default: True)
**Note:** This method forces the new content to be sent to the client, even if the tab was already displayed.
## Advanced Features
### Auto-increment Labels
When creating multiple tabs programmatically, you can use auto-increment to generate unique labels:
```python
# Using the on_new_tab method with auto_increment
def create_multiple_tabs():
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
tabs.on_new_tab("Untitled", content, auto_increment=True)
tabs.on_new_tab("Untitled", content, auto_increment=True)
tabs.on_new_tab("Untitled", content, auto_increment=True)
```
**How it works:**
- The TabsManager maintains an internal counter (`_tab_count`)
- When `auto_increment=True`, the counter value is appended to the label
- Counter increments with each auto-incremented tab creation
- Useful for "New Tab 1", "New Tab 2" patterns in editors or tools
### Duplicate Detection
The TabsManager automatically detects and reuses tabs with identical content to prevent duplicates:
```python
# Create a control instance
network = VisNetwork(parent=tabs, nodes=data, edges=edges)
# First call creates a new tab
tab_id_1 = tabs.create_tab("Network", network)
# Second call with same label and component returns existing tab_id
tab_id_2 = tabs.create_tab("Network", network)
# tab_id_1 == tab_id_2 (True - same tab!)
```
**Detection criteria:**
A tab is considered a duplicate if all three match:
- Same `label`
- Same `component_type` (component class prefix)
- Same `component_id` (component instance ID)
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs
and will always create new tabs.
### Dynamic Content Updates
You can update tabs dynamically during the session:
```python
# Initial tab creation
tab_id = tabs.create_tab("Data View", Div("Loading..."))
# Later, update with actual data
def load_data():
data_content = Div(H2("Data"), P("Loaded content"))
tabs.change_tab_content(tab_id, "Data View", data_content)
# Returns HTMX response to update the UI
```
**Use cases:**
- Loading data asynchronously
- Refreshing tab content based on user actions
- Updating visualizations with new data
- Switching between different views in the same tab
### Tab Search Menu
The built-in search menu helps users navigate when many tabs are open:
```python
# The search menu is automatically created and includes:
# - A Search control for filtering tabs by label
# - Live filtering as you type
# - Click to activate a tab from search results
```
**How to access:**
- Click the ⊞ icon in the tab header
- Start typing to filter tabs by label
- Click a result to activate that tab
The search menu updates automatically when tabs are added or removed.
### HTMX Out-of-Band Swaps
For advanced HTMX control, you can customize swap behavior:
```python
# Standard behavior (out-of-band swap enabled)
tabs.show_tab(tab_id, oob=True) # Default
# Custom target behavior (disable out-of-band)
tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
```
**When to use `oob=False`:**
- When you want to control the exact HTMX target
- When combining with other HTMX responses
- When the tab activation is triggered by a command with a specific target
**When to use `oob=True` (default):**
- Most common use case
- Allows other controls to trigger tab changes without caring about targets
- Enables automatic UI updates across multiple elements
### CSS Customization
The TabsManager uses CSS classes that you can customize:
| Class | Element |
|--------------------------|---------------------------------|
| `mf-tabs-manager` | Root tabs manager container |
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
| `mf-tabs-header` | Tab buttons container |
| `mf-tab-button` | Individual tab button |
| `mf-tab-active` | Active tab button (modifier) |
| `mf-tab-label` | Tab label text |
| `mf-tab-close-btn` | Close button (×) |
| `mf-tab-content-wrapper` | Content area container |
| `mf-tab-content` | Individual tab content |
| `mf-empty-content` | Empty state when no tabs |
**Example customization:**
```css
/* Change active tab color */
.mf-tab-active {
background-color: #3b82f6;
color: white;
}
/* Customize close button */
.mf-tab-close-btn:hover {
color: red;
}
/* Style the content area */
.mf-tab-content-wrapper {
padding: 2rem;
background-color: #f9fafb;
}
```
## Examples
### Example 1: Multi-view Application
A typical application with different views accessible through tabs:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create tabs manager
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="app-tabs")
# Dashboard view
dashboard = Div(
H1("Dashboard"),
Div(
Div("Total Users: 1,234", cls="stat"),
Div("Active Sessions: 56", cls="stat"),
Div("Revenue: $12,345", cls="stat"),
cls="stats-grid"
)
)
# Analytics view
analytics = Div(
H1("Analytics"),
P("Detailed analytics and reports"),
Div("Chart placeholder", cls="chart-container")
)
# Settings view
settings = Div(
H1("Settings"),
Form(
Label("Username:", Input(name="username", value="admin")),
Label("Email:", Input(name="email", value="admin@example.com")),
Button("Save", type="submit"),
)
)
# Create tabs
tabs.create_tab("Dashboard", dashboard)
tabs.create_tab("Analytics", analytics)
tabs.create_tab("Settings", settings)
# Render
return tabs
```
### Example 2: Dynamic Tabs with VisNetwork
Creating tabs dynamically with interactive network visualizations:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="network-tabs")
# Create initial tab with welcome message
tabs.create_tab("Welcome", Div(
H1("Network Visualizer"),
P("Click 'Add Network' to create a new network visualization")
))
# Function to create a new network tab
def add_network_tab():
# Define network data
nodes = [
{"id": 1, "label": "Node 1"},
{"id": 2, "label": "Node 2"},
{"id": 3, "label": "Node 3"}
]
edges = [
{"from": 1, "to": 2},
{"from": 2, "to": 3}
]
# Create network instance
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
# Use auto-increment to create unique labels
return tabs.on_new_tab("Network", network, auto_increment=True)
# Create command for adding networks
add_cmd = Command("add_network", "Add network tab", add_network_tab)
# Add button to create new network tabs
add_button = mk.button("Add Network", command=add_cmd, cls="btn btn-primary")
# Return tabs and button
return Div(add_button, tabs)
```
### Example 3: Tab Management with Content Updates
An application that updates tab content based on user interaction:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="editor-tabs")
# Create initial document tabs
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
# Function to refresh a document's content
def refresh_document(tab_id, doc_name):
# Simulate loading new content
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
tabs.change_tab_content(tab_id, doc_name, new_content)
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
# Create refresh commands
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id, "Document 2")
# Add refresh buttons
controls = Div(
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
cls="controls-bar"
)
return Div(controls, tabs)
```
### Example 4: Using Auto-increment for Dynamic Tabs
Creating multiple tabs programmatically with auto-generated labels:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="dynamic-tabs")
# Create initial placeholder tab
tabs.create_tab("Start", Div(
H2("Welcome"),
P("Click 'New Tab' to create numbered tabs")
))
# Function to create a new numbered tab
def create_numbered_tab():
content = Div(
H2("New Tab Content"),
P(f"This tab was created dynamically"),
Input(placeholder="Enter some text...", cls="input")
)
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
return tabs.on_new_tab("Tab", content, auto_increment=True)
# Create command
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
# Add button
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
return Div(
Div(new_tab_button, cls="toolbar"),
tabs
)
```
---
## Developer Reference
This section contains technical details for developers working on the TabsManager component itself.
### State
The TabsManager component maintains the following state properties:
| Name | Type | Description | Default |
|--------------------------|----------------|---------------------------------------------------|---------|
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
| `active_tab` | str \| None | ID of the currently active tab | `None` |
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
### Commands
Available commands for programmatic control:
| Name | Description |
|---------------------------------------------|--------------------------------------------|
| `show_tab(tab_id)` | Activate or show a specific tab |
| `close_tab(tab_id)` | Close a specific tab |
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
### Public Methods
| Method | Description |
|---------------------------------------------------------------|-------------------------------------------------|
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
| `close_tab(tab_id)` | Close and remove a tab |
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
| `add_tab_btn()` | Returns add tab button element |
| `get_state()` | Returns the TabsManagerState object |
| `render()` | Renders the complete TabsManager component |
### High Level Hierarchical Structure
```
Div(id="{id}", cls="mf-tabs-manager")
├── Div(id="{id}-controller") # Controller (hidden, manages active state)
├── Div(id="{id}-header-wrapper") # Header wrapper
│ ├── Div(id="{id}-header") # Tab buttons container
│ │ ├── Div (mf-tab-button) # Tab button 1
│ │ │ ├── Span (mf-tab-label) # Label (clickable)
│ │ │ └── Span (mf-tab-close-btn) # Close button
│ │ ├── Div (mf-tab-button) # Tab button 2
│ │ └── ...
│ └── Div (dropdown) # Search menu
│ ├── Icon (tabs24_regular) # Menu toggle button
│ └── Div (dropdown-content) # Search component
├── Div(id="{id}-content-wrapper") # Content wrapper
│ ├── Div(id="{id}-{tab_id_1}-content") # Tab 1 content
│ ├── Div(id="{id}-{tab_id_2}-content") # Tab 2 content
│ └── ...
└── Script # Initialization script
```
### Element IDs
| Name | Description |
|-------------------------|-----------------------------------------|
| `{id}` | Root tabs manager container |
| `{id}-controller` | Hidden controller managing active state |
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
| `{id}-header` | Tab buttons container |
| `{id}-content-wrapper` | Content area wrapper |
| `{id}-{tab_id}-content` | Individual tab content |
| `{id}-search` | Search component ID |
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|-----------------------------------------|-----------------------------------------------------|
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
| `_mk_tab_button(tab_data)` | Renders a single tab button |
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
| `_get_tab_list()` | Returns list of tab dictionaries in order |
| `_get_tab_count()` | Returns and increments internal tab counter |
### Tab Metadata Structure
Each tab in the `tabs` dictionary has the following structure:
```python
{
'id': 'uuid-string', # Unique tab identifier
'label': 'Tab Label', # Display label
'component_type': 'prefix', # Component class prefix (or None)
'component_id': 'instance-id' # Component instance ID (or None)
}
```
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from
`BaseInstance`.

598
docs/TreeView.md Normal file
View File

@@ -0,0 +1,598 @@
# TreeView Component
## Introduction
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
**Key features:**
- Expand/collapse nodes with visual indicators
- Add child and sibling nodes dynamically
- Inline rename with keyboard support (ESC to cancel)
- Delete nodes (only leaf nodes without children)
- Node selection tracking
- Persistent state per session
- Configurable icons per node type
**Common use cases:**
- File/folder browser
- Category/subcategory management
- Organizational hierarchy viewer
- Navigation menu builder
- Document outline editor
## Quick Start
Here's a minimal example showing a file system tree:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
# Create TreeView instance
tree = TreeView(parent=root_instance, _id="file-tree")
# Add root folder
root = TreeNode(id="root", label="Documents", type="folder")
tree.add_node(root)
# Add some files
file1 = TreeNode(id="file1", label="report.pdf", type="file")
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
tree.add_node(file1, parent_id="root")
tree.add_node(file2, parent_id="root")
# Expand root to show children
tree.expand_all()
# Render the tree
return tree
```
This creates an interactive tree where users can:
- Click chevrons to expand/collapse folders
- Click labels to select items
- Use action buttons (visible on hover) to add, rename, or delete nodes
**Note:** All interactions use commands and update via HTMX without page reload.
## Basic Usage
### Creating a TreeView
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
```python
tree = TreeView(parent=root_instance, _id="my-tree")
```
### TreeNode Structure
Nodes are represented by the `TreeNode` dataclass:
```python
from myfasthtml.controls.TreeView import TreeNode
node = TreeNode(
id="unique-id", # Auto-generated UUID if not provided
label="Node Label", # Display text
type="default", # Type for icon mapping
parent=None, # Parent node ID (None for root)
children=[] # List of child node IDs
)
```
### Adding Nodes
Add nodes using the `add_node()` method:
```python
# Add root node
root = TreeNode(id="root", label="Root", type="folder")
tree.add_node(root)
# Add child node
child = TreeNode(label="Child 1", type="item")
tree.add_node(child, parent_id="root")
# Add with specific position
sibling = TreeNode(label="Child 2", type="item")
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
```
### Visual Structure
```
TreeView
├── Root Node 1
│ ├── [>] Child 1-1 # Collapsed node with children
│ ├── [ ] Child 1-2 # Leaf node (no children)
│ └── [v] Child 1-3 # Expanded node
│ ├── [ ] Grandchild
│ └── [ ] Grandchild
└── Root Node 2
└── [>] Child 2-1
```
**Legend:**
- `[>]` - Collapsed node (has children)
- `[v]` - Expanded node (has children)
- `[ ]` - Leaf node (no children)
### Expanding Nodes
Control node expansion programmatically:
```python
# Expand all nodes with children
tree.expand_all()
# Expand specific nodes by adding to opened list
tree._state.opened.append("node-id")
```
**Note:** Users can also toggle nodes by clicking the chevron icon.
## Interactive Features
### Node Selection
Users can select nodes by clicking on labels. The selected node is visually highlighted:
```python
# Programmatically select a node
tree._state.selected = "node-id"
# Check current selection
current = tree._state.selected
```
### Adding Nodes
Users can add nodes via action buttons (visible on hover):
**Add Child:**
- Adds a new node as a child of the target node
- Automatically expands the parent
- Creates node with same type as parent
**Add Sibling:**
- Adds a new node next to the target node (same parent)
- Inserts after the target node
- Cannot add sibling to root nodes
```python
# Programmatically add child
tree._add_child(parent_id="root", new_label="New Child")
# Programmatically add sibling
tree._add_sibling(node_id="child1", new_label="New Sibling")
```
### Renaming Nodes
Users can rename nodes via the edit button:
1. Click the edit icon (visible on hover)
2. Input field appears with current label
3. Press Enter to save (triggers command)
4. Press ESC to cancel (keyboard shortcut)
```python
# Programmatically start rename
tree.handle_start_rename("node-id")
# Save rename
tree.handle_save_rename("node-id", "New Label")
# Cancel rename
tree.handle_cancel_rename()
```
### Deleting Nodes
Users can delete nodes via the delete button:
**Restrictions:**
- Can only delete leaf nodes (no children)
- Attempting to delete a node with children raises an error
- Deleted node is removed from parent's children list
```python
# Programmatically delete node
tree.handle_delete_node("node-id") # Raises ValueError if node has children
```
## Content System
### Node Types and Icons
Assign types to nodes for semantic grouping and custom icon display:
```python
# Define node types
root = TreeNode(label="Project", type="project")
folder = TreeNode(label="src", type="folder")
file = TreeNode(label="main.py", type="python-file")
# Configure icons for types
tree.set_icon_config({
"project": "fluent.folder_open",
"folder": "fluent.folder",
"python-file": "fluent.document_python"
})
```
**Note:** Icon configuration is stored in state and persists within the session.
### Hierarchical Organization
Nodes automatically maintain parent-child relationships:
```python
# Get node's children
node = tree._state.items["node-id"]
child_ids = node.children
# Get node's parent
parent_id = node.parent
# Navigate tree programmatically
for child_id in node.children:
child_node = tree._state.items[child_id]
print(child_node.label)
```
### Finding Root Nodes
Root nodes are nodes without a parent:
```python
root_nodes = [
node_id for node_id, node in tree._state.items.items()
if node.parent is None
]
```
## Advanced Features
### Keyboard Shortcuts
TreeView includes keyboard support for common operations:
| Key | Action |
|-----|--------|
| `ESC` | Cancel rename operation |
Additional shortcuts can be added via the Keyboard component:
```python
from myfasthtml.controls.Keyboard import Keyboard
tree = TreeView(parent=root_instance)
# ESC handler is automatically included for cancel rename
```
### State Management
TreeView maintains persistent state within the session:
| State Property | Type | Description |
|----------------|------|-------------|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
| `opened` | `list[str]` | IDs of expanded nodes |
| `selected` | `str \| None` | Currently selected node ID |
| `editing` | `str \| None` | Node being renamed (if any) |
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
### Dynamic Updates
TreeView updates are handled via commands that return the updated tree:
```python
# Commands automatically target the tree for HTMX swap
cmd = tree.commands.toggle_node("node-id")
# When executed, returns updated TreeView with new state
```
### CSS Customization
TreeView uses CSS classes for styling:
| Class | Element |
|-------|---------|
| `mf-treeview` | Root container |
| `mf-treenode-container` | Container for node and its children |
| `mf-treenode` | Individual node row |
| `mf-treenode.selected` | Selected node highlight |
| `mf-treenode-label` | Node label text |
| `mf-treenode-input` | Input field during rename |
| `mf-treenode-actions` | Action buttons container (hover) |
You can override these classes to customize appearance:
```css
.mf-treenode.selected {
background-color: #e0f2fe;
border-left: 3px solid #0284c7;
}
.mf-treenode-actions {
opacity: 0;
transition: opacity 0.2s;
}
.mf-treenode:hover .mf-treenode-actions {
opacity: 1;
}
```
## Examples
### Example 1: File System Browser
A file/folder browser with different node types:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
# Create tree
tree = TreeView(parent=root_instance, _id="file-browser")
# Configure icons
tree.set_icon_config({
"folder": "fluent.folder",
"python": "fluent.document_python",
"text": "fluent.document_text"
})
# Build file structure
root = TreeNode(id="root", label="my-project", type="folder")
tree.add_node(root)
src = TreeNode(id="src", label="src", type="folder")
tree.add_node(src, parent_id="root")
main = TreeNode(label="main.py", type="python")
utils = TreeNode(label="utils.py", type="python")
tree.add_node(main, parent_id="src")
tree.add_node(utils, parent_id="src")
readme = TreeNode(label="README.md", type="text")
tree.add_node(readme, parent_id="root")
# Expand to show structure
tree.expand_all()
return tree
```
### Example 2: Category Management
Managing product categories with inline editing:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
tree = TreeView(parent=root_instance, _id="categories")
# Root categories
electronics = TreeNode(id="elec", label="Electronics", type="category")
tree.add_node(electronics)
# Subcategories
computers = TreeNode(label="Computers", type="subcategory")
phones = TreeNode(label="Phones", type="subcategory")
tree.add_node(computers, parent_id="elec")
tree.add_node(phones, parent_id="elec")
# Products (leaf nodes)
laptop = TreeNode(label="Laptops", type="product")
desktop = TreeNode(label="Desktops", type="product")
tree.add_node(laptop, parent_id=computers.id)
tree.add_node(desktop, parent_id=computers.id)
tree.expand_all()
return tree
```
### Example 3: Document Outline Editor
Building a document outline with headings:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
tree = TreeView(parent=root_instance, _id="outline")
# Document structure
doc = TreeNode(id="doc", label="My Document", type="document")
tree.add_node(doc)
# Chapters
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
tree.add_node(ch1, parent_id="doc")
tree.add_node(ch2, parent_id="doc")
# Sections
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
tree.add_node(sec1_1, parent_id="ch1")
tree.add_node(sec1_2, parent_id="ch1")
# Subsections
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
tree.add_node(subsec, parent_id=sec1_1.id)
tree.expand_all()
return tree
```
### Example 4: Dynamic Tree with Event Handling
Responding to tree events with custom logic:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
tree = TreeView(parent=root_instance, _id="dynamic-tree")
# Initial structure
root = TreeNode(id="root", label="Tasks", type="folder")
tree.add_node(root)
# Function to handle selection
def on_node_selected(node_id):
# Custom logic when node is selected
node = tree._state.items[node_id]
tree.handle_select_node(node_id)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Override select command with custom handler
# (In practice, you'd extend the Commands class or use event callbacks)
tree.expand_all()
return tree
```
---
## Developer Reference
This section contains technical details for developers working on the TreeView component itself.
### State
The TreeView component maintains the following state properties:
| Name | Type | Description | Default |
|------|------|-------------|---------|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
| `opened` | `list[str]` | Expanded node IDs | `[]` |
| `selected` | `str \| None` | Selected node ID | `None` |
| `editing` | `str \| None` | Node being renamed | `None` |
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
### Commands
Available commands for programmatic control:
| Name | Description |
|------|-------------|
| `toggle_node(node_id)` | Toggle expand/collapse state |
| `add_child(parent_id)` | Add child node to parent |
| `add_sibling(node_id)` | Add sibling node after target |
| `start_rename(node_id)` | Enter rename mode for node |
| `save_rename(node_id)` | Save renamed node label |
| `cancel_rename()` | Cancel rename operation |
| `delete_node(node_id)` | Delete node (if no children) |
| `select_node(node_id)` | Select a node |
All commands automatically target the TreeView component for HTMX updates.
### Public Methods
| Method | Description |
|--------|-------------|
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
| `expand_all()` | Expand all nodes with children |
| `set_icon_config(config)` | Configure icons for node types |
| `render()` | Render the complete TreeView |
### TreeNode Dataclass
```python
@dataclass
class TreeNode:
id: str # Unique identifier (auto-generated UUID)
label: str = "" # Display text
type: str = "default" # Node type for icon mapping
parent: Optional[str] = None # Parent node ID
children: list[str] = [] # Child node IDs
```
### High Level Hierarchical Structure
```
Div(id="treeview", cls="mf-treeview")
├── Div(cls="mf-treenode-container", data-node-id="root1")
│ ├── Div(cls="mf-treenode")
│ │ ├── Icon # Toggle chevron
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
│ │ └── Div(cls="mf-treenode-actions")
│ │ ├── Icon # Add child
│ │ ├── Icon # Rename
│ │ └── Icon # Delete
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
│ └── ...
├── Div(cls="mf-treenode-container", data-node-id="root2")
│ └── ...
└── Keyboard # ESC handler
```
### Element IDs and Attributes
| Attribute | Element | Description |
|-----------|---------|-------------|
| `id` | Root Div | TreeView component ID |
| `data-node-id` | Node container | Node's unique ID |
### Internal Methods
These methods are used internally for rendering and state management:
| Method | Description |
|--------|-------------|
| `_toggle_node(node_id)` | Toggle expand/collapse state |
| `_add_child(parent_id, new_label)` | Add child node implementation |
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
| `_start_rename(node_id)` | Enter rename mode |
| `_save_rename(node_id, node_label)` | Save renamed node |
| `_cancel_rename()` | Cancel rename operation |
| `_delete_node(node_id)` | Delete node if no children |
| `_select_node(node_id)` | Select a node |
| `_render_action_buttons(node_id)` | Render hover action buttons |
| `_render_node(node_id, level)` | Recursively render node and children |
### Commands Class
The `Commands` nested class provides command factory methods:
| Method | Returns |
|--------|---------|
| `toggle_node(node_id)` | Command to toggle node |
| `add_child(parent_id)` | Command to add child |
| `add_sibling(node_id)` | Command to add sibling |
| `start_rename(node_id)` | Command to start rename |
| `save_rename(node_id)` | Command to save rename |
| `cancel_rename()` | Command to cancel rename |
| `delete_node(node_id)` | Command to delete node |
| `select_node(node_id)` | Command to select node |
All commands are automatically configured with HTMX targeting.
### Integration with Keyboard Component
TreeView includes a Keyboard component for ESC key handling:
```python
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
```
This enables users to press ESC to cancel rename operations without clicking.

File diff suppressed because it is too large Load Diff

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

@@ -1,281 +1,12 @@
/**
* Layout Drawer Resizer
*
* Handles resizing of left and right drawers with drag functionality.
* Communicates with server via HTMX to persist width changes.
*/
/**
* Initialize drawer resizer functionality for a specific layout instance
*
* @param {string} layoutId - The ID of the layout instance to initialize
*/
function initLayoutResizer(layoutId) {
'use strict';
const MIN_WIDTH = 150;
const MAX_WIDTH = 600;
let isResizing = false;
let currentResizer = null;
let currentDrawer = null;
let startX = 0;
let startWidth = 0;
let side = null;
const layoutElement = document.getElementById(layoutId);
if (!layoutElement) {
console.error(`Layout element with ID "${layoutId}" not found`);
return;
}
/**
* Initialize resizer functionality for this layout instance
*/
function initResizers() {
const resizers = layoutElement.querySelectorAll('.mf-layout-resizer');
resizers.forEach(resizer => {
// Remove existing listener if any to avoid duplicates
resizer.removeEventListener('mousedown', handleMouseDown);
resizer.addEventListener('mousedown', handleMouseDown);
});
}
/**
* Handle mouse down event on resizer
*/
function handleMouseDown(e) {
e.preventDefault();
currentResizer = e.target;
side = currentResizer.dataset.side;
currentDrawer = currentResizer.closest('.mf-layout-drawer');
if (!currentDrawer) {
console.error('Could not find drawer element');
return;
}
isResizing = true;
startX = e.clientX;
startWidth = currentDrawer.offsetWidth;
// Add event listeners for mouse move and up
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Add resizing class for visual feedback
document.body.classList.add('mf-layout-resizing');
currentDrawer.classList.add('mf-layout-drawer-resizing');
}
/**
* Handle mouse move event during resize
*/
function handleMouseMove(e) {
if (!isResizing) return;
e.preventDefault();
let newWidth;
if (side === 'left') {
// Left drawer: increase width when moving right
newWidth = startWidth + (e.clientX - startX);
} else if (side === 'right') {
// Right drawer: increase width when moving left
newWidth = startWidth - (e.clientX - startX);
}
// Constrain width between min and max
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
// Update drawer width visually
currentDrawer.style.width = `${newWidth}px`;
}
/**
* Handle mouse up event - end resize and save to server
*/
function handleMouseUp(e) {
if (!isResizing) return;
isResizing = false;
// Remove event listeners
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// Remove resizing classes
document.body.classList.remove('mf-layout-resizing');
currentDrawer.classList.remove('mf-layout-drawer-resizing');
// Get final width
const finalWidth = currentDrawer.offsetWidth;
const commandId = currentResizer.dataset.commandId;
if (!commandId) {
console.error('No command ID found on resizer');
return;
}
// Send width update to server
saveDrawerWidth(commandId, finalWidth);
// Reset state
currentResizer = null;
currentDrawer = null;
side = null;
}
/**
* Save drawer width to server via HTMX
*/
function saveDrawerWidth(commandId, width) {
htmx.ajax('POST', '/myfasthtml/commands', {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
swap: "outerHTML",
target: `#${currentDrawer.id}`,
values: {
c_id: commandId,
width: width
}
});
}
// Initialize resizers
initResizers();
// Re-initialize after HTMX swaps within this layout
layoutElement.addEventListener('htmx:afterSwap', function (event) {
initResizers();
});
}
function initBoundaries(elementId, updateUrl) {
function updateBoundaries() {
const container = document.getElementById(elementId);
if (!container) {
console.warn("initBoundaries : element " + elementId + " is not found !");
return;
}
const rect = container.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
console.log("boundaries: ", rect)
// Send boundaries to server
htmx.ajax('POST', updateUrl, {
target: '#' + elementId,
swap: 'outerHTML',
values: {width: width, height: height}
});
}
// Debounce function
let resizeTimeout;
function debouncedUpdate() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateBoundaries, 250);
}
// Update on load
setTimeout(updateBoundaries, 100);
// Update on window resize
const container = document.getElementById(elementId);
container.addEventListener('resize', debouncedUpdate);
// Cleanup on element removal
if (container) {
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.removedNodes.forEach(function (node) {
if (node.id === elementId) {
window.removeEventListener('resize', debouncedUpdate);
}
});
});
});
observer.observe(container.parentNode, {childList: true});
}
}
/**
* Updates the tabs display by showing the active tab content and scrolling to make it visible.
* This function is called when switching between tabs to update both the content visibility
* and the tab button states.
*
* @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-controller")
*/
function updateTabs(controllerId) {
const controller = document.getElementById(controllerId);
if (!controller) {
console.warn(`Controller ${controllerId} not found`);
return;
}
const activeTabId = controller.dataset.activeTab;
if (!activeTabId) {
console.warn('No active tab ID found');
return;
}
// Extract manager ID from controller ID (remove '-controller' suffix)
const managerId = controllerId.replace('-controller', '');
// Hide all tab contents for this manager
const contentWrapper = document.getElementById(`${managerId}-content-wrapper`);
if (contentWrapper) {
contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => {
content.classList.add('hidden');
});
// Show the active tab content
const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`);
if (activeContent) {
activeContent.classList.remove('hidden');
}
}
// Update active tab button styling
const header = document.getElementById(`${managerId}-header`);
if (header) {
// Remove active class from all tabs
header.querySelectorAll('.mf-tab-button').forEach(btn => {
btn.classList.remove('mf-tab-active');
});
// Add active class to current tab
const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`);
if (activeButton) {
activeButton.classList.add('mf-tab-active');
// Scroll to make active tab visible if needed
activeButton.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}
}
/**
* Create keyboard bindings
*/
(function() {
(function () {
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
const KeyboardRegistry = {
elements: new Map(), // elementId -> { trie, element }
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
currentKeys: new Set(),
snapshotHistory: [],
@@ -339,10 +70,10 @@ function updateTabs(controllerId) {
}
/**
* Create a new trie node
* @returns {Object} - New trie node
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTrieNode() {
function createTreeNode() {
return {
config: null,
combinationStr: null,
@@ -351,12 +82,12 @@ function updateTabs(controllerId) {
}
/**
* Build a trie from combinations
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root trie node
* @returns {Object} - Root tree node
*/
function buildTrie(combinations) {
const root = createTrieNode();
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
@@ -366,7 +97,7 @@ function updateTabs(controllerId) {
const key = setToKey(keySet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTrieNode());
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
@@ -381,13 +112,13 @@ function updateTabs(controllerId) {
}
/**
* Traverse the trie with the current snapshot history
* @param {Object} trieRoot - Root of the trie
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
* @returns {Object|null} - Current node or null if no match
*/
function traverseTrie(trieRoot, snapshotHistory) {
let currentNode = trieRoot;
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
@@ -430,8 +161,9 @@ function updateTabs(controllerId) {
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus is inside the element
*/
function triggerAction(elementId, config, combinationStr) {
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
@@ -475,13 +207,14 @@ function updateTabs(controllerId) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination and has_focus
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
@@ -506,6 +239,7 @@ function updateTabs(controllerId) {
// Add key to current pressed keys
KeyboardRegistry.currentKeys.add(key);
console.debug("Received key", key);
// Create a snapshot of current keyboard state
const snapshot = new Set(KeyboardRegistry.currentKeys);
@@ -530,13 +264,17 @@ function updateTabs(controllerId) {
const element = document.getElementById(elementId);
if (!element) continue;
const trieRoot = data.trie;
// Check if focus is inside this element (element itself or any child)
const isInside = element.contains(document.activeElement);
// Traverse the trie with current snapshot history
const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this trie, continue to next element
// No match in this tree, continue to next element
console.debug("No match in tree for event", key);
continue;
}
@@ -559,7 +297,8 @@ function updateTabs(controllerId) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
@@ -574,7 +313,7 @@ function updateTabs(controllerId) {
// We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
@@ -589,7 +328,7 @@ function updateTabs(controllerId) {
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
@@ -640,28 +379,71 @@ function updateTabs(controllerId) {
}
}
/**
* Detach the global keyboard event listener
*/
function detachGlobalListener() {
if (KeyboardRegistry.listenerAttached) {
document.removeEventListener('keydown', handleKeyboardEvent);
document.removeEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = false;
// Clean up all state
KeyboardRegistry.currentKeys.clear();
KeyboardRegistry.snapshotHistory = [];
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
}
KeyboardRegistry.pendingMatches = [];
}
}
/**
* Add keyboard support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_keyboard_support = function(elementId, combinationsJson) {
window.add_keyboard_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build trie for this element
const trie = buildTrie(combinations);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
KeyboardRegistry.elements.set(elementId, {
trie: trie,
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove keyboard support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_keyboard_support = function (elementId) {
// Remove from registry
if (!KeyboardRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in keyboard registry!");
return;
}
KeyboardRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (KeyboardRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

634
examples/mouse_support.js Normal file
View File

@@ -0,0 +1,634 @@
/**
* Create mouse bindings
*/
(function () {
/**
* Global registry to store mouse shortcuts for multiple elements
*/
const MouseRegistry = {
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500, // 500ms timeout for sequences
clickHandler: null,
contextmenuHandler: null
};
/**
* Normalize mouse action names
* @param {string} action - The action to normalize
* @returns {string} - Normalized action name
*/
function normalizeAction(action) {
const normalized = action.toLowerCase().trim();
// Handle aliases
const aliasMap = {
'rclick': 'right_click'
};
return aliasMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of actions for Map indexing
* @param {Set} actionSet - Set of normalized actions
* @returns {string} - Sorted string representation
*/
function setToKey(actionSet) {
return Array.from(actionSet).sort().join('+');
}
/**
* Parse a single element (can be a simple click or click with modifiers)
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
* @returns {Set} - Set of normalized actions
*/
function parseElement(element) {
if (element.includes('+')) {
// Click with modifiers
return new Set(element.split('+').map(a => normalizeAction(a)));
}
// Simple click
return new Set([normalizeAction(element)]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "click right_click")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a click or click with modifiers)
return [parseElement(combination)];
}
/**
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTreeNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root tree node
*/
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const actionSet of sequence) {
const key = setToKey(actionSet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
* @returns {Object|null} - Current node or null if no match
*/
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where clicking should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Get the element that was actually clicked (from registered elements)
* @param {Element} target - The clicked element
* @returns {string|null} - Element ID if found, null otherwise
*/
function findRegisteredElement(target) {
// Check if target itself is registered
if (target.id && MouseRegistry.elements.has(target.id)) {
return target.id;
}
// Check if any parent is registered
let current = target.parentElement;
while (current) {
if (current.id && MouseRegistry.elements.has(current.id)) {
return current.id;
}
current = current.parentElement;
}
return null;
}
/**
* Create a snapshot from mouse event
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
* @returns {Set} - Set of actions representing this click
*/
function createSnapshot(event, baseAction) {
const actions = new Set([baseAction]);
// Add modifiers if present
if (event.ctrlKey || event.metaKey) {
actions.add('ctrl');
}
if (event.shiftKey) {
actions.add('shift');
}
if (event.altKey) {
actions.add('alt');
}
return actions;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the click was inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle mouse events and trigger matching combinations
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
*/
function handleMouseEvent(event, baseAction) {
// Different behavior for click vs right_click
if (baseAction === 'click') {
// Click: trigger for ALL registered elements (useful for closing modals/popups)
handleGlobalClick(event);
} else if (baseAction === 'right_click') {
// Right-click: trigger ONLY if clicked on a registered element
handleElementRightClick(event);
}
}
/**
* Handle global click events (triggers for all registered elements)
* @param {MouseEvent} event - The mouse event
*/
function handleGlobalClick(event) {
console.debug("Global click detected");
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for ALL registered elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
for (const [elementId, data] of MouseRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
// Check if click was inside this element
const isInside = element.contains(event.target);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Handle right-click events (triggers only for clicked element)
* @param {MouseEvent} event - The mouse event
*/
function handleElementRightClick(event) {
// Find which registered element was clicked
const elementId = findRegisteredElement(event.target);
if (!elementId) {
// Right-click wasn't on a registered element - don't prevent default
// This allows browser context menu to appear
return;
}
console.debug("Right-click on registered element", elementId);
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
const clickedInside = true;
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'right_click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for this element
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
const data = MouseRegistry.elements.get(elementId);
if (!data) return;
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
console.debug("No match in tree for right-click");
// Clear history for invalid sequences
MouseRegistry.snapshotHistory = [];
return;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: true // Right-click only triggers when clicking on element
});
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Attach the global mouse event listeners if not already attached
*/
function attachGlobalListener() {
if (!MouseRegistry.listenerAttached) {
// Store handler references for proper removal
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
document.addEventListener('click', MouseRegistry.clickHandler);
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = true;
}
}
/**
* Detach the global mouse event listeners
*/
function detachGlobalListener() {
if (MouseRegistry.listenerAttached) {
document.removeEventListener('click', MouseRegistry.clickHandler);
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = false;
// Clean up handler references
MouseRegistry.clickHandler = null;
MouseRegistry.contextmenuHandler = null;
// Clean up all state
MouseRegistry.snapshotHistory = [];
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
}
MouseRegistry.pendingMatches = [];
}
}
/**
* Add mouse support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_mouse_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
MouseRegistry.elements.set(elementId, {
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove mouse support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_mouse_support = function (elementId) {
// Remove from registry
if (!MouseRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in mouse registry!");
return;
}
MouseRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (MouseRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

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

@@ -137,6 +137,12 @@
<div class="test-container">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
<strong>Parameters Explained:</strong><br>
<code>has_focus</code>: Whether the registered element itself has focus<br>
<code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
</p>
</div>
<div class="test-container">
@@ -151,6 +157,9 @@
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div>
<div class="test-container">
@@ -172,12 +181,14 @@
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`
`Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
];
if (config.target) {
@@ -223,6 +234,16 @@
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_keyboard_support('test-element-2');
logEvent('Element 2 keyboard support removed',
'ESC and Shift Shift no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Keyboard Support Removed';
}
</script>
<!-- Include keyboard support script -->

View File

@@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mouse Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
user-select: none;
}
.test-element:hover {
background-color: #e0e0e0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.actions-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.actions-list h3 {
margin-top: 0;
}
.actions-list ul {
margin: 10px 0;
padding-left: 20px;
}
.actions-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
.remove-button {
background-color: #FF5722;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
}
.remove-button:hover {
background-color: #E64A19;
}
.remove-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
h1, h2 {
color: #333;
}
.note {
background-color: #e3f2fd;
border-left: 4px solid #2196F3;
padding: 10px 15px;
margin: 10px 0;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>Mouse Support Test Page</h1>
<div class="actions-list">
<h3>🖱️ Configured Mouse Actions</h3>
<p><strong>Element 1 - All Actions:</strong></p>
<ul>
<li><code>click</code> - Simple left click</li>
<li><code>right_click</code> - Right click (context menu blocked)</li>
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
<li><code>shift+click</code> - Shift + Click</li>
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
<li><code>click right_click</code> - Click then right-click within 500ms</li>
<li><code>click click</code> - Click twice in sequence</li>
</ul>
<p><strong>Element 2 - Using rclick alias:</strong></p>
<ul>
<li><code>click</code> - Simple click</li>
<li><code>rclick</code> - Right click (using rclick alias)</li>
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
</ul>
<p><strong>Element 3 - Mousedown&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>
<div class="note">
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
</div>
<div class="note">
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
Try right-clicking outside the test elements - the browser's context menu will appear normally.
</div>
<div class="note">
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
</div>
<div class="test-container">
<h2>Test Element 1 (All Actions)</h2>
<div id="test-element-1" class="test-element" tabindex="0">
Try different mouse actions here!<br>
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (Using rclick alias)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element uses "rclick" alias for right-click<br>
Also has a "click rclick" sequence
</div>
<button class="remove-button" onclick="removeElement2()">
Remove Element 2 Mouse Support
</button>
</div>
<div class="test-container">
<h2>Test Element 3 (Mousedown&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"
style="width: 100%; padding: 10px; font-size: 14px;">
</div>
<div class="test-container" style="background-color: #f9f9f9;">
<h2>🎯 Click Outside Test Area</h2>
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
</p>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Click inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_mouse_support('test-element-2');
logEvent('Element 2 mouse support removed',
'Click and right-click no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Mouse Support Removed';
}
</script>
<!-- Include mouse support script -->
<script src="mouse_support.js"></script>
<!-- Initialize mouse support -->
<script>
// Element 1 - Full configuration
const combinations1 = {
"click": {
"hx-post": "/test/click"
},
"right_click": {
"hx-post": "/test/right-click"
},
"ctrl+click": {
"hx-post": "/test/ctrl-click",
"hx-swap": "innerHTML"
},
"shift+click": {
"hx-post": "/test/shift-click",
"hx-target": "#result"
},
"ctrl+shift+click": {
"hx-post": "/test/ctrl-shift-click",
"hx-vals": {"modifier": "both"}
},
"click right_click": {
"hx-post": "/test/click-then-right-click",
"hx-vals": {"type": "sequence"}
},
"click click": {
"hx-post": "/test/double-click-sequence"
}
};
add_mouse_support('test-element-1', JSON.stringify(combinations1));
// Element 2 - Using rclick alias
const combinations2 = {
"click": {
"hx-post": "/test/element2-click"
},
"rclick": { // Using rclick alias instead of right_click
"hx-post": "/test/element2-rclick"
},
"click rclick": { // Sequence using rclick alias
"hx-post": "/test/element2-click-rclick-sequence"
}
};
add_mouse_support('test-element-2', JSON.stringify(combinations2));
// JS function for dynamic mousedown>mouseup values
// Returns cell-like data for testing
window.getCellId = function(event, element, combination) {
const x = event.clientX;
const y = event.clientY;
return {
cell_id: `cell-${Math.floor(x / 50)}-${Math.floor(y / 50)}`,
x: x,
y: y
};
};
// Element 3 - Mousedown>mouseup actions
const combinations3 = {
"click": {
"hx-post": "/test/element3-click"
},
"mousedown>mouseup": {
"hx-post": "/test/element3-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"ctrl+mousedown>mouseup": {
"hx-post": "/test/element3-ctrl-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"rmousedown>mouseup": {
"hx-post": "/test/element3-rmousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
},
"click mousedown>mouseup": {
"hx-post": "/test/element3-click-then-mousedown-mouseup",
"hx-vals-extra": {"js": "getCellId"}
}
};
add_mouse_support('test-element-3', JSON.stringify(combinations3));
// Log initial state
logEvent('Mouse support initialized',
'Element 1: All mouse actions configured',
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
'Element 3: Mousedown>mouseup actions (with JS getCellId function)',
'Smart timeout: 500ms for sequences', false);
</script>
</body>
</html>

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 = [
@@ -43,6 +43,7 @@ dependencies = [
"uvloop",
"watchfiles",
"websockets",
"lark",
]
[project.urls]
@@ -73,10 +74,12 @@ dev = [
# -------------------------------------------------------------------
[tool.setuptools]
package-dir = { "" = "src" }
packages = ["myfasthtml"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
myfasthtml = [
"assets/*.css",
"assets/*.js"
"assets/**/*.css",
"assets/**/*.js"
]

View File

@@ -33,21 +33,25 @@ jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
keyring==25.6.0
lark==1.3.1
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.1
mydbengine==0.1.0
myutils==0.4.0
mydbengine==0.2.1
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
myutils==0.5.1
nh3==0.3.1
numpy==2.3.5
oauthlib==3.3.1
openpyxl==3.1.5
packaging==25.0
pandas==2.3.3
pandas-stubs==2.3.3.251201
passlib==1.7.4
pipdeptree==2.29.0
pluggy==1.6.0
pyarrow==22.0.0
pyasn1==0.6.1
pycparser==2.23
pydantic==2.12.3
@@ -77,6 +81,7 @@ soupsieve==2.8
starlette==0.48.0
twine==6.2.0
typer==0.20.0
types-pytz==2025.2.0.20251108
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2

View File

@@ -1,19 +1,26 @@
import logging.config
import yaml
from dbengine.handlers import handlers
from fasthtml import serve
from fasthtml.components import Div
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.DataGridFormattingManager import DataGridFormattingManager
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p3 import folder_open20_regular
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular, timer20_regular
from myfasthtml.myfastapp import create_app
with open('logging.yaml', 'r') as f:
@@ -33,11 +40,14 @@ app, rt = create_app(protect_routes=True,
@rt("/")
def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
session_instance = UniqueInstance(session=session,
_id=Ids.UserSession,
on_init=lambda: handlers.register_handler(DataFrameHandler()))
layout = Layout(session_instance, "Testing Layout")
layout.set_footer("Goodbye World")
layout.footer_left.add("Goodbye World")
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab
btn_show_right_drawer = mk.button("show",
command=layout.commands.toggle_drawer("right"),
id="btn_show_right_drawer_id")
@@ -45,35 +55,59 @@ def index(session):
instances_debugger = InstancesDebugger(layout)
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
command=add_tab("Instances", instances_debugger),
id=f"l_{instances_debugger.get_id()}")
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=None,
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=f"l_{commands_debugger.get_id()}")
profiler = Profiler(layout)
btn_show_profiler = mk.label("Profiler",
icon=timer20_regular,
command=add_tab("Profiler", profiler),
id=f"l_{profiler.get_id()}")
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")),
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
id="file_upload_id")
btn_popup = mk.label("Popup",
command=tabs_manager.commands.add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-popup")))
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_show_profiler, "Debugger")
# Parameters
formatting_manager = DataGridFormattingManager(layout)
btn_show_formatting_manager = mk.label("Formatting",
icon=text_edit_style20_regular,
command=add_tab("Formatting", formatting_manager),
id=f"l_{formatting_manager.get_id()}")
layout.left_drawer.add(btn_show_formatting_manager, "Parameters")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
# data grids
dgs_manager = DataGridsManager(session_instance, save_state=True)
layout.left_drawer.add_group("Documents", Div("Documents",
dgs_manager.mk_main_icons(),
cls="mf-layout-group flex gap-3"))
layout.left_drawer.add(dgs_manager, "Documents")
layout.set_main(tabs_manager)
# keyboard shortcuts
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
tabs_manager.commands.add_tab("File Open",
FileUpload(layout,
_id="-file_upload")))
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")))
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
return layout, keyboard

View File

@@ -1,294 +0,0 @@
# Keyboard Support - Test Instructions
## ⚠️ Breaking Change
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
**Old format (no longer supported)**:
```javascript
{"A": "/url"}
```
**New format (required)**:
```javascript
{"A": {"hx-post": "/url"}}
```
## Files
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
- `test_keyboard_support.html` - Test page to verify functionality
## Key Features
### Multiple Simultaneous Triggers
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
```javascript
add_keyboard_support('modal', '{"esc": "/close-modal"}');
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
// Pressing ESC will trigger all 3 URLs simultaneously
```
This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.).
### Smart Timeout Logic (Longest Match)
The library uses **a single global timeout** based on the sequence state, not on individual elements:
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
Examples:
**Example 1**: Three elements, same combination
```javascript
add_keyboard_support('elem1', '{"esc": "/url1"}');
add_keyboard_support('elem2', '{"esc": "/url2"}');
add_keyboard_support('elem3', '{"esc": "/url3"}');
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
```
**Example 2**: Mixed - one has longer sequence
```javascript
add_keyboard_support('elem1', '{"A": "/url1"}');
add_keyboard_support('elem2', '{"A": "/url2"}');
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
// Press A:
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
// - If B arrives: only elem3 triggers with "A B"
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
```
**Example 3**: Different combinations
```javascript
add_keyboard_support('elem1', '{"A B": "/url1"}');
add_keyboard_support('elem2', '{"C D": "/url2"}');
// Press A: elem1 waits for B, elem2 not affected
// Press C: elem2 waits for D, elem1 not affected
```
The timeout is tied to the **sequence being typed**, not to individual elements.
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
- `<input>` elements
- `<textarea>` elements
- Any `contenteditable` element
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
## How to Test
1. **Download both files** to the same directory
2. **Open `test_keyboard_support.html`** in a web browser
3. **Try the configured shortcuts**:
- `a` - Simple key (waits if "A B" might follow)
- `Ctrl+S` - Save combination (immediate)
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
- `A B` - Sequence (waits because "A B C" exists)
- `A B C` - Triple sequence (triggers immediately)
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
- `shift shift` - Press Shift twice in sequence
- `esc` - Escape key (immediate)
4. **Test focus behavior**:
- Click on the test element to focus it (turns blue)
- Try shortcuts with focus
- Click outside to remove focus
- Try shortcuts without focus
- The log shows whether the element had focus when triggered
5. **Test input protection**:
- Try typing in the input field
- Use Ctrl+C, Ctrl+A, etc. - should work normally
- Shortcuts should NOT trigger while typing in input
## Expected Behavior
### Smart Timeout Examples
**Scenario 1**: Only "A" is configured (no element has "A B")
- Press A → Triggers **immediately**
**Scenario 2**: At least one element has "A B"
- Press A → **ALL elements with "A" wait 500ms**
- If B pressed within 500ms → Only elements with "A B" trigger
- If timeout expires → ALL elements with "A" trigger
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
- Press A → Waits (because "A B" exists)
- Press B → Waits (because "A B C" exists)
- Press C → Triggers "A B C" **immediately**
**Scenario 4**: Multiple elements, ESC on all
```javascript
add_keyboard_support('modal', '{"esc": "/close"}');
add_keyboard_support('editor', '{"esc": "/cancel"}');
```
- Press ESC → **Both trigger simultaneously** (no longer sequences)
## Integration in Your Project
## Integration in Your Project
### Configuration Format
The library now uses **HTMX configuration objects** instead of simple URL strings:
```python
# New format with HTMX configuration
combinations = {
"Ctrl+S": {
"hx-post": "/save-url",
"hx-target": "#result",
"hx-swap": "innerHTML"
},
"A B": {
"hx-post": "/sequence-url",
"hx-vals": {"extra": "data"}
},
"esc": {
"hx-get": "/cancel-url"
}
}
# This will generate the JavaScript call
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
```
### Supported HTMX Attributes
You can use any HTMX attribute in the configuration object:
**HTTP Methods** (one required):
- `hx-post` - POST request
- `hx-get` - GET request
- `hx-put` - PUT request
- `hx-delete` - DELETE request
- `hx-patch` - PATCH request
**Common Options**:
- `hx-target` - Target element selector
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
- `hx-vals` - Additional values to send (object)
- `hx-headers` - Custom headers (object)
- `hx-select` - Select specific content from response
- `hx-confirm` - Confirmation message
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
### Automatic Parameters
The library automatically adds these parameters to every request:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus
Example final request:
```javascript
htmx.ajax('POST', '/save-url', {
target: '#result',
swap: 'innerHTML',
values: {
extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic
has_focus: true // automatic
}
})
```
## Integration Examples
### Basic Usage
```python
combinations = {
"Ctrl+S": {
"hx-post": "/save"
}
}
```
### With Target and Swap
```python
combinations = {
"Ctrl+D": {
"hx-delete": "/item",
"hx-target": "#item-list",
"hx-swap": "outerHTML"
}
}
```
### With Extra Values
```python
combinations = {
"Ctrl+N": {
"hx-post": "/create",
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
}
}
```
### Multiple Elements Example
```python
# Modal close
modal_combinations = {
"esc": {
"hx-post": "/modal/close",
"hx-target": "#modal",
"hx-swap": "outerHTML"
}
}
# Editor cancel
editor_combinations = {
"esc": {
"hx-post": "/editor/cancel",
"hx-target": "#editor",
"hx-swap": "innerHTML"
}
}
# Both will trigger when ESC is pressed
f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
```
## Technical Details
### Trie-based Matching
The library uses a prefix tree (trie) data structure:
- Each node represents a keyboard snapshot (set of pressed keys)
- Leaf nodes contain the HTMX configuration object
- Intermediate nodes indicate longer sequences exist
- Enables efficient O(n) matching where n is sequence length
### HTMX Integration
Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
- `combination` and `has_focus` are automatically added to values
- All standard HTMX options are supported
### Key Normalization
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
- Simultaneous keys represented as sorted sets
## Notes
- The test page mocks `htmx.ajax` to display results in the console
- In production, real AJAX calls will be made to your backend
- Sequence timeout is 500ms between keys
- Maximum 10 snapshots kept in history to prevent memory issues

View File

@@ -0,0 +1,18 @@
# Commands used
```
# Url to get codemirror resources : https://cdnjs.com/libraries/codemirror
cd src/myfasthtml/assets/codemirror/
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/placeholder.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/mode/simple.min.js
# Url for SortableJS : https://cdnjs.com/libraries/Sortable
cd src/myfasthtml/assets/sortablejs/
wget https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.min.js
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.CodeMirror-lint-markers{width:16px}.CodeMirror-lint-tooltip{background-color:#ffd;border:1px solid #000;border-radius:4px 4px 4px 4px;color:#000;font-family:monospace;font-size:10pt;overflow:hidden;padding:2px 5px;position:fixed;white-space:pre;white-space:pre-wrap;z-index:100;max-width:600px;opacity:0;transition:opacity .4s;-moz-transition:opacity .4s;-webkit-transition:opacity .4s;-o-transition:opacity .4s;-ms-transition:opacity .4s}.CodeMirror-lint-mark{background-position:left bottom;background-repeat:repeat-x}.CodeMirror-lint-mark-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=)}.CodeMirror-lint-mark-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==)}.CodeMirror-lint-marker{background-position:center center;background-repeat:no-repeat;cursor:pointer;display:inline-block;height:16px;width:16px;vertical-align:middle;position:relative}.CodeMirror-lint-message{padding-left:18px;background-position:top left;background-repeat:no-repeat}.CodeMirror-lint-marker-warning,.CodeMirror-lint-message-warning{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=)}.CodeMirror-lint-marker-error,.CodeMirror-lint-message-error{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=)}.CodeMirror-lint-marker-multiple{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:right bottom;width:100%;height:100%}.CodeMirror-lint-line-error{background-color:rgba(183,76,81,.08)}.CodeMirror-lint-line-warning{background-color:rgba(255,211,0,.1)}

View File

@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(p){"use strict";var h="CodeMirror-lint-markers",g="CodeMirror-lint-line-";function u(t){t.parentNode&&t.parentNode.removeChild(t)}function v(t,e,n,o){t=t,e=e,n=n,(i=document.createElement("div")).className="CodeMirror-lint-tooltip cm-s-"+t.options.theme,i.appendChild(n.cloneNode(!0)),(t.state.lint.options.selfContain?t.getWrapperElement():document.body).appendChild(i),p.on(document,"mousemove",a),a(e),null!=i.style.opacity&&(i.style.opacity=1);var i,r=i;function a(t){if(!i.parentNode)return p.off(document,"mousemove",a);var e=Math.max(0,t.clientY-i.offsetHeight-5),t=Math.max(0,Math.min(t.clientX+5,i.ownerDocument.defaultView.innerWidth-i.offsetWidth));i.style.top=e+"px",i.style.left=t+"px"}function l(){var t;p.off(o,"mouseout",l),r&&((t=r).parentNode&&(null==t.style.opacity&&u(t),t.style.opacity=0,setTimeout(function(){u(t)},600)),r=null)}var s=setInterval(function(){if(r)for(var t=o;;t=t.parentNode){if((t=t&&11==t.nodeType?t.host:t)==document.body)return;if(!t){l();break}}if(!r)return clearInterval(s)},400);p.on(o,"mouseout",l)}function a(s,t,e){for(var n in this.marked=[],(t=t instanceof Function?{getAnnotations:t}:t)&&!0!==t||(t={}),this.options={},this.linterOptions=t.options||{},o)this.options[n]=o[n];for(var n in t)o.hasOwnProperty(n)?null!=t[n]&&(this.options[n]=t[n]):t.options||(this.linterOptions[n]=t[n]);this.timeout=null,this.hasGutter=e,this.onMouseOver=function(t){var e=s,n=t.target||t.srcElement;if(/\bCodeMirror-lint-mark-/.test(n.className)){for(var n=n.getBoundingClientRect(),o=(n.left+n.right)/2,n=(n.top+n.bottom)/2,i=e.findMarksAt(e.coordsChar({left:o,top:n},"client")),r=[],a=0;a<i.length;++a){var l=i[a].__annotation;l&&r.push(l)}r.length&&!function(t,e,n){for(var o=n.target||n.srcElement,i=document.createDocumentFragment(),r=0;r<e.length;r++){var a=e[r];i.appendChild(M(a))}v(t,n,i,o)}(e,r,t)}},this.waitingFor=0}var o={highlightLines:!1,tooltips:!0,delay:500,lintOnChange:!0,getAnnotations:null,async:!1,selfContain:null,formatAnnotation:null,onUpdateLinting:null};function C(t){var n,e=t.state.lint;e.hasGutter&&t.clearGutter(h),e.options.highlightLines&&(n=t).eachLine(function(t){var e=t.wrapClass&&/\bCodeMirror-lint-line-\w+\b/.exec(t.wrapClass);e&&n.removeLineClass(t,"wrap",e[0])});for(var o=0;o<e.marked.length;++o)e.marked[o].clear();e.marked.length=0}function M(t){var e=(e=t.severity)||"error",n=document.createElement("div");return n.className="CodeMirror-lint-message CodeMirror-lint-message-"+e,void 0!==t.messageHTML?n.innerHTML=t.messageHTML:n.appendChild(document.createTextNode(t.message)),n}function l(e){var t,n,o,i,r,a,l=e.state.lint;function s(){a=-1,o.off("change",s)}!l||(t=(i=l.options).getAnnotations||e.getHelper(p.Pos(0,0),"lint"))&&(i.async||t.async?(i=t,r=(o=e).state.lint,a=++r.waitingFor,o.on("change",s),i(o.getValue(),function(t,e){o.off("change",s),r.waitingFor==a&&(e&&t instanceof p&&(t=e),o.operation(function(){c(o,t)}))},r.linterOptions,o)):(n=t(e.getValue(),l.linterOptions,e))&&(n.then?n.then(function(t){e.operation(function(){c(e,t)})}):e.operation(function(){c(e,n)})))}function c(t,e){var n=t.state.lint;if(n){for(var o,i,r=n.options,a=(C(t),function(t){for(var e=[],n=0;n<t.length;++n){var o=t[n],i=o.from.line;(e[i]||(e[i]=[])).push(o)}return e}(e)),l=0;l<a.length;++l){var s=a[l];if(s){for(var u=null,c=n.hasGutter&&document.createDocumentFragment(),f=0;f<s.length;++f){var m=s[f],d=m.severity;i=d=d||"error",u="error"==(o=u)?o:i,r.formatAnnotation&&(m=r.formatAnnotation(m)),n.hasGutter&&c.appendChild(M(m)),m.to&&n.marked.push(t.markText(m.from,m.to,{className:"CodeMirror-lint-mark CodeMirror-lint-mark-"+d,__annotation:m}))}n.hasGutter&&t.setGutterMarker(l,h,function(e,n,t,o,i){var r=document.createElement("div"),a=r;return r.className="CodeMirror-lint-marker CodeMirror-lint-marker-"+t,o&&((a=r.appendChild(document.createElement("div"))).className="CodeMirror-lint-marker CodeMirror-lint-marker-multiple"),0!=i&&p.on(a,"mouseover",function(t){v(e,t,n,a)}),r}(t,c,u,1<s.length,r.tooltips)),r.highlightLines&&t.addLineClass(l,"wrap",g+u)}}r.onUpdateLinting&&r.onUpdateLinting(e,a,t)}}function s(t){var e=t.state.lint;e&&(clearTimeout(e.timeout),e.timeout=setTimeout(function(){l(t)},e.options.delay))}p.defineOption("lint",!1,function(t,e,n){if(n&&n!=p.Init&&(C(t),!1!==t.state.lint.options.lintOnChange&&t.off("change",s),p.off(t.getWrapperElement(),"mouseover",t.state.lint.onMouseOver),clearTimeout(t.state.lint.timeout),delete t.state.lint),e){for(var o=t.getOption("gutters"),i=!1,r=0;r<o.length;++r)o[r]==h&&(i=!0);n=t.state.lint=new a(t,e,i);n.options.lintOnChange&&t.on("change",s),0!=n.options.tooltips&&"gutter"!=n.options.tooltips&&p.on(t.getWrapperElement(),"mouseover",n.onMouseOver),l(t)}}),p.defineExtension("performLint",function(){l(this)})});

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(r){function n(e){e.state.placeholder&&(e.state.placeholder.parentNode.removeChild(e.state.placeholder),e.state.placeholder=null)}function i(e){n(e);var o=e.state.placeholder=document.createElement("pre"),t=(o.style.cssText="height: 0; overflow: visible",o.style.direction=e.getOption("direction"),o.className="CodeMirror-placeholder CodeMirror-line-like",e.getOption("placeholder"));"string"==typeof t&&(t=document.createTextNode(t)),o.appendChild(t),e.display.lineSpace.insertBefore(o,e.display.lineSpace.firstChild)}function l(e){c(e)&&i(e)}function a(e){var o=e.getWrapperElement(),t=c(e);o.className=o.className.replace(" CodeMirror-empty","")+(t?" CodeMirror-empty":""),(t?i:n)(e)}function c(e){return 1===e.lineCount()&&""===e.getLine(0)}r.defineOption("placeholder","",function(e,o,t){var t=t&&t!=r.Init;o&&!t?(e.on("blur",l),e.on("change",a),e.on("swapDoc",a),r.on(e.getInputField(),"compositionupdate",e.state.placeholderCompose=function(){var t;t=e,setTimeout(function(){var e,o=!1;((o=1==t.lineCount()?"TEXTAREA"==(e=t.getInputField()).nodeName?!t.getLine(0).length:!/[^\u200b]/.test(e.querySelector(".CodeMirror-line").textContent):o)?i:n)(t)},20)}),a(e)):!o&&t&&(e.off("blur",l),e.off("change",a),e.off("swapDoc",a),r.off(e.getInputField(),"compositionupdate",e.state.placeholderCompose),n(e),(t=e.getWrapperElement()).className=t.className.replace(" CodeMirror-empty","")),o&&!e.hasFocus()&&l(e)})});

View File

@@ -0,0 +1 @@
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});

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

@@ -1,471 +1,270 @@
:root {
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--font-weight-medium: 500;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
.mf-icon-16 {
width: 16px;
min-width: 16px;
height: 16px;
margin-top: auto;
}
.mf-icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
}
.mf-icon-24 {
width: 24px;
min-width: 24px;
height: 24px;
margin-top: auto;
}
.mf-icon-28 {
width: 28px;
min-width: 28px;
height: 28px;
margin-top: auto;
}
.mf-icon-32 {
width: 32px;
min-width: 32px;
height: 32px;
margin-top: auto;
}
/*
* MF Layout Component - CSS Grid Layout
* Provides fixed header/footer, collapsible drawers, and scrollable main content
* Compatible with DaisyUI 5
*/
/* Main layout container using CSS Grid */
.mf-layout {
display: grid;
grid-template-areas:
"header header header"
"left-drawer main right-drawer"
"footer footer footer";
grid-template-rows: 32px 1fr 32px;
grid-template-columns: auto 1fr auto;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Header - fixed at top */
.mf-layout-header {
grid-area: header;
display: flex;
align-items: center;
justify-content: space-between; /* put one item on each side */
gap: 1rem;
padding: 0 1rem;
background-color: var(--color-base-300);
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
z-index: 30;
}
/* Footer - fixed at bottom */
.mf-layout-footer {
grid-area: footer;
display: flex;
align-items: center;
padding: 0 1rem;
background-color: var(--color-neutral);
color: var(--color-neutral-content);
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
z-index: 30;
}
/* Main content area - scrollable */
.mf-layout-main {
grid-area: main;
overflow-y: auto;
overflow-x: auto;
padding: 0.5rem;
background-color: var(--color-base-100);
}
/* Drawer base styles */
.mf-layout-drawer {
overflow-y: auto;
overflow-x: hidden;
background-color: var(--color-base-100);
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
width: 250px;
padding: 1rem;
}
/* Left drawer */
.mf-layout-left-drawer {
grid-area: left-drawer;
border-right: 1px solid var(--color-border-primary);
}
/* Right drawer */
.mf-layout-right-drawer {
grid-area: right-drawer;
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
border-left: 1px solid var(--color-border-primary);
}
/* Collapsed drawer states */
.mf-layout-drawer.collapsed {
width: 0;
padding: 0;
border: none;
overflow: hidden;
}
/* Toggle buttons positioning */
.mf-layout-toggle-left {
margin-right: auto;
}
.mf-layout-toggle-right {
margin-left: auto;
}
/* Smooth scrollbar styling for webkit browsers */
.mf-layout-main::-webkit-scrollbar,
.mf-layout-drawer::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.mf-layout-main::-webkit-scrollbar-track,
.mf-layout-drawer::-webkit-scrollbar-track {
background: var(--color-base-200);
}
.mf-layout-main::-webkit-scrollbar-thumb,
.mf-layout-drawer::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
border-radius: 4px;
}
.mf-layout-main::-webkit-scrollbar-thumb:hover,
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
}
/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
.mf-layout-drawer {
width: 200px;
}
.mf-layout-header,
.mf-layout-footer {
padding: 0 0.5rem;
}
.mf-layout-main {
padding: 0.5rem;
}
}
/* Handle layouts with no drawers */
.mf-layout[data-left-drawer="false"] {
grid-template-areas:
"header header"
"main right-drawer"
"footer footer";
grid-template-columns: 1fr auto;
}
.mf-layout[data-right-drawer="false"] {
grid-template-areas:
"header header"
"left-drawer main"
"footer footer";
grid-template-columns: auto 1fr;
}
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
grid-template-areas:
"header"
"main"
"footer";
grid-template-columns: 1fr;
}
/**
* Layout Drawer Resizer Styles
*
* Styles for the resizable drawer borders with visual feedback
*/
/* Ensure drawer has relative positioning and no overflow */
.mf-layout-drawer {
position: relative;
overflow: hidden;
}
/* Content wrapper handles scrolling */
.mf-layout-drawer-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem;
}
/* Base resizer styles */
.mf-layout-resizer {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background-color: transparent;
transition: background-color 0.2s ease;
z-index: 100;
}
/* Resizer on the right side (for left drawer) */
.mf-layout-resizer-right {
right: 0;
cursor: col-resize;
}
/* Resizer on the left side (for right drawer) */
.mf-layout-resizer-left {
left: 0;
cursor: col-resize;
}
/* Hover state */
.mf-layout-resizer:hover {
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
}
/* Active state during resize */
.mf-layout-drawer-resizing .mf-layout-resizer {
background-color: rgba(59, 130, 246, 0.5);
}
/* Disable transitions during resize for smooth dragging */
.mf-layout-drawer-resizing {
transition: none !important;
}
/* Prevent text selection during resize */
.mf-layout-resizing {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor override for entire body during resize */
.mf-layout-resizing * {
cursor: col-resize !important;
}
/* Visual indicator for resizer on hover - subtle border */
.mf-layout-resizer::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.mf-layout-resizer-right::before {
right: 1px;
}
.mf-layout-resizer-left::before {
left: 1px;
}
.mf-layout-resizer:hover::before,
.mf-layout-drawer-resizing .mf-layout-resizer::before {
opacity: 1;
}
.mf-layout-group {
font-weight: bold;
/*font-size: var(--text-sm);*/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}
/* *********************************************** */
/* *********** Tabs Manager Component ************ */
/* *********************************************** */
/* Tabs Manager Container */
.mf-tabs-manager {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--color-base-200);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
border-radius: .5rem;
}
/* Tabs Header using DaisyUI tabs component */
.mf-tabs-header {
display: flex;
gap: 0;
flex-shrink: 1;
min-height: 25px;
overflow-x: hidden;
overflow-y: hidden;
white-space: nowrap;
}
.mf-tabs-header-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
/*overflow: hidden; important */
}
/* Individual Tab Button using DaisyUI tab classes */
.mf-tab-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem 0 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.mf-tab-button:hover {
color: var(--color-base-content); /* Change text color on hover */
}
.mf-tab-button.mf-tab-active {
--depth: 1;
background-color: var(--color-base-100);
color: var(--color-base-content);
border-radius: .25rem;
border-bottom: 4px solid var(--color-primary);
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
}
/* Tab Label */
.mf-tab-label {
user-select: none;
white-space: nowrap;
max-width: 150px;
}
/* Tab Close Button */
.mf-tab-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
font-size: 1.25rem;
line-height: 1;
@apply text-base-content/50;
transition: all 0.2s ease;
}
.mf-tab-close-btn:hover {
@apply bg-base-300 text-error;
}
/* Tab Content Area */
.mf-tab-content {
flex: 1;
overflow: auto;
height: 100%;
}
.mf-tab-content-wrapper {
flex: 1;
overflow: auto;
background-color: var(--color-base-100);
padding: 1rem;
border-top: 1px solid var(--color-border-primary);
}
/* Empty Content State */
.mf-empty-content {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
@apply text-base-content/50;
font-style: italic;
}
.mf-vis {
width: 100%;
height: 100%;
}
.mf-search-results {
margin-top: 0.5rem;
max-height: 200px;
overflow: auto;
}
.mf-dropdown-wrapper {
position: relative; /* CRUCIAL for the anchor */
display: inline-block;
}
.mf-dropdown-wrapper:hover .mf-dropdown {
display: block;
}
.mf-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0px;
z-index: 1;
width: 200px;
border: 1px solid black;
padding: 10px;
box-sizing: border-box;
overflow-x: auto;
/*opacity: 0;*/
/*transition: opacity 0.2s ease-in-out;*/
}
.mf-dropdown.is-visible {
display: block;
opacity: 1;
}
/*
* MF Layout Component - CSS Grid Layout
* Provides fixed header/footer, collapsible drawers, and scrollable main content
* Compatible with DaisyUI 5
*/
/* Main layout container using CSS Grid */
.mf-layout {
display: grid;
grid-template-areas:
"header header header"
"left-drawer main right-drawer"
"footer footer footer";
grid-template-rows: 32px 1fr 32px;
grid-template-columns: auto 1fr auto;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Header - fixed at top */
.mf-layout-header {
grid-area: header;
display: flex;
align-items: center;
justify-content: space-between; /* put one item on each side */
gap: 1rem;
padding: 0 1rem;
background-color: var(--color-base-300);
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
z-index: 30;
}
/* Footer - fixed at bottom */
.mf-layout-footer {
grid-area: footer;
display: flex;
align-items: center;
padding: 0 1rem;
background-color: var(--color-neutral);
color: var(--color-neutral-content);
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
z-index: 30;
}
/* Main content area - scrollable */
.mf-layout-main {
grid-area: main;
overflow-y: auto;
overflow-x: auto;
padding: 0.5rem;
background-color: var(--color-base-100);
}
/* Drawer base styles */
.mf-layout-drawer {
overflow-y: auto;
overflow-x: hidden;
background-color: var(--color-base-100);
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
width: 250px;
padding: 1rem;
}
/* Left drawer */
.mf-layout-left-drawer {
grid-area: left-drawer;
border-right: 1px solid var(--color-border-primary);
}
/* Right drawer */
.mf-layout-right-drawer {
grid-area: right-drawer;
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
border-left: 1px solid var(--color-border-primary);
}
/* Collapsed drawer states */
.mf-layout-drawer.collapsed {
width: 0;
padding: 0;
border: none;
overflow: hidden;
}
/* Toggle buttons positioning */
.mf-layout-toggle-left {
margin-right: auto;
}
.mf-layout-toggle-right {
margin-left: auto;
}
/* Smooth scrollbar styling for webkit browsers */
.mf-layout-main::-webkit-scrollbar,
.mf-layout-drawer::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.mf-layout-main::-webkit-scrollbar-track,
.mf-layout-drawer::-webkit-scrollbar-track {
background: var(--color-base-200);
}
.mf-layout-main::-webkit-scrollbar-thumb,
.mf-layout-drawer::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
border-radius: 4px;
}
.mf-layout-main::-webkit-scrollbar-thumb:hover,
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
}
/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
.mf-layout-drawer {
width: 200px;
}
.mf-layout-header,
.mf-layout-footer {
padding: 0 0.5rem;
}
.mf-layout-main {
padding: 0.5rem;
}
}
/* Handle layouts with no drawers */
.mf-layout[data-left-drawer="false"] {
grid-template-areas:
"header header"
"main right-drawer"
"footer footer";
grid-template-columns: 1fr auto;
}
.mf-layout[data-right-drawer="false"] {
grid-template-areas:
"header header"
"left-drawer main"
"footer footer";
grid-template-columns: auto 1fr;
}
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
grid-template-areas:
"header"
"main"
"footer";
grid-template-columns: 1fr;
}
/**
* Layout Drawer Resizer Styles
*
* Styles for the resizable drawer borders with visual feedback
*/
/* Ensure drawer has relative positioning and no overflow */
.mf-layout-drawer {
position: relative;
overflow: hidden;
}
/* Content wrapper handles scrolling */
.mf-layout-drawer-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem;
}
/* Base resizer styles */
.mf-layout-resizer {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background-color: transparent;
transition: background-color 0.2s ease;
z-index: 100;
}
/* Resizer on the right side (for left drawer) */
.mf-layout-resizer-right {
right: 0;
cursor: col-resize;
}
/* Resizer on the left side (for right drawer) */
.mf-layout-resizer-left {
left: 0;
cursor: col-resize;
}
/* Hover state */
.mf-layout-resizer:hover {
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
}
/* Active state during resize */
.mf-layout-drawer-resizing .mf-layout-resizer {
background-color: rgba(59, 130, 246, 0.5);
}
/* Disable transitions during resize for smooth dragging */
.mf-layout-drawer-resizing {
transition: none !important;
}
/* Prevent text selection during resize */
.mf-layout-resizing {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor override for entire body during resize */
.mf-layout-resizing * {
cursor: col-resize !important;
}
/* Visual indicator for resizer on hover - subtle border */
.mf-layout-resizer::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.mf-layout-resizer-right::before {
right: 1px;
}
.mf-layout-resizer-left::before {
left: 1px;
}
.mf-layout-resizer:hover::before,
.mf-layout-drawer-resizing .mf-layout-resizer::before {
opacity: 1;
}
.mf-layout-group {
font-weight: bold;
/*font-size: var(--text-sm);*/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}

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,177 @@
/* ================================================================== */
/* Profiler Control */
/* ================================================================== */
/* ------------------------------------------------------------------
Root wrapper — fills parent, stacks toolbar above panel
------------------------------------------------------------------ */
.mf-profiler {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-size: var(--text-sm);
}
/* ------------------------------------------------------------------
Toolbar
------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Danger variant for clear button */
.mf-profiler-btn-danger {
color: var(--color-error) !important;
}
.mf-profiler-btn-danger:hover {
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
}
/* Overhead metrics — right-aligned text */
.mf-profiler-overhead {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
white-space: nowrap;
}
/* ------------------------------------------------------------------
Trace list — left panel content
------------------------------------------------------------------ */
.mf-profiler-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 80px 96px;
padding: 4px 10px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.mf-profiler-col-header {
font-family: var(--font-sans);
}
.mf-profiler-col-right {
text-align: right;
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
/* ------------------------------------------------------------------
Trace row
------------------------------------------------------------------ */
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 80px 96px;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
cursor: pointer;
transition: background 0.1s;
}
.mf-profiler-row:hover {
background: var(--color-base-200);
}
.mf-profiler-row-selected {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-left: 2px solid var(--color-primary);
padding-left: 8px;
}
.mf-profiler-row-selected:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
/* Command name + description cell */
.mf-profiler-cmd-cell {
display: flex;
align-items: baseline;
gap: 6px;
overflow: hidden;
}
.mf-profiler-cmd {
font-family: var(--font-sans);
font-size: var(--text-sm);
white-space: nowrap;
flex-shrink: 0;
}
.mf-profiler-cmd-description {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-style: italic;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Duration cell — monospace, color-coded */
.mf-profiler-duration {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
}
.mf-profiler-fast {
color: var(--color-success);
}
.mf-profiler-medium {
color: var(--color-warning);
}
.mf-profiler-slow {
color: var(--color-error);
}
/* Timestamp cell */
.mf-profiler-ts {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
}
/* ------------------------------------------------------------------
Empty state
------------------------------------------------------------------ */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
font-family: var(--font-sans);
font-size: var(--text-sm);
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}

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

@@ -1,7 +1,6 @@
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance
@@ -17,6 +16,7 @@ class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner,
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")

View File

@@ -1,26 +1,26 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.network_utils import from_parent_child_list
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.vis_network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
"""
Represents a debugger designed for visualizing and managing commands in a parent-child
hierarchical structure.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
commands = self._get_commands()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent(x))
)
nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
return vis_network
@staticmethod
def get_command_parent(command):
def get_command_parent_from_ft(command):
if (ft := command.get_ft()) is None:
return None
if hasattr(ft, "get_id") and callable(ft.get_id):
@@ -32,6 +32,30 @@ class CommandsDebugger(SingleInstance):
return None
@staticmethod
def get_command_parent_from_instance(command):
if command.owner is None:
return None
return command.owner.get_full_id()
def _get_nodes_and_edges(self):
commands = self._get_commands()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent_from_instance(x)),
ghost_label_getter=lambda x: InstancesManager.get(*x.split("#")).get_id()
)
for edge in edges:
edge["color"] = "blue"
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
for node in nodes:
node["shape"] = "box"
return nodes, edges
def _get_commands(self):
return list(CommandsManager.commands.values())

View File

@@ -0,0 +1,56 @@
import logging
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("CycleStateControl")
class CycleState(DbObject):
def __init__(self, owner, save_state):
with self.initializing():
super().__init__(owner, save_state=save_state)
self.state = None
class Commands(BaseCommands):
def cycle_state(self):
return Command("CycleState",
"Cycle state",
self._owner,
self._owner.cycle_state).htmx(target=f"#{self._id}")
class CycleStateControl(MultipleInstance):
def __init__(self, parent, controls: dict, _id=None, save_state=True):
super().__init__(parent, _id=_id)
self._state = CycleState(self, save_state)
self.controls_by_states = controls
self.commands = Commands(self)
# init the state if required
if self._state.state is None and controls:
self._state.state = next(iter(controls.keys()))
def cycle_state(self):
logger.debug(f"cycle_state datagrid={self._parent.get_table_name()}")
keys = list(self.controls_by_states.keys())
current_idx = keys.index(self._state.state)
self._state.state = keys[(current_idx + 1) % len(keys)]
return self
def get_state(self):
return self._state.state
def render(self):
return mk.mk(
Div(self.controls_by_states[self._state.state], id=self._id),
command=self.commands.cycle_state()
)
def __ft__(self):
return self.render()

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,287 @@
import logging
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGridFormulaEditor import DataGridFormulaEditor
from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, get_columns_types
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine
from myfasthtml.core.formula.dsl.parser import FormulaParser
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
logger = logging.getLogger("DataGridColumnsManager")
class Commands(BaseCommands):
def toggle_column(self, col_id):
return Command(f"ToggleColumn",
f"Toggle column {col_id}",
self._owner,
self._owner.toggle_column,
kwargs={"col_id": col_id}).htmx(swap="outerHTML", target=f"#tcolman_{self._id}-{col_id}")
def show_column_details(self, col_id):
return Command(f"ShowColumnDetails",
f"Show column details {col_id}",
self._owner,
self._owner.show_column_details,
kwargs={"col_id": col_id}).htmx(target=f"#{self._id}", swap="innerHTML")
def show_all_columns(self):
return Command(f"ShowAllColumns",
f"Show all columns",
self._owner,
self._owner.handle_show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
def save_column_details(self, col_id):
return Command(f"SaveColumnDetails",
f"Save column {col_id}",
self._owner,
self._owner.handle_save_column_details,
kwargs={"col_id": col_id}
).htmx(target=f"#{self._id}", swap="innerHTML")
def add_new_column(self):
return Command(f"AddNewColumn",
f"Add a new column",
self._owner,
self._owner.handle_add_new_column).htmx(target=f"#{self._id}", swap="innerHTML")
def on_column_type_changed(self):
return Command(f"OnColumnTypeChanged",
f"Column Type changed",
self._owner,
self._owner.on_column_type_changed).htmx(target=f"#{self._id}", swap="innerHTML", trigger="change")
class DataGridColumnsManager(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self.adding_new_column = False
self._all_columns = True # Do not return to all_columns after column details
completion_engine = FormulaCompletionEngine(
self._parent._parent,
self._parent.get_table_name(),
)
conf = DslEditorConf(name="formula", save_button=False, line_numbers=False, engine_id=completion_engine.get_id())
self._formula_editor = DataGridFormulaEditor(self, conf=conf, _id=f"{self._id}-formula-editor")
DslsManager.register(completion_engine, FormulaParser())
@property
def columns(self):
return self._parent.get_state().columns
def _get_col_def_from_col_id(self, col_id, copy=True):
"""
"""
cols_defs = [c for c in self.columns if c.col_id == col_id]
if not cols_defs:
return None
return cols_defs[0].copy() if copy else cols_defs[0]
def _get_updated_col_def_from_col_id(self, col_id, updates=None, copy=True):
col_def = self._get_col_def_from_col_id(col_id, copy=copy)
if col_def is None:
col_def = DataGridColumnState(col_id, -1)
if updates is not None:
updates["visible"] = "visible" in updates and updates["visible"] == "on"
for k, v in [(k, v) for k, v in updates.items() if hasattr(col_def, k)]:
if k == "type":
col_def.type = ColumnType(v)
elif k == "width":
col_def.width = int(v)
elif k == "formula":
col_def.formula = v or ""
# self._register_formula(col_def), Will be done in save_column_details()
else:
setattr(col_def, k, v)
return col_def
def set_all_columns(self, all_columns):
self._all_columns = all_columns
def toggle_column(self, col_id):
logger.debug(f"toggle_column {col_id=}")
col_def = self._get_col_def_from_col_id(col_id, copy=False)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
col_def.visible = not col_def.visible
self._parent.save_state()
return self.mk_column_label(col_def)
def show_column_details(self, col_id):
logger.debug(f"show_column_details {col_id=}")
col_def = self._get_updated_col_def_from_col_id(col_id)
if col_def is None:
logger.debug(f" column '{col_id}' is not found.")
return Div(f"Column '{col_id}' not found")
return self.mk_column_details(col_def)
def handle_show_all_columns(self):
self.adding_new_column = False
return self._mk_inner_content() if self._all_columns else None
def handle_save_column_details(self, col_id, client_response):
logger.debug(f"save_column_details {col_id=}, {client_response=}")
col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
if col_def.col_id == "__new__":
self._parent.add_new_column(col_def) # sets the correct col_id before _register_formula
self.adding_new_column = False
self._register_formula(col_def)
self._parent.save_state()
return self._mk_inner_content() if self._all_columns else None
def handle_add_new_column(self):
self.adding_new_column = True
col_def = DataGridColumnState("__new__", -1)
return self.mk_column_details(col_def)
def on_column_type_changed(self, col_id, client_response):
logger.debug(f"on_column_type_changed {col_id=}, {client_response=}")
col_def = self._get_updated_col_def_from_col_id(col_id, client_response)
return self.mk_column_details(col_def)
def _register_formula(self, col_def) -> None:
"""Register or remove a formula column with the FormulaEngine via DataService.
Registers only when col_def.type is Formula and the formula text is
non-empty. Removes the formula in all other cases so the engine stays
consistent with the column definition.
"""
data_service = getattr(self._parent, "_data_service", None)
if data_service is None:
return
if col_def.type == ColumnType.Formula and col_def.formula:
data_service.register_formula(col_def.col_id, col_def.formula)
logger.debug("Registered formula for col %s", col_def.col_id)
else:
data_service.remove_formula(col_def.col_id)
def mk_column_label(self, col_def: DataGridColumnState):
return Div(
mk.mk(
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
command=self.commands.toggle_column(col_def.col_id)
),
mk.mk(
Div(
Div(mk.label(col_def.col_id, icon=IconsHelper.get(col_def.type), cls="ml-2")),
Div(mk.icon(chevron_right20_regular), cls="mr-2"),
cls="dt2-column-manager-label"
),
command=self.commands.show_column_details(col_def.col_id)
),
cls="flex mb-1 items-center",
id=f"tcolman_{self._id}-{col_def.col_id}"
)
def mk_column_details(self, col_def: DataGridColumnState):
size = "sm"
return Div(
mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()),
Form(
Fieldset(
Label("Column Id"),
Input(name="col_id",
cls=f"input input-{size}",
value=col_def.col_id,
readonly=True),
Label("Title"),
Input(name="title",
cls=f"input input-{size}",
value=col_def.title),
Label("type"),
mk.mk(
Select(
*[Option(option.value,
value=option.value,
selected=option == col_def.type) for option in get_columns_types()],
name="type",
cls=f"select select-{size}",
value=col_def.title,
), command=self.commands.on_column_type_changed()
),
*([
Label("Formula"),
self._formula_editor,
] if col_def.type == ColumnType.Formula else []),
Div(
Div(
Label("Visible"),
Input(name="visible",
type="checkbox",
cls=f"checkbox checkbox-{size}",
checked="true" if col_def.visible else None),
),
Div(
Label("Width"),
Input(name="width",
type="number",
cls=f"input input-{size}",
value=col_def.width),
),
cls="flex",
),
legend="Column details",
cls="fieldset border-base-300 rounded-box"
),
mk.dialog_buttons(on_ok=self.commands.save_column_details(col_def.col_id),
on_cancel=self.commands.show_all_columns()),
cls="mb-1",
),
)
def mk_all_columns(self):
return Search(self,
items_names="Columns",
items=self.columns,
get_attr=lambda x: x.col_id,
template=self.mk_column_label,
max_height=None
)
def mk_new_column(self):
return Div(
mk.button("New Column", command=self.commands.add_new_column()),
cls="mb-1",
)
def _mk_inner_content(self):
if self.adding_new_column:
col_def = DataGridColumnState("__new__", -1)
return self.mk_column_details(col_def)
return (self.mk_all_columns(),
self.mk_new_column())
def render(self):
return Div(
*self._mk_inner_content(),
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,137 @@
import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.controls.datagrid_objects import DataGridRowUiState
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
logger = logging.getLogger("DataGridFormattingEditor")
class DataGridFormattingEditor(DslEditor):
def _find_column_by_name(self, name: str):
"""
Find a column by name, searching col_id first, then title.
Returns:
tuple (col_pos, col_def) if found, (None, None) otherwise
"""
# First pass: match by col_id
for col_pos, col_def in enumerate(self._parent.get_state().columns):
if col_def.col_id == name:
return col_pos, col_def
# Second pass: match by title
for col_pos, col_def in enumerate(self._parent.get_state().columns):
if col_def.title == name:
return col_pos, col_def
return None, None
def _get_cell_id(self, scope: CellScope):
"""
Get cell_id from CellScope.
If scope has cell_id, use it directly.
Otherwise, resolve coordinates (column, row) to cell_id.
Returns:
cell_id string or None if column not found
"""
if scope.cell_id:
return scope.cell_id
col_pos, _ = self._find_column_by_name(scope.column)
if col_pos is None:
logger.warning(f"Column '{scope.column}' not found for CellScope")
return None
return self._parent._get_element_id_from_pos("cell", (col_pos, scope.row))
def on_content_changed(self):
dsl = self.get_content()
# Step 1: Parse DSL
try:
scoped_rules = parse_dsl(dsl)
except DSLSyntaxError as e:
logger.debug(f"DSL syntax error, keeping old formatting: {e}")
return
# Step 2: Group rules by scope
columns_rules = defaultdict(list) # key = column name
rows_rules = defaultdict(list) # key = row index
cells_rules = defaultdict(list) # key = cell_id
table_rules = [] # rules for this table
tables_rules = [] # global rules for all tables
for scoped_rule in scoped_rules:
scope = scoped_rule.scope
rule = scoped_rule.rule
if isinstance(scope, ColumnScope):
columns_rules[scope.column].append(rule)
elif isinstance(scope, RowScope):
rows_rules[scope.row].append(rule)
elif isinstance(scope, CellScope):
cell_id = self._get_cell_id(scope)
if cell_id:
cells_rules[cell_id].append(rule)
elif isinstance(scope, TableScope):
# Validate table name matches current grid
if scope.table == self._parent.get_table_name():
table_rules.append(rule)
else:
logger.warning(f"Table name '{scope.table}' does not match grid name '{self._parent.get_table_name()}', skipping rules")
elif isinstance(scope, TablesScope):
tables_rules.append(rule)
# Step 3: Copy state for atomic update
state = self._parent.get_state().copy()
# Step 4: Clear existing formats on the copy
for col in state.columns:
col.format = None
for row in state.rows:
row.format = None
state.cell_formats.clear()
state.table_format = []
# Step 5: Apply grouped rules on the copy
for column_name, rules in columns_rules.items():
col_pos, col_def = self._find_column_by_name(column_name)
if col_def:
# Find the column in the copied state
state.columns[col_pos].format = rules
else:
logger.warning(f"Column '{column_name}' not found, skipping rules")
for row_index, rules in rows_rules.items():
row_state = next((r for r in state.rows if r.row_id == row_index), None)
if row_state is None:
row_state = DataGridRowUiState(row_id=row_index)
state.rows.append(row_state)
row_state.format = rules
for cell_id, rules in cells_rules.items():
state.cell_formats[cell_id] = rules
# Apply table-level rules
if table_rules:
state.table_format = table_rules
# Apply global tables-level rules to manager
if tables_rules:
from myfasthtml.controls.DataGridsManager import DataGridsManager
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
if manager:
manager.get_state().all_tables_formats = tables_rules
# Step 6: Update state atomically
self._parent.get_state().update(state)
# Step 7: Refresh the DataGrid
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells, table: {len(table_rules)}, tables: {len(tables_rules)}")
return self._parent.render_partial("body")

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

@@ -0,0 +1,97 @@
import logging
from typing import Optional
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent import brain_circuit20_regular
from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
logger = logging.getLogger("DataGridQuery")
DG_QUERY_FILTER = "filter"
DG_QUERY_SEARCH = "search"
DG_QUERY_AI = "ai"
query_type = {
DG_QUERY_FILTER: filter20_regular,
DG_QUERY_SEARCH: search20_regular,
DG_QUERY_AI: brain_circuit20_regular
}
class DataGridFilterState(DbObject):
def __init__(self, owner):
with self.initializing():
super().__init__(owner)
self.filter_type: str = "filter"
self.query: Optional[str] = None
class Commands(BaseCommands):
def change_filter_type(self):
return Command("ChangeFilterType",
"Change filter type",
self._owner,
self._owner.change_query_type).htmx(target=f"#{self._id}")
def on_filter_changed(self):
return Command("QueryChanged",
"Query changed",
self._owner,
self._owner.query_changed).htmx(target=None)
def on_cancel_query(self):
return Command("CancelQuery",
"Cancel query",
self._owner,
self._owner.query_changed,
kwargs={"query": ""}
).htmx(target=f"#{self._id}")
class DataGridQuery(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = DataGridFilterState(self)
def get_query(self):
return self._state.query
def get_query_type(self):
return self._state.filter_type
def change_query_type(self):
keys = list(query_type.keys()) # ["filter", "search", "ai"]
current_idx = keys.index(self._state.filter_type)
self._state.filter_type = keys[(current_idx + 1) % len(keys)]
return self
def query_changed(self, query):
logger.debug(f"query_changed {query=}")
self._state.query = query.strip() if query is not None else None
return self
def render(self):
return Div(
mk.label(
Input(name="query",
value=self._state.query if self._state.query is not None else "",
placeholder="Search...",
**self.commands.on_filter_changed().get_htmx_params(values_encode="json")),
icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()),
cls="input input-xs flex gap-3"
),
mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()),
cls="flex",
id=self._id
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,339 @@
import uuid
from dataclasses import dataclass
from io import BytesIO
import pandas as pd
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode, TreeViewConf
from myfasthtml.controls.helpers import mk
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
from myfasthtml.core.commands import Command
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.formatting.dataclasses import FormatRule
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
@dataclass
class DocumentDefinition:
document_id: str
namespace: str
name: str
type: str # table, card,
tab_id: str
datagrid_id: str
class DataGridsState(DbObject):
def __init__(self, owner, save_state, name=None):
super().__init__(owner, save_state=save_state, name=name)
with self.initializing():
self.elements: list[DocumentDefinition] = []
self.all_tables_formats: list[FormatRule] = []
class Commands(BaseCommands):
def upload_from_source(self):
return Command("UploadFromSource",
"Upload from source",
self._owner,
self._owner.upload_from_source).htmx(target=None)
def new_grid(self):
return Command("NewGrid",
"New grid",
self._owner,
self._owner.handle_new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
def open_from_excel(self, tab_id, file_upload):
return Command("OpenFromExcel",
"Open from Excel",
self._owner,
self._owner.open_from_excel,
args=[tab_id,
file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
def clear_tree(self):
return Command("ClearTree",
"Clear tree",
self._owner,
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
def show_document(self):
return Command("ShowDocument",
"Show document",
self._owner,
self._owner.select_document,
key="SelectNode")
def delete_grid(self):
return Command("DeleteGrid",
"Delete grid",
self._owner,
self._owner.delete_grid,
key="DeleteNode")
class DataGridsManager(SingleInstance):
"""UI manager for DataGrids.
Responsible for the visual organisation of DataGrids: TreeView, TabsManager,
and document lifecycle (create, open, delete). All data concerns are handled
by DataServicesManager and DataService.
"""
def __init__(self, parent, _id=None, save_state=None):
if not getattr(self, "_is_new_instance", False):
return
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = DataGridsState(self, save_state)
self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before")
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
# Data layer — session-scoped singletons
self._data_services_manager = DataServicesManager(self._parent)
def get_state(self):
return self._state
# ------------------------------------------------------------------
# Grid lifecycle
# ------------------------------------------------------------------
def _create_and_register_grid(self, namespace: str, name: str, df: pd.DataFrame) -> DataGrid:
"""Create a DataGrid and its companion DataService, then register both.
Args:
namespace: Grid namespace.
name: Grid name.
df: DataFrame to initialise the grid with.
Returns:
Created DataGrid instance.
"""
table_name = f"{namespace}.{name}" if namespace else name
data_service = self._data_services_manager.create_service(table_name, save_state=True)
data_service.load_dataframe(df)
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
dg_conf = DatagridConf(namespace=namespace, name=name)
dg = DataGrid(self, conf=dg_conf, save_state=True, _id=grid_id)
self._registry.put(namespace, name, dg.get_id())
return dg
def _create_document(self, namespace: str, name: str, datagrid: DataGrid, tab_id: str = None) -> tuple[
str, DocumentDefinition]:
"""Create a DocumentDefinition and its associated tab.
Args:
namespace: Document namespace.
name: Document name.
datagrid: Associated DataGrid instance.
tab_id: Optional existing tab ID. If None, creates a new tab.
Returns:
Tuple of (tab_id, document).
"""
if tab_id is None:
tab_id = self._tabs_manager.create_tab(name, datagrid)
document = DocumentDefinition(
document_id=str(uuid.uuid4()),
namespace=namespace,
name=name,
type="excel",
tab_id=tab_id,
datagrid_id=datagrid.get_id()
)
self._state.elements = self._state.elements + [document]
return tab_id, document
def _add_document_to_tree(self, document: DocumentDefinition, parent_id: str) -> TreeNode:
"""Add a document node to the tree view.
Args:
document: Document to add.
parent_id: Parent node ID in the tree.
Returns:
Created TreeNode.
"""
tree_node = TreeNode(
id=document.document_id,
label=document.name,
type=document.type,
parent=parent_id,
bag=document.document_id
)
self._tree.add_node(tree_node, parent_id=parent_id)
return tree_node
# ------------------------------------------------------------------
# Commands handlers
# ------------------------------------------------------------------
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
def handle_new_grid(self):
selected_id = self._tree.get_selected_id()
if selected_id is None:
parent_id = self._tree.ensure_path("Untitled")
else:
node = self._tree._state.items[selected_id]
if node.type == "folder":
parent_id = selected_id
else:
parent_id = node.parent
namespace = self._tree.get_state().items[parent_id].label
name = self._generate_unique_sheet_name(parent_id)
dg = self._create_and_register_grid(namespace, name, pd.DataFrame())
tab_id, document = self._create_document(namespace, name, dg)
self._add_document_to_tree(document, parent_id)
if parent_id not in self._tree.get_state().opened:
self._tree.get_state().opened.append(parent_id)
self._tree.get_state().selected = document.document_id
self._tree.handle_start_rename(document.document_id)
return self._tree, self._tabs_manager.show_tab(tab_id)
def _generate_unique_sheet_name(self, parent_id: str) -> str:
children = self._tree.get_state().items[parent_id].children
existing_labels = {self._tree.get_state().items[c].label for c in children}
n = 1
while f"Sheet{n}" in existing_labels:
n += 1
return f"Sheet{n}"
def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content()
df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
namespace = file_upload.get_file_basename()
name = file_upload.get_sheet_name()
dg = self._create_and_register_grid(namespace, name, df)
tab_id, document = self._create_document(namespace, name, dg, tab_id=tab_id)
parent_id = self._tree.ensure_path(document.namespace)
self._add_document_to_tree(document, parent_id)
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, dg)
def select_document(self, node_id):
document_id = self._tree.get_bag(node_id)
try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self, _id=document.datagrid_id)
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
except StopIteration:
return None
def delete_grid(self, node_id):
"""Delete a grid and all its associated resources.
Called BEFORE TreeView._delete_node() so we can access the node bag.
Args:
node_id: ID of the TreeView node to delete.
Returns:
List of UI updates, or None.
"""
document_id = self._tree.get_bag(node_id)
if document_id is None:
return None
res = []
try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self, _id=document.datagrid_id)
close_tab_res = self._tabs_manager.close_tab(document.tab_id)
res.append(close_tab_res)
self._registry.remove(document.datagrid_id)
self._data_services_manager.remove_service(document.datagrid_id)
dg.delete()
InstancesManager.remove(self._session, document.datagrid_id)
self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
self._state.save()
except StopIteration:
pass
return res
def create_tab_content(self, tab_id):
"""Recreate tab content after restart.
Args:
tab_id: ID of the tab to recreate.
Returns:
DataGrid instance for the tab.
"""
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
if document is None:
raise ValueError(f"No document found for tab {tab_id}")
dg = DataGrid(self, _id=document.datagrid_id)
return dg
def clear_tree(self):
self._state.elements = []
self._tree.clear()
return self._tree
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def mk_main_icons(self):
return Div(
mk.icon(folder_open20_regular, tooltip="Upload from source",
command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid",
command=self.commands.new_grid()),
cls="flex"
)
def _mk_tree(self):
conf = TreeViewConf(add_leaf=False,
icons={"folder": "database20_regular", "excel": "table20_regular"})
tree = TreeView(self, conf=conf, _id="-treeview")
for element in self._state.elements:
parent_id = tree.ensure_path(element.namespace, node_type="folder")
tree.add_node(TreeNode(id=element.document_id,
label=element.name,
type=element.type,
parent=parent_id,
bag=element.document_id))
return tree
def render(self):
return Div(
self._tree,
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -1,50 +1,106 @@
from fastcore.xml import FT
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
class Commands(BaseCommands):
def close(self):
return Command("Close",
"Close Dropdown",
self._owner,
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
def click(self):
return Command("Click",
"Click on Dropdown",
self._owner,
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
class DropdownState:
def __init__(self):
self.opened = False
class Dropdown(MultipleInstance):
def __init__(self, parent, content, button=None, _id=None):
"""
Interactive dropdown component that toggles open/closed on button click.
Provides automatic close behavior when clicking outside or pressing ESC.
Supports configurable positioning relative to the trigger button.
Args:
parent: Parent instance (required).
content: Content to display in the dropdown panel.
button: Trigger element that toggles the dropdown.
_id: Custom ID for the instance.
position: Vertical position relative to button.
- "below" (default): Dropdown appears below the button.
- "above": Dropdown appears above the button.
align: Horizontal alignment relative to button.
- "left" (default): Aligns to the left edge of the button.
- "right": Aligns to the right edge of the button.
- "center": Centers relative to the button.
Example:
dropdown = Dropdown(
parent=root,
button=Button("Menu"),
content=Ul(Li("Option 1"), Li("Option 2")),
position="below",
align="right"
)
"""
def __init__(self, parent, content=None, button=None, _id=None,
position="below", align="left"):
super().__init__(parent, _id=_id)
self.button = button
self.button = Div(button) if not isinstance(button, FT) else button
self.content = content
self.commands = Commands(self)
self._state = DropdownState()
self._position = position
self._align = align
def toggle(self):
self._state.opened = not self._state.opened
return self._mk_content()
def close(self):
self._state.opened = False
return self._mk_content()
def on_click(self, combination, is_inside: bool, is_button: bool = False):
if combination == "click":
if is_button:
self._state.opened = not self._state.opened
else:
self._state.opened = is_inside
return self._mk_content()
def _mk_content(self):
position_cls = f"mf-dropdown-{self._position}"
align_cls = f"mf-dropdown-{self._align}"
return Div(self.content,
cls=f"mf-dropdown {position_cls} {align_cls} {'is-visible' if self._state.opened else ''}",
id=f"{self._id}-content"),
def render(self):
return Div(
Div(self.button) if self.button else Div("None"),
Div(self.content, cls="mf-dropdown"),
cls="mf-dropdown-wrapper"
Div(
Div(self.button if self.button else "None", cls="mf-dropdown-btn"),
self._mk_content(),
cls="mf-dropdown-wrapper"
),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close(), require_inside=True),
Mouse(self, "-mouse").add("click", self.commands.click(), hx_vals="js:getDropdownExtra()"),
id=self._id
)
def __ft__(self):
return self.render()
# document.addEventListener('htmx:afterSwap', function(event) {
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
#
# // Vérifie si c'est bien notre popup
# if (targetElement.classList.contains('mf-popup-container')) {
#
# // Trouver l'élément déclencheur HTMX (le bouton existant)
# // HTMX stocke l'élément déclencheur dans event.detail.elt
# const trigger = document.querySelector('#mon-bouton-existant');
#
# if (trigger) {
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
# const rect = trigger.getBoundingClientRect();
#
# // L'élément du popup à positionner
# const popup = targetElement;
#
# // Appliquer la position au conteneur du popup
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
#
# // Top: Juste en dessous de l'élément déclencheur
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
#
# // Left: Aligner avec le côté gauche de l'élément déclencheur
# popup.style.left = (rect.left + window.scrollX) + 'px';
# }
# }
# });

View File

@@ -0,0 +1,222 @@
"""
DslEditor control - A CodeMirror wrapper for DSL editing.
Provides syntax highlighting, line numbers, and autocompletion
for domain-specific languages defined with Lark grammars.
"""
import json
import logging
from dataclasses import dataclass
from typing import Optional
from fasthtml.common import Script
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("DslEditor")
@dataclass
class DslEditorConf:
"""Configuration for DslEditor."""
name: str = None
line_numbers: bool = True
autocompletion: bool = True
linting: bool = True
placeholder: str = ""
readonly: bool = False
engine_id: str = None # id of the DSL engine to use for autocompletion
save_button: bool = True
class DslEditorState(DbObject):
"""Non-persisted state for DslEditor."""
def __init__(self, owner, name, save_state):
with self.initializing():
super().__init__(owner, name=name, save_state=save_state)
self.content: str = ""
self.auto_save: bool = True
class Commands(BaseCommands):
"""Commands for DslEditor interactions."""
def update_content(self):
"""Command to update content from CodeMirror."""
return Command(
"UpdateContent",
"Update editor content",
self._owner,
self._owner.update_content,
).htmx(target=f"#{self._id}", swap="none")
def toggle_auto_save(self):
return Command("ToggleAutoSave",
"Toggle auto save",
self._owner,
self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click")
def save_content(self):
return Command("SaveContent",
"Save content",
self._owner,
self._owner.save_content
).htmx(target=None)
class DslEditor(MultipleInstance):
"""
CodeMirror wrapper for editing DSL code.
Provides:
- Syntax highlighting based on DSL grammar
- Line numbers
- Autocompletion from grammar keywords/operators
Args:
parent: Parent instance.
dsl: DSL definition providing grammar and completions.
conf: Editor configuration.
_id: Optional custom ID.
"""
def __init__(
self,
parent,
dsl: DSLDefinition,
conf: Optional[DslEditorConf] = None,
save_state: bool = True,
_id: Optional[str] = None,
):
super().__init__(parent, _id=_id)
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState(self, name=self.conf.name, save_state=save_state)
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
def set_content(self, content: str):
"""Set the editor content programmatically."""
self._state.content = content
return self
def get_content(self) -> str:
"""Get the current editor content."""
return self._state.content
def update_content(self, content: str = ""):
"""Handler for content update from CodeMirror."""
self._state.content = content
logger.debug(f"Content updated: {len(content)} chars")
if self._state.auto_save:
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
return None
def save_content(self):
logger.debug("save_content")
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
def toggle_auto_save(self):
logger.debug("toggle_auto_save")
self._state.auto_save = not self._state.auto_save
logger.debug(f" auto_save={self._state.auto_save}")
return self._mk_auto_save()
def on_content_changed(self) -> None:
pass
def _get_editor_config(self) -> dict:
"""Build the JavaScript configuration object."""
# Get Simple Mode config if available
simple_mode_config = None
if hasattr(self._dsl, 'simple_mode_config'):
simple_mode_config = self._dsl.simple_mode_config
config = {
"elementId": str(self._id),
"textareaId": f"ta_{self._id}",
"lineNumbers": self.conf.line_numbers,
"autocompletion": self.conf.autocompletion,
"linting": self.conf.linting,
"placeholder": self.conf.placeholder,
"readonly": self.conf.readonly,
"updateCommandId": str(self.commands.update_content().id),
"dslId": self.conf.engine_id,
"dsl": {
"name": self._dsl.name,
"completions": self._dsl.completions,
"simpleModeConfig": simple_mode_config,
},
}
return config
def _mk_textarea(self):
"""Create the hidden textarea for form submission."""
return Textarea(
self._state.content,
id=f"ta_{self._id}",
name=self.conf.name if (self.conf and self.conf.name) else f"ta_{self._id}",
cls="hidden",
)
def _mk_editor_container(self):
"""Create the container where CodeMirror will be mounted."""
return Div(
id=f"cm_{self._id}",
cls="mf-dsl-editor",
)
def _mk_init_script(self):
"""Create the initialization script."""
config = self._get_editor_config()
config_json = json.dumps(config)
return Script(f"initDslEditor({config_json});")
def _mk_auto_save(self):
if not self.conf.save_button:
return None
return Div(
Label(
mk.mk(
Input(type="checkbox",
checked="on" if self._state.auto_save else None,
cls="toggle toggle-xs"),
command=self.commands.toggle_auto_save()
),
"Auto Save",
cls="text-xs",
),
mk.button("Save",
cls="btn btn-xs btn-primary",
disabled="disabled" if self._state.auto_save else None,
command=self.commands.save_content()),
cls="flex justify-between items-center p-2",
id=f"as_{self._id}",
),
def render(self):
"""Render the DslEditor component."""
return Div(
self._mk_auto_save(),
self._mk_textarea(),
self._mk_editor_container(),
self._mk_init_script(),
id=self._id,
cls="mf-dsl-editor-wrapper",
)
def __ft__(self):
"""FastHTML magic method for rendering."""
return self.render()

View File

@@ -6,7 +6,7 @@ from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -24,32 +24,62 @@ class FileUploadState(DbObject):
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
self.ns_file_content: bytes | None = None
self.ns_on_ok = None
self.ns_on_cancel = None
class Commands(BaseCommands):
def __init__(self, owner):
super().__init__(owner)
def upload_file(self):
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
def on_file_uploaded(self):
return Command("UploadFile",
"Upload file",
self._owner,
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
def on_sheet_selected(self):
return Command("SheetSelected",
"Sheet selected",
self._owner,
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):
"""
Represents a file upload component.
This class provides functionality to handle the uploading process of a file,
extract sheet names from an Excel file, and enables users to select a specific
sheet for further processing. It integrates commands and state management
to ensure smooth operation within a parent application.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def __init__(self, parent, _id=None, **kwargs):
super().__init__(parent, _id=_id, **kwargs)
self.commands = Commands(self)
self._state = FileUploadState(self)
self._state.ns_on_ok = None
def set_on_ok(self, callback):
self._state.ns_on_ok = callback
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(file_content)
self._state.ns_file_content = file.file.read()
self._state.ns_file_name = file.filename
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
def select_sheet(self, sheet_name: str):
logger.debug(f"select_sheet: {sheet_name=}")
self._state.ns_selected_sheet_name = sheet_name
return self.mk_sheet_selector()
def mk_sheet_selector(self):
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
[Option(
@@ -57,12 +87,27 @@ class FileUpload(MultipleInstance):
selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names]
return Select(
return mk.mk(Select(
*options,
name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2"
)
), command=self.commands.on_sheet_selected())
def get_content(self):
return self._state.ns_file_content
def get_file_name(self):
return self._state.ns_file_name
def get_file_basename(self):
if self._state.ns_file_name is None:
return None
return self._state.ns_file_name.split(".")[0]
def get_sheet_name(self):
return self._state.ns_selected_sheet_name
@staticmethod
def get_sheets_names(file_content):
@@ -86,12 +131,12 @@ class FileUpload(MultipleInstance):
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
command=self.commands.upload_file()
command=self.commands.on_file_uploaded()
),
self.mk_sheet_selector(),
cls="flex"
),
mk.dialog_buttons(),
mk.dialog_buttons(on_ok=self._state.ns_on_ok, on_cancel=self._state.ns_on_cancel),
)
def __ft__(self):

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

Some files were not shown because too many files have changed in this diff Show More