16 Commits

Author SHA1 Message Date
3ea551bc1a Fixed wrong full refresh 2026-03-23 22:32:05 +01:00
3bcf50f55f Hardened instance creation 2026-03-23 22:10:11 +01:00
7f099b14f6 Fixed double Panel double instantiation 2026-03-23 21:41:06 +01:00
0e1087a614 First version of Profiler control with right part 2026-03-22 16:40:21 +01:00
d3c0381e34 Fixed unit tests ! 2026-03-22 08:37:07 +01:00
b8fd4e5ed1 Added Profiler control with basic UI 2026-03-22 08:32:40 +01:00
72d6cce6ff Adding Profiler module 2026-03-21 18:08:34 +01:00
f887267362 Optimizing keyboard navigation and selection handling 2026-03-21 18:08:13 +01:00
853bc4abae Added keyboard navigation support 2026-03-16 22:43:45 +01:00
2fcc225414 Added some unit tests for the grid 2026-03-16 21:46:19 +01:00
ef9f269a49 I can edit a cell 2026-03-16 21:16:21 +01:00
0951680466 Fixed minor issues.
* highlighted cells not correctly rendered
* text selection not correctly working
2026-03-15 18:45:55 +01:00
0c9c8bc7fa Fixed FormattingRules not being applied 2026-03-15 16:50:21 +01:00
feb9da50b2 Implemented enable/disable for keyboard support 2026-03-15 08:33:39 +01:00
f773fd1611 Keyboard.py : ajout de enabled dans add(), nouveau render() retournant (Script,
control_div), et méthodes mk_enable / mk_disable
  - keyboard.js : nouvelle signature add_keyboard_support(elementId, controlDivId,
  combinationsJson), fonction isCombinationEnabled(), vérification avant déclenchement
  - test_Keyboard.py : 8 tests couvrant les comportements et le rendu
2026-03-14 23:29:18 +01:00
af83f4b6dc Added unit test 2026-03-14 22:16:20 +01:00
45 changed files with 6649 additions and 691 deletions

View File

@@ -205,7 +205,7 @@ def render(self):
return Div(
self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
id=self._id
)
```

View File

@@ -209,13 +209,14 @@ For interactive controls, compose `Keyboard` and `Mouse`:
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
def render(self):
return Div(
self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
id=self._id
)
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
)
```
---

File diff suppressed because it is too large Load Diff

118
docs/Datagrid Tests.md Normal file
View File

@@ -0,0 +1,118 @@
# DataGrid Tests — Backlog
Source file: `tests/controls/test_datagrid.py`
Legend: ✅ Done — ⬜ Pending
---
## TestDataGridBehaviour
### Edition flow
| # | Status | Test | Description |
|---|--------|-----------------------------------------------------------|----------------------------------------------------------|
| 1 | ⬜ | `test_i_can_convert_edition_value_for_number` | `"3.14"``float`, `"5"``int` |
| 2 | ⬜ | `test_i_can_convert_edition_value_for_bool` | `"true"`, `"1"`, `"yes"``True`; others → `False` |
| 3 | ⬜ | `test_i_can_convert_edition_value_for_text` | String value returned unchanged |
| 4 | ⬜ | `test_i_can_handle_start_edition` | Sets `edition.under_edition` and returns a cell render |
| 5 | ⬜ | `test_i_cannot_handle_start_edition_when_already_editing` | Second call while `under_edition` is set is a no-op |
| 6 | ⬜ | `test_i_can_handle_save_edition` | Writes value to data service and clears `under_edition` |
| 7 | ⬜ | `test_i_cannot_handle_save_edition_when_not_editing` | Returns partial render without touching the data service |
### Column management
| # | Status | Test | Description |
|----|--------|---------------------------------------------------------|----------------------------------------------------------------|
| 8 | ⬜ | `test_i_can_add_new_column` | Appends column to `_state.columns` and `_columns` |
| 9 | ⬜ | `test_i_can_handle_columns_reorder` | Reorders `_state.columns` according to provided list |
| 10 | ⬜ | `test_i_can_handle_columns_reorder_ignores_unknown_ids` | Unknown IDs skipped; known columns not in list appended at end |
### Mouse selection
| # | Status | Test | Description |
|----|--------|-------------------------------------------------|---------------------------------------------------------------|
| 11 | ⬜ | `test_i_can_on_mouse_selection_sets_range` | Sets `extra_selected` with `("range", ...)` from two cell IDs |
| 12 | ⬜ | `test_i_cannot_on_mouse_selection_when_outside` | `is_inside=False` leaves `extra_selected` unchanged |
### Key pressed
| # | Status | Test | Description |
|----|--------|--------------------------------------------------|----------------------------------------------------------------------------------------------|
| 13 | ⬜ | `test_i_can_on_key_pressed_enter_starts_edition` | `enter` on selected cell enters edition when `enable_edition=True` and nothing under edition |
### Click
| # | Status | Test | Description |
|----|--------|---------------------------------------------------|-----------------------------------------------------------------|
| 14 | ⬜ | `test_i_can_on_click_second_click_enters_edition` | Second click on already-selected cell triggers `_enter_edition` |
### Filtering / sorting
| # | Status | Test | Description |
|----|--------|--------------------------------------------|-------------------------------------------------------------------------------------|
| 15 | ⬜ | `test_i_can_filter_grid` | `filter()` updates `_state.filtered`; filtered DataFrame excludes non-matching rows |
| 16 | ⬜ | `test_i_can_apply_sort` | `_apply_sort` returns rows in correct order when a sort definition is present |
| 17 | ⬜ | `test_i_can_apply_filter_by_column_values` | Column filter (non-FILTER_INPUT) keeps only matching rows |
### Format rules priority
| # | Status | Test | Description |
|----|--------|----------------------------------------------------------------------|------------------------------------------------------------------|
| 18 | ⬜ | `test_i_can_get_format_rules_cell_level_takes_priority` | Cell format overrides row, column and table format |
| 19 | ⬜ | `test_i_can_get_format_rules_row_level_takes_priority_over_column` | Row format overrides column and table when no cell format |
| 20 | ⬜ | `test_i_can_get_format_rules_column_level_takes_priority_over_table` | Column format overrides table when no cell or row format |
| 21 | ⬜ | `test_i_can_get_format_rules_falls_back_to_table_format` | Table format returned when no cell, row or column format defined |
---
## TestDataGridRender
### Table structure
| # | Status | Test | Description |
|----|--------|------------------------------------------|-------------------------------------------------------------------------------------------|
| 22 | ✅ | `test_i_can_render_table_wrapper` | ID `tw_{id}`, class `dt2-table-wrapper`, 3 sections: selection manager, table, scrollbars |
| 23 | ✅ | `test_i_can_render_table` | ID `t_{id}`, class `dt2-table`, 3 containers: header, body wrapper, footer |
| 24 | ✅ | `test_i_can_render_table_has_scrollbars` | Scrollbars overlay contains vertical and horizontal tracks |
### render_partial fragments
| # | Status | Test | Description |
|----|--------|---------------------------------------------------|--------------------------------------------------------------------------------------|
| 25 | ✅ | `test_i_can_render_partial_body` | Returns `(selection_manager, body_wrapper)` — body wrapper has `hx-on::after-settle` |
| 26 | ✅ | `test_i_can_render_partial_table` | Returns `(selection_manager, table)` — table has `hx-on::after-settle` |
| 27 | ✅ | `test_i_can_render_partial_header` | Returns header with `hx-on::after-settle` containing `setColumnWidth` |
| 28 | ✅ | `test_i_can_render_partial_cell_by_pos` | Returns `(selection_manager, cell)` for a specific `(col, row)` position |
| 29 | ✅ | `test_i_can_render_partial_cell_with_no_position` | Returns only `(selection_manager,)` when no `pos` or `cell_id` given |
### Edition cell
| # | Status | Test | Description |
|----|--------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------|
| 30 | ⬜ | `test_i_can_render_body_cell_in_edition_mode` | When `edition.under_edition` matches, `mk_body_cell` returns an input cell with class `dt2-cell-edition` |
### Cell content — search highlighting
| # | Status | Test | Description |
|----|--------|-----------------------------------------------------------------------------|-----------------------------------------------------------------|
| 31 | ⬜ | `test_i_can_render_body_cell_content_with_search_highlight` | Matching keyword produces a `Span` with class `dt2-highlight-1` |
| 32 | ⬜ | `test_i_can_render_body_cell_content_with_no_highlight_when_keyword_absent` | Non-matching keyword produces no `dt2-highlight-1` span |
### Footer
| # | Status | Test | Description |
|----|--------|-----------------------------------------------------------|--------------------------------------------------------------------------|
| 33 | ⬜ | `test_i_can_render_footers_wrapper` | `mk_footers` renders with ID `tf_{id}` and class `dt2-footer` |
| 34 | ⬜ | `test_i_can_render_aggregation_cell_sum` | `mk_aggregation_cell` with `FooterAggregation.Sum` renders the sum value |
| 35 | ⬜ | `test_i_cannot_render_aggregation_cell_for_hidden_column` | Hidden column returns `Div(cls="dt2-col-hidden")` |
---
## Summary
| Class | Total | ✅ Done | ⬜ Pending |
|-------------------------|--------|--------|-----------|
| `TestDataGridBehaviour` | 21 | 0 | 21 |
| `TestDataGridRender` | 14 | 8 | 6 |
| **Total** | **35** | **8** | **27** |

View File

@@ -100,6 +100,57 @@ add_keyboard_support('elem2', '{"C D": "/url2"}');
The timeout is tied to the **sequence being typed**, not to individual elements.
### Enabling and Disabling Combinations
Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered.
| State | Behavior |
|-------|----------|
| `enabled=True` (default) | Combination triggers normally |
| `enabled=False` | Combination is silently ignored when pressed |
**Setting the initial state at registration:**
```python
# Enabled by default
keyboard.add("ctrl+s", self.commands.save())
# Disabled at startup
keyboard.add("ctrl+d", self.commands.delete(), enabled=False)
```
**Toggling dynamically at runtime:**
Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload.
```python
# Enable a combination (e.g., once an item is selected)
def handle_select(self):
item = ...
return item.render(), self.keyboard.mk_enable("ctrl+d")
# Disable a combination (e.g., when nothing is selected)
def handle_deselect(self):
return self.keyboard.mk_disable("ctrl+d")
```
The enabled state is stored in a hidden control `<div>` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action.
**State at a glance:**
```
┌─────────────────────────────────────┐
│ Keyboard control div (hidden) │
│ ┌──────────────────────────────┐ │
│ │ div [data-combination="esc"] │ │
│ │ [data-enabled="true"] │ │
│ ├──────────────────────────────┤ │
│ │ div [data-combination="ctrl+d"] │ │
│ │ [data-enabled="false"] │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
@@ -364,16 +415,56 @@ remove_keyboard_support('modal');
## API Reference
### add_keyboard_support(elementId, combinationsJson)
### add_keyboard_support(elementId, controlDivId, combinationsJson)
Adds keyboard support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
- `elementId` (string): ID of the HTML element to watch for key events
- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
### Python Component Methods
These methods are available on the `Keyboard` instance.
#### add(sequence, command, require_inside=True, enabled=True)
Registers a key combination.
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — |
| `command` | `Command` | Command to execute when the combination is triggered | — |
| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` |
| `enabled` | `bool` | Whether the combination is active at render time | `True` |
**Returns**: `self` (chainable)
#### mk_enable(sequence)
Returns an out-of-band HTMX element that enables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
#### mk_disable(sequence)
Returns an out-of-band HTMX element that disables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
---
### remove_keyboard_support(elementId)
Removes keyboard support from an element.

355
docs/Profiler.md Normal file
View File

@@ -0,0 +1,355 @@
# Profiler — Design & Implementation Plan
## Context
Performance issues were identified during keyboard navigation in the DataGrid (173ms server-side
per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bottleneck is
server-side. A persistent, in-application profiling system is needed for continuous analysis
across sessions and future investigations.
---
## Implementation Status
| Phase | Item | Status |
|-------|------|--------|
| **Phase 1 — Core** | `profiler.py` — data model + probe mechanisms | ✅ Done |
| **Phase 1 — Core** | `tests/core/test_profiler.py` — full test suite (7 classes) | ✅ Done |
| **Phase 1 — Core** | Hook `utils.py` — Level A `command_span` | ✅ Done |
| **Phase 1 — Core** | Hook `commands.py` — Level B phases | ⏳ Deferred |
| **Phase 2 — Controls** | `Profiler.py` — global layout (toolbar + list) | 🔄 In progress |
| **Phase 2 — Controls** | `Profiler.py` — detail panel (span tree + pie) | ⏳ Pending |
| **Phase 2 — Controls** | CSS `profiler.css` | 🔄 In progress |
| **Phase 2 — Controls** | `ProfilerPieChart.py` | ⏳ Future |
---
## Design Decisions
### Data Collection Strategy
Two complementary levels:
- **Level A** (route handler): One trace per `/myfasthtml/commands` call. Captures total
server-side duration including lookup, execution, and HTMX swap overhead.
- **Level B** (granular spans): Decomposition of each trace into named phases. Activated
by placing probes in the code.
Both levels are active simultaneously. Level A gives the global picture; Level B gives the
breakdown.
### Probe Mechanisms
Four complementary mechanisms, chosen based on the context:
#### 1. Context manager — partial block instrumentation
```python
with profiler.span("oob_swap"):
# only this block is timed
result = build_oob_elements(...)
```
Metadata can be attached during execution:
```python
with profiler.span("query") as span:
rows = db.query(...)
span.set("row_count", len(rows))
```
#### 2. Decorator — full function instrumentation
```python
@profiler.span("callback")
def execute_callback(self, client_response):
...
```
Function arguments are captured automatically. Metadata can be attached via `current_span()`:
```python
@profiler.span("process")
def process(self, rows):
result = do_work(rows)
profiler.current_span().set("row_count", len(result))
return result
```
#### 3. Cumulative span — loop instrumentation
For loops with many iterations. Aggregates instead of creating one span per iteration.
```python
for row in rows:
with profiler.cumulative_span("process_row"):
process(row)
# or as a decorator
@profiler.cumulative_span("process_row")
def process_row(self, row):
...
```
Exposes: `count`, `total`, `min`, `max`, `avg`. Single entry in the trace tree regardless of
iteration count.
#### 4. `trace_all` — class-level static instrumentation
Wraps all methods of a class at definition time. No runtime overhead beyond the spans themselves.
```python
@profiler.trace_all
class DataGrid(MultipleInstance):
def navigate_cell(self, ...): # auto-spanned
...
# Exclude specific methods
@profiler.trace_all(exclude=["__ft__", "render"])
class DataGrid(MultipleInstance):
...
```
Implementation: uses `inspect` to iterate over methods and wraps each with `@profiler.span()`.
No `sys.settrace()` involved — pure static wrapping.
#### 5. `trace_calls` — sub-call exploration
Traces all function calls made within a single function, recursively. Used for exploration
when the bottleneck location is unknown.
```python
@profiler.trace_calls
def navigate_cell(self, ...):
self._update_selection() # auto-traced as child span
self._compute_visible() # auto-traced as child span
db.query(...) # auto-traced as child span
```
Implementation: uses `sys.setprofile()` scoped to the decorated function's execution only.
Overhead is localized to that function's call stack. This is an exploration tool — use it
to identify hotspots, then replace with explicit probes.
### Span Hierarchy
Hierarchy is determined by code nesting via a `ContextVar` stack (async-safe). No explicit
parent references required.
```python
with profiler.span("execute"): # root
with profiler.span("callback"): # child of execute
result = self.callback(...)
with profiler.span("oob_swap"): # sibling of callback
...
```
When a command calls another command, the second command's spans automatically become children
of the first command's active span.
`profiler.current_span()` provides access to the active span from anywhere in the call stack.
### Storage
- **Scope**: Global (all sessions). Profiling measures server behavior, not per-user state.
- **Structure**: `deque` with a configurable maximum size.
- **Default size**: 500 traces (constant `PROFILER_MAX_TRACES`).
- **Eviction**: Oldest traces are dropped when the buffer is full (FIFO).
- **Persistence**: In-memory only. Lost on server restart.
### Toggle and Clear
- `profiler.enabled` — boolean flag. When `False`, all probe mechanisms are no-ops (zero overhead).
- `profiler.clear()` — empties the trace buffer.
- Both are controllable from the UI control.
### Overhead Measurement
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
Exposes:
- `overhead_per_span_us` — average cost of one span boundary in microseconds
- `total_overhead_ms` — estimated total overhead across all active spans
Visible in the UI to verify the profiler does not bias measurements significantly.
---
## Data Model
```
ProfilingTrace
command_name: str
command_id: str
kwargs: dict
timestamp: datetime
total_duration_ms: float
root_span: ProfilingSpan
ProfilingSpan
name: str
start: float (perf_counter)
duration_ms: float
data: dict (attached via span.set())
children: list[ProfilingSpan | CumulativeSpan]
CumulativeSpan
name: str
count: int
total_ms: float
min_ms: float
max_ms: float
avg_ms: float
```
---
## Code Hooks
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
```python
command = CommandsManager.get_command(c_id)
if command:
with profiler.command_span(command.name, c_id, client_response or {}):
return command.execute(client_response)
```
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
Planned breakdown inside `Command.execute()`:
```python
def execute(self, client_response=None):
with profiler.span("before_commands"):
...
with profiler.span("callback"):
result = self.callback(...)
with profiler.span("after_commands"):
...
with profiler.span("oob_swap"):
...
```
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
---
## UI Control Design
### Control name: `Profiler` (SingleInstance)
Single entry point. Replaces the earlier `ProfilerList` name.
**Files:**
- `src/myfasthtml/controls/Profiler.py`
- `src/myfasthtml/assets/core/profiler.css`
### Layout
Split view using `Panel`:
```
┌─────────────────────────────────────────────────────┐
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
├──────────────────────┬──────────────────────────────┤
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
│ ──────────────────────│ ─────────────────────────────│
│ NavigateCell 173ms … │ [Metadata card] │
│ NavigateCell 168ms … │ [kwargs card] │
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
│ … │ │
└──────────────────────┴──────────────────────────────┘
```
### Toolbar
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
Direct `mk.icon()` calls:
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
- **Clear**: delete icon, always red
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
Overhead metrics displayed as plain text on the right side of the toolbar.
### Trace list (left panel)
Three columns: command name / duration (color-coded) / timestamp.
Click on a row → update right panel via HTMX.
**Duration color thresholds:**
- Green (`mf-profiler-fast`): < 20 ms
- Orange (`mf-profiler-medium`): 20100 ms
- Red (`mf-profiler-slow`): > 100 ms
### Detail panel (right)
Two view modes, toggled by icons in the detail panel header:
1. **Tree view** (default): Properties-style cards (Metadata, kwargs) + span breakdown with
proportional bars and indentation. Cumulative spans show `×N · min/avg/max` badge.
2. **Pie view**: `ProfilerPieChart` control (future) — distribution of time across spans
at the current zoom level.
The `Properties` control is used as-is for Metadata and kwargs cards.
The span breakdown is custom rendering (not a `Properties` instance).
### Font conventions
- Labels, headings, command names: `--font-sans` (DaisyUI default)
- Values (durations, timestamps, kwargs values): `--font-mono`
- Consistent with `properties.css` (`mf-properties-value` uses `--default-mono-font-family`)
### Visual reference
Mockups available in `examples/`:
- `profiler_mockup.html` — first iteration (monospace font everywhere)
- `profiler_mockup_2.html`**reference** (correct fonts, icon toolbar, tree/pie toggle)
---
## Implementation Plan
### Phase 1 — Core ✅ Complete
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
2. `ProfilingManager` with all probe mechanisms
3. `profiler` singleton
4. Hook into `utils.py` (Level A) ✅
5. Hook into `commands.py` (Level B) — deferred
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
### Phase 2 — Controls
#### Step 2.1 — Global layout (current) 🔄
`src/myfasthtml/controls/Profiler.py`:
- `SingleInstance` inheriting
- Toolbar: `mk.icon()` for enable/disable and clear, overhead text
- `Panel` for split layout
- Left: trace list table (command / duration / timestamp), click → select_trace command
- Right: placeholder (empty until Step 2.2)
`src/myfasthtml/assets/core/profiler.css`:
- All `mf-profiler-*` classes
#### Step 2.2 — Detail panel ⏳
Right panel content:
- Metadata and kwargs via `Properties`
- Span tree: custom `_mk_span_tree()` with bars and cumulative badges
- View toggle (tree / pie) in detail header
#### Step 2.3 — Pie chart ⏳ Future
`src/myfasthtml/controls/ProfilerPieChart.py`
---
## Naming Conventions
- Control files: `ProfilerXxx.py`
- CSS classes: `mf-profiler-xxx`
- Logger: `logging.getLogger("Profiler")`
- Constant: `PROFILER_MAX_TRACES = 500` in `src/myfasthtml/core/constants.py`

View File

@@ -181,13 +181,13 @@ Users can rename nodes via the edit button:
```python
# Programmatically start rename
tree._start_rename("node-id")
tree.handle_start_rename("node-id")
# Save rename
tree._save_rename("node-id", "New Label")
tree.handle_save_rename("node-id", "New Label")
# Cancel rename
tree._cancel_rename()
tree.handle_cancel_rename()
```
### Deleting Nodes
@@ -201,7 +201,7 @@ Users can delete nodes via the delete button:
```python
# Programmatically delete node
tree._delete_node("node-id") # Raises ValueError if node has children
tree.handle_delete_node("node-id") # Raises ValueError if node has children
```
## Content System
@@ -449,18 +449,20 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree")
root = TreeNode(id="root", label="Tasks", type="folder")
tree.add_node(root)
# Function to handle selection
def on_node_selected(node_id):
# Custom logic when node is selected
node = tree._state.items[node_id]
tree._select_node(node_id)
# Custom logic when node is selected
node = tree._state.items[node_id]
tree.handle_select_node(node_id)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Override select command with custom handler
# (In practice, you'd extend the Commands class or use event callbacks)

View File

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

@@ -13,13 +13,14 @@ from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_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:
@@ -55,13 +56,19 @@ def index(session):
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
id=f"l_{instances_debugger.get_id()}")
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
id=f"l_{commands_debugger.get_id()}")
profiler = Profiler(layout)
btn_show_profiler = mk.label("Profiler",
icon=timer20_regular,
command=add_tab("Profiler", profiler),
id=f"l_{profiler.get_id()}")
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
@@ -75,12 +82,14 @@ def index(session):
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))
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")

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

@@ -1,7 +1,15 @@
/**
* 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
*/
@@ -172,8 +180,11 @@
// 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 = [];
@@ -198,8 +209,7 @@
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree, continue to next element
// console.debug("No match in tree for event", key);
kbLog(` element="${elementId}" → no match in tree`);
continue;
}
@@ -212,25 +222,33 @@
// 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 flag
// Collect matches, respecting require_inside and enabled flags
if (hasMatch) {
const requireInside = currentNode.config["require_inside"] === true;
if (!requireInside || isInside) {
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();
@@ -238,6 +256,7 @@
// 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) {
@@ -248,6 +267,7 @@
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
@@ -255,6 +275,7 @@
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);
@@ -267,10 +288,12 @@
}, 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 = [];
@@ -279,11 +302,13 @@
// 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 = [];
}
}
@@ -328,12 +353,29 @@
}
}
/**
* 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, combinationsJson) {
window.add_keyboard_support = function (elementId, controlDivId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
@@ -350,7 +392,8 @@
// Add to registry
KeyboardRegistry.elements.set(elementId, {
tree: tree,
element: element
element: element,
controlDivId: controlDivId
});
// Attach global listener if not already attached

View File

@@ -14,6 +14,7 @@
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500, // 500ms timeout for sequences
clickHandler: null,
dblclickHandler: null, // Handler reference for dblclick
contextmenuHandler: null,
mousedownState: null, // Active drag state (only after movement detected)
suppressNextClick: false, // Prevents click from firing after mousedown>mouseup
@@ -35,7 +36,9 @@
// Handle aliases
const aliasMap = {
'rclick': 'right_click'
'rclick': 'right_click',
'double_click': 'dblclick',
'dclick': 'dblclick'
};
return aliasMap[normalized] || normalized;
@@ -563,6 +566,43 @@
}
}
/**
* Handle dblclick events (triggers for all registered elements).
* Uses a fresh single-step history so it never conflicts with click sequences.
* @param {MouseEvent} event - The dblclick event
*/
function handleDblClick(event) {
const snapshot = createSnapshot(event, 'dblclick');
const dblclickHistory = [snapshot];
const currentMatches = [];
for (const [elementId, data] of MouseRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
const isInside = element.contains(event.target);
const currentNode = traverseTree(data.tree, dblclickHistory);
if (!currentNode || !currentNode.config) continue;
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
const anyMatchInside = currentMatches.some(m => m.isInside);
if (anyMatchInside && !isInInputContext()) {
event.preventDefault();
}
for (const match of currentMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
}
}
/**
* Clean up mousedown state and safety timeout
*/
@@ -989,11 +1029,13 @@
if (!MouseRegistry.listenerAttached) {
// Store handler references for proper removal
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
MouseRegistry.dblclickHandler = (e) => handleDblClick(e);
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
MouseRegistry.mousedownHandler = (e) => handleMouseDown(e);
MouseRegistry.mouseupHandler = (e) => handleMouseUp(e);
document.addEventListener('click', MouseRegistry.clickHandler);
document.addEventListener('dblclick', MouseRegistry.dblclickHandler);
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
document.addEventListener('mousedown', MouseRegistry.mousedownHandler);
document.addEventListener('mouseup', MouseRegistry.mouseupHandler);
@@ -1007,6 +1049,7 @@
function detachGlobalListener() {
if (MouseRegistry.listenerAttached) {
document.removeEventListener('click', MouseRegistry.clickHandler);
document.removeEventListener('dblclick', MouseRegistry.dblclickHandler);
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
document.removeEventListener('mousedown', MouseRegistry.mousedownHandler);
document.removeEventListener('mouseup', MouseRegistry.mouseupHandler);
@@ -1014,6 +1057,7 @@
// Clean up handler references
MouseRegistry.clickHandler = null;
MouseRegistry.dblclickHandler = null;
MouseRegistry.contextmenuHandler = null;
MouseRegistry.mousedownHandler = null;
MouseRegistry.mouseupHandler = null;

View File

@@ -0,0 +1,340 @@
/* ================================================================== */
/* Profiler Control */
/* ================================================================== */
/* ------------------------------------------------------------------
Root wrapper — fills parent, stacks toolbar above panel
------------------------------------------------------------------ */
.mf-profiler {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-size: var(--text-sm);
}
/* ------------------------------------------------------------------
Toolbar
------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Danger variant for clear button */
.mf-profiler-btn-danger {
color: var(--color-error) !important;
}
.mf-profiler-btn-danger:hover {
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
}
/* Overhead metrics — right-aligned text */
.mf-profiler-overhead {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
white-space: nowrap;
}
/* ------------------------------------------------------------------
Trace list — left panel content
------------------------------------------------------------------ */
.mf-profiler-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 80px 96px;
padding: 4px 10px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.mf-profiler-col-header {
font-family: var(--font-sans);
}
.mf-profiler-col-right {
text-align: right;
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
/* ------------------------------------------------------------------
Trace row
------------------------------------------------------------------ */
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 80px 96px;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
cursor: pointer;
transition: background 0.1s;
}
.mf-profiler-row:hover {
background: var(--color-base-200);
}
.mf-profiler-row-selected {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-left: 2px solid var(--color-primary);
padding-left: 8px;
}
.mf-profiler-row-selected:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
/* Command name + description cell */
.mf-profiler-cmd-cell {
display: flex;
align-items: baseline;
gap: 6px;
overflow: hidden;
}
.mf-profiler-cmd {
font-family: var(--font-sans);
font-size: var(--text-sm);
white-space: nowrap;
flex-shrink: 0;
}
.mf-profiler-cmd-description {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-style: italic;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Duration cell — monospace, color-coded */
.mf-profiler-duration {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
}
.mf-profiler-fast {
color: var(--color-success);
}
.mf-profiler-medium {
color: var(--color-warning);
}
.mf-profiler-slow {
color: var(--color-error);
}
/* Timestamp cell */
.mf-profiler-ts {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
}
/* ------------------------------------------------------------------
Detail panel — right side
------------------------------------------------------------------ */
.mf-profiler-detail {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mf-profiler-detail-header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.mf-profiler-detail-title {
flex: 1;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mf-profiler-detail-cmd {
font-family: var(--font-sans);
}
.mf-profiler-detail-duration {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.mf-profiler-view-toggle {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.mf-profiler-view-btn-active {
color: var(--color-primary) !important;
background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important;
}
.mf-profiler-detail-body {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* ------------------------------------------------------------------
Span tree — inside a Properties group card
------------------------------------------------------------------ */
.mf-profiler-span-tree-content {
display: flex;
flex-direction: column;
}
.mf-profiler-span-row {
display: flex;
align-items: center;
padding: 4px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent);
}
.mf-profiler-span-row:last-child {
border-bottom: none;
}
.mf-profiler-span-row:hover {
background: var(--color-base-200);
}
.mf-profiler-span-indent {
flex-shrink: 0;
width: 14px;
align-self: stretch;
border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
}
.mf-profiler-span-body {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding-left: 6px;
min-width: 0;
}
.mf-profiler-span-name {
min-width: 120px;
font-family: var(--font-sans);
font-size: var(--text-xs);
white-space: nowrap;
flex-shrink: 0;
}
.mf-profiler-span-name-root {
font-weight: 600;
}
.mf-profiler-span-bar-bg {
flex: 1;
height: 5px;
background: color-mix(in oklab, var(--color-border) 80%, transparent);
border-radius: 3px;
overflow: hidden;
min-width: 0;
}
.mf-profiler-span-bar {
height: 100%;
border-radius: 3px;
background: var(--color-primary);
}
.mf-profiler-span-bar.mf-profiler-medium {
background: var(--color-warning);
}
.mf-profiler-span-bar.mf-profiler-slow {
background: var(--color-error);
}
.mf-profiler-span-ms {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
min-width: 60px;
text-align: right;
flex-shrink: 0;
}
.mf-profiler-span-ms.mf-profiler-medium {
color: var(--color-warning);
}
.mf-profiler-span-ms.mf-profiler-slow {
color: var(--color-error);
}
.mf-profiler-cumulative-badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent);
color: var(--color-primary);
flex-shrink: 0;
white-space: nowrap;
}
/* ------------------------------------------------------------------
Empty state
------------------------------------------------------------------ */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
font-family: var(--font-sans);
font-size: var(--text-sm);
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}

View File

@@ -142,6 +142,19 @@
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);

View File

@@ -3,7 +3,7 @@ function initDataGrid(gridId) {
initDataGridMouseOver(gridId);
makeDatagridColumnsResizable(gridId);
makeDatagridColumnsMovable(gridId);
updateDatagridSelection(gridId)
updateDatagridSelection(gridId);
}
/**
@@ -673,9 +673,9 @@ function updateDatagridSelection(datagridId) {
// 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();
}
// 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}`);
@@ -688,6 +688,7 @@ function updateDatagridSelection(datagridId) {
});
// 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');
@@ -697,6 +698,8 @@ function updateDatagridSelection(datagridId) {
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}`);
@@ -744,6 +747,11 @@ function updateDatagridSelection(datagridId) {
}
}
});
if (!hasFocusedCell) {
const grid = document.getElementById(datagridId);
if (grid) requestAnimationFrame(() => grid.focus({ preventScroll: true }));
}
}
/**

View File

@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
Keep the boundaries updated
"""
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id)
self._owner = owner
self._container_id = container_id or owner.get_id()
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
super().__init__(parent, _id=_id)
self._owner = parent
self._container_id = container_id or parent.get_id()
self._on_resize = on_resize
self._commands = Commands(self)
self._state = BoundariesState()

View File

@@ -1,7 +1,7 @@
import bisect
import html
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
@@ -18,9 +18,7 @@ from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
DataGridRowSelectionColumnState
from myfasthtml.controls.datagrid_objects import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
@@ -83,6 +81,7 @@ class DatagridState(DbObject):
self.selection: DatagridSelectionState = DatagridSelectionState()
self.cell_formats: dict = {}
self.table_format: list = []
self.ns_visible_indices: list[int] | None = None
class DatagridSettings(DbObject):
@@ -156,7 +155,7 @@ class Commands(BaseCommands):
return Command("OnClick",
"Click on the table",
self._owner,
self._owner.on_click
self._owner.handle_on_click
).htmx(target=f"#tsm_{self._id}")
def on_key_pressed(self):
@@ -212,15 +211,40 @@ class Commands(BaseCommands):
self._owner,
self._owner.on_column_changed
)
def start_edition(self):
return Command("StartEdition",
"Enter cell edit mode",
self._owner,
self._owner.handle_start_edition
).htmx(target=f"#tsm_{self._id}")
def save_edition(self):
return Command("SaveEdition",
"Save cell edition",
self._owner,
self._owner.handle_save_edition
).htmx(target=f"#tsm_{self._id}",
trigger="blur, keydown[key=='Enter']")
class DataGrid(MultipleInstance):
_ARROW_KEY_DIRECTIONS = {
"arrowright": "right",
"arrowleft": "left",
"arrowdown": "down",
"arrowup": "up",
}
def __init__(self, parent, conf=None, save_state=None, _id=None):
super().__init__(parent, _id=_id)
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
self._state = DatagridState(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine()
self._formatting_provider = DatagridMetadataProvider(self._parent)
self._formatting_engine = FormattingEngine(
rule_presets_provider=lambda: self._formatting_provider.rule_presets
)
self._columns = None
self.commands = Commands(self)
@@ -248,7 +272,7 @@ class DataGrid(MultipleInstance):
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
# add Selection Selector
# add Selection Selector (cell, row, column)
selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"),
@@ -268,8 +292,7 @@ class DataGrid(MultipleInstance):
# self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting:
provider = DatagridMetadataProvider(self._parent)
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
completion_engine = FormattingCompletionEngine(self._formatting_provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,
@@ -291,10 +314,16 @@ class DataGrid(MultipleInstance):
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"dblclick": {"command": self.commands.start_edition(), "hx_vals": "js:getCellId()"},
}
self._key_support = {
"esc": {"command": self.commands.on_key_pressed(), "require_inside": True},
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
"enter": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowup": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowdown": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowleft": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowright": {"command": self.commands.on_key_pressed(), "require_inside": True},
}
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
@@ -371,6 +400,41 @@ class DataGrid(MultipleInstance):
else:
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
def _get_navigable_col_positions(self) -> list[int]:
return [i for i, col in enumerate(self._columns)
if col.visible and col.type != ColumnType.RowSelection_]
def _get_visible_row_indices(self) -> list[int]:
if self._state.ns_visible_indices is None:
if self._df is None:
self._state.ns_visible_indices = []
else:
self._state.ns_visible_indices = list(self._apply_filter(self._df).index)
return self._state.ns_visible_indices
def _navigate(self, pos: tuple, direction: str) -> tuple:
col_pos, row_index = pos
navigable_cols = self._get_navigable_col_positions()
visible_rows = self._get_visible_row_indices()
if not navigable_cols or not visible_rows:
return pos
if direction == "right":
next_cols = [c for c in navigable_cols if c > col_pos]
return (next_cols[0], row_index) if next_cols else pos
elif direction == "left":
prev_cols = [c for c in navigable_cols if c < col_pos]
return (prev_cols[-1], row_index) if prev_cols else pos
elif direction == "down":
next_pos = bisect.bisect_right(visible_rows, row_index)
return (col_pos, visible_rows[next_pos]) if next_pos < len(visible_rows) else pos
elif direction == "up":
prev_pos = bisect.bisect_left(visible_rows, row_index) - 1
return (col_pos, visible_rows[prev_pos]) if prev_pos >= 0 else pos
return pos
def _get_pos_from_element_id(self, element_id):
if element_id is None:
return None
@@ -381,9 +445,13 @@ class DataGrid(MultipleInstance):
return None
def _update_current_position(self, pos):
def _update_current_position(self, pos, reset_selection: bool = False):
self._state.selection.last_selected = self._state.selection.selected
self._state.selection.selected = pos
if reset_selection:
self._state.selection.extra_selected.clear()
self._state.edition.under_edition = None
self._state.save()
def _get_format_rules(self, col_pos, row_index, col_def):
@@ -449,6 +517,35 @@ class DataGrid(MultipleInstance):
if self._settings.enable_edition:
self._columns.insert(0, DataGridRowSelectionColumnState())
def _enter_edition(self, pos):
logger.debug(f"enter_edition: {pos=}")
col_pos, row_index = pos
col_def = self._columns[col_pos]
if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula):
return self.render_partial()
if col_def.type == ColumnType.Bool:
return self._toggle_bool_cell(col_pos, row_index, col_def)
self._state.edition.under_edition = pos
self._state.save()
return self.render_partial("cell", pos=pos)
def _toggle_bool_cell(self, col_pos, row_index, col_def):
col_array = self._fast_access.get(col_def.col_id)
current_value = col_array[row_index] if col_array is not None and row_index < len(col_array) else False
self._data_service.set_data(col_def.col_id, row_index, not bool(current_value))
return self.render_partial("cell", pos=(col_pos, row_index))
def _convert_edition_value(self, value_str, col_type):
if col_type == ColumnType.Number:
try:
return float(value_str) if '.' in value_str else int(value_str)
except (ValueError, TypeError):
return value_str
elif col_type == ColumnType.Bool:
return value_str.lower() in ('true', '1', 'yes')
else:
return value_str
def add_new_column(self, col_def: DataGridColumnState) -> None:
"""Add a new column, delegating data mutation to DataService.
@@ -567,16 +664,30 @@ class DataGrid(MultipleInstance):
def filter(self):
logger.debug("filter")
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
self._state.ns_visible_indices = None
return self.render_partial("body")
def on_click(self, combination, is_inside, cell_id):
def handle_on_click(self, combination, is_inside, cell_id):
logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
if is_inside and cell_id:
logger.debug(f" is_inside=True")
self._state.selection.extra_selected.clear()
if cell_id.startswith("tcell_"):
pos = self._get_pos_from_element_id(cell_id)
self._update_current_position(pos)
pos = self._get_pos_from_element_id(cell_id)
if (self._settings.enable_edition and
pos is not None and
pos == self._state.selection.selected and
self._state.edition.under_edition is None):
return self._enter_edition(pos)
else:
logger.debug(
f" {pos=}, selected={self._state.selection.selected}, under_edition={self._state.edition.under_edition}")
self._update_current_position(pos)
else:
logger.debug(f" is_inside=False")
return self.render_partial()
@@ -601,8 +712,22 @@ class DataGrid(MultipleInstance):
def on_key_pressed(self, combination, has_focus, is_inside):
logger.debug(f"on_key_pressed table={self.get_table_name()} {combination=} {has_focus=} {is_inside=}")
if combination == "esc":
self._update_current_position(None)
self._state.selection.extra_selected.clear()
self._update_current_position(None, reset_selection=True)
return self.render_partial("cell", pos=self._state.selection.last_selected)
elif (combination == "enter" and
self._settings.enable_edition and
self._state.selection.selected and
self._state.edition.under_edition is None):
return self._enter_edition(self._state.selection.selected)
elif combination in self._ARROW_KEY_DIRECTIONS:
current_pos = (self._state.selection.selected
or self._state.selection.last_selected
or (0, 0))
direction = self._ARROW_KEY_DIRECTIONS[combination]
new_pos = self._navigate(current_pos, direction)
self._update_current_position(new_pos)
return self.render_partial()
@@ -672,6 +797,30 @@ class DataGrid(MultipleInstance):
self._panel.set_title(side="right", title="Formatting")
self._panel.set_right(self._formatting_editor)
def handle_start_edition(self, cell_id):
logger.debug(f"handle_start_edition: {cell_id=}")
if not self._settings.enable_edition:
return self.render_partial()
if self._state.edition.under_edition is not None:
return self.render_partial()
pos = self._get_pos_from_element_id(cell_id)
if pos is None:
return self.render_partial()
self._update_current_position(pos)
return self._enter_edition(pos)
def handle_save_edition(self, value):
logger.debug(f"handle_save_edition: {value=}")
if self._state.edition.under_edition is None:
return self.render_partial()
col_pos, row_index = self._state.edition.under_edition
col_def = self._columns[col_pos]
typed_value = self._convert_edition_value(value, col_def.type)
self._data_service.set_data(col_def.col_id, row_index, typed_value)
self._state.edition.under_edition = None
self._state.save()
return self.render_partial("cell", pos=(col_pos, row_index))
def handle_set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}")
@@ -722,6 +871,31 @@ class DataGrid(MultipleInstance):
def get_data_service_id_from_data_grid_id(datagrid_id):
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1)
def _mk_edition_cell(self, col_pos, row_index, col_def: DataGridColumnState, is_last):
col_array = self._fast_access.get(col_def.col_id)
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
value_str = str(value) if not is_null(value) else ""
save_cmd = self.commands.save_edition()
input_elem = mk.mk(
Input(value=value_str, name="value", autofocus=True, cls="dt2-cell-input"),
command=save_cmd
)
return OptimizedDiv(
input_elem,
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
cls=merge_classes("dt2-cell dt2-cell-edition", "dt2-last-cell" if is_last else None),
style=f"width:{col_def.width}px;"
)
def _mk_cell_oob(self, col_pos, row_index):
col_def = self._columns[col_pos]
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
is_last = col_pos == len(self._columns) - 1
return self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last)
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
@@ -792,7 +966,7 @@ class DataGrid(MultipleInstance):
res.append(Span(value_str[:index], cls=f"{css_class}"))
res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1"))
if index + len_keyword < len(value_str):
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}"))
res.append(Span(value_str[index + len_keyword:]))
return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0]
@@ -865,6 +1039,10 @@ class DataGrid(MultipleInstance):
if col_def.type == ColumnType.RowSelection_:
return OptimizedDiv(cls="dt2-row-selection")
if (self._settings.enable_edition and
self._state.edition.under_edition == (col_pos, row_index)):
return self._mk_edition_cell(col_pos, row_index, col_def, is_last)
col_array = self._fast_access.get(col_def.col_id)
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
@@ -874,6 +1052,7 @@ class DataGrid(MultipleInstance):
data_tooltip=str(value),
style=f"width:{col_def.width}px;",
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
tabindex="-1",
cls=merge_classes("dt2-cell", "dt2-last-cell" if is_last else None))
def mk_row(self, row_index, filter_keyword_lower, len_columns_1):
@@ -989,25 +1168,19 @@ class DataGrid(MultipleInstance):
)
def mk_selection_manager(self):
extra_attr = {
"hx-on::after-settle": f"updateDatagridSelection('{self._id}');",
}
selected = []
if self._state.selection.selected:
# selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected)))
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
for extra_sel in self._state.selection.extra_selected:
selected.append(extra_sel)
return Div(
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
id=f"tsm_{self._id}",
selection_mode=f"{self._state.selection.selection_mode}",
**extra_attr,
)
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
@@ -1093,7 +1266,9 @@ class DataGrid(MultipleInstance):
),
id=self._id,
cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;"
style="height: 100%; grid-template-rows: auto 1fr;",
tabindex="-1",
**{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}') updateDatagridSelection('{self._id}');"}
)
def render_partial(self, fragment="cell", **kwargs):
@@ -1103,7 +1278,7 @@ class DataGrid(MultipleInstance):
:param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
:return:
"""
res = []
res = [self.mk_selection_manager()]
extra_attr = {
"hx-on::after-settle": f"initDataGrid('{self._id}');",
@@ -1131,7 +1306,21 @@ class DataGrid(MultipleInstance):
header.attrs.update(header_extra_attr)
return header
res.append(self.mk_selection_manager())
else:
col_pos, row_index = None, None
if (cell_id := kwargs.get("cell_id")) is not None:
col_pos, row_index = self._get_pos_from_element_id(cell_id)
elif (pos := kwargs.get("pos")) is not None:
col_pos, row_index = pos
if col_pos is not None and row_index is not None:
col_def = self._columns[col_pos]
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
is_last_col = col_pos == len(self._columns) - 1
cell = self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last_col)
res.append(cell)
return tuple(res)

View File

@@ -211,7 +211,7 @@ class DataGridsManager(SingleInstance):
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._start_rename(document.document_id)
self._tree.handle_start_rename(document.document_id)
return self._tree, self._tabs_manager.show_tab(tab_id)

View File

@@ -140,7 +140,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._query.bind_command("CancelQuery", self.commands.apply_filter())
# Add Menu
self._menu = Menu(self, conf=MenuConf(["ResetView"]))
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)}")

View File

@@ -1,13 +1,14 @@
from fastcore.basics import NotStr
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.constants import ColumnType, MediaActions
from myfasthtml.core.utils import pascal_to_snake
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular, pause_circle20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
dismiss_circle20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
default_icons = {
# default type icons
@@ -22,6 +23,12 @@ default_icons = {
"TreeViewFolder": folder20_regular,
"TreeViewFile": document20_regular,
# Media
MediaActions.Play: play_circle20_regular,
MediaActions.Pause: pause_circle20_regular,
MediaActions.Stop: record_stop20_regular,
MediaActions.Cancel: dismiss_circle20_regular,
# Datagrid column icons
ColumnType.RowIndex: number_symbol20_regular,
ColumnType.Text: text_field20_regular,

View File

@@ -4,7 +4,7 @@ from fasthtml.components import Div
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties
from myfasthtml.controls.Properties import Properties, PropertiesConf
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
@@ -73,10 +73,11 @@ class InstancesDebugger(SingleInstance):
"Commands": {"*": "commands"},
}
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
properties_def,
_id="-properties"))
return self._panel.set_right(Properties(
self,
conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
_id="-properties",
))
def _get_instance_kind(self, instance) -> str:
"""Determine the instance kind for visualization.

View File

@@ -1,9 +1,11 @@
import json
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.utils import make_html_id
class Keyboard(MultipleInstance):
@@ -16,18 +18,38 @@ class Keyboard(MultipleInstance):
def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}
def add(self, sequence: str, command: Command, require_inside: bool = True):
self.combinations[sequence] = {"command": command, "require_inside": require_inside}
def add(self, sequence: str, command: Command, require_inside: bool = True, enabled: bool = True):
self.combinations[sequence] = {"command": command, "require_inside": require_inside, "enabled": enabled}
return self
def render(self):
str_combinations = {}
control_children = []
for sequence, value in self.combinations.items():
params = value["command"].get_htmx_params()
params["require_inside"] = value.get("require_inside", True)
str_combinations[sequence] = params
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
control_children.append(
Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="true" if value.get("enabled", True) else "false")
)
script = Script(f"add_keyboard_support('{self._parent.get_id()}', '{self.get_id()}', '{json.dumps(str_combinations)}')")
control_div = Div(*control_children, id=self.get_id(), name="keyboard")
return script, control_div
def mk_enable(self, sequence: str):
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="true",
hx_swap_oob="true")
def mk_disable(self, sequence: str):
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="false",
hx_swap_oob="true")
def __ft__(self):
return self.render()

View File

@@ -36,7 +36,8 @@ class Mouse(MultipleInstance):
VALID_ACTIONS = {
'click', 'right_click', 'rclick',
'mousedown>mouseup', 'rmousedown>mouseup'
'mousedown>mouseup', 'rmousedown>mouseup',
'dblclick', 'double_click', 'dclick'
}
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
def __init__(self, parent, _id=None, combinations=None):

View File

@@ -104,7 +104,7 @@ class Panel(MultipleInstance):
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
def __init__(self, parent, conf: Optional[PanelConf] = None, _id="-panel"):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
hide_icon,
Div(content, id=self._ids.content(side)),
cls=panel_cls,
style=f"width: {self._state.left_width}px;",
style=f"width: {self._state.right_width}px;",
id=self._ids.panel(side)
)

View File

@@ -0,0 +1,423 @@
import logging
from fasthtml.components import Div, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Properties import Properties, PropertiesConf
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
from myfasthtml.icons.fluent import (
arrow_clockwise20_regular,
data_pie24_regular,
text_bullet_list_tree20_filled,
)
logger = logging.getLogger("Profiler")
# ---------------------------------------------------------------------------
# Span tree renderer — module-level, passed via PropertiesConf.types
# ---------------------------------------------------------------------------
def _mk_span_rows(span, depth: int, total_ms: float) -> list:
"""Recursively build span rows for the tree view.
Args:
span: A ProfilingSpan or CumulativeSpan to render.
depth: Current nesting depth (controls indentation).
total_ms: Reference duration used to compute bar widths.
Returns:
List of FT elements, one per span row (depth-first order).
"""
rows = []
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
if isinstance(span, CumulativeSpan):
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
duration_cls = _span_duration_cls(span.total_ms)
badge = Span(
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
cls="mf-profiler-cumulative-badge",
)
row = Div(
*indent,
Div(
Span(span.name, cls="mf-profiler-span-name"),
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
badge,
cls="mf-profiler-span-body",
),
cls="mf-profiler-span-row",
)
rows.append(row)
else:
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
duration_cls = _span_duration_cls(span.duration_ms)
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
row = Div(
*indent,
Div(
Span(span.name, cls=name_cls),
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
cls="mf-profiler-span-body",
),
cls="mf-profiler-span-row",
)
rows.append(row)
for child in span.children:
rows.extend(_mk_span_rows(child, depth + 1, total_ms))
return rows
def _span_duration_cls(duration_ms: float) -> str:
"""Return the CSS modifier class for a span duration."""
if duration_ms < 20:
return "mf-profiler-fast"
if duration_ms < 100:
return "mf-profiler-medium"
return "mf-profiler-slow"
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
Args:
span: The root span to render as a tree.
trace: The parent trace, used to compute proportional bar widths.
Returns:
A FT element containing the full span tree.
"""
rows = _mk_span_rows(span, 0, trace.total_duration_ms)
return Div(*rows, cls="mf-profiler-span-tree-content")
class Commands(BaseCommands):
def toggle_detail_view(self):
return Command(
"ProfilerToggleDetailView",
"Switch between tree and pie view",
self._owner,
self._owner.handle_toggle_detail_view,
).htmx(target=f"#{self._id}")
def toggle_enable(self):
return Command(
"ProfilerToggleEnable",
"Enable / Disable profiler",
self._owner,
self._owner.handle_toggle_enable,
).htmx(target=f"#{self._id}")
def clear_traces(self):
return Command(
"ProfilerClearTraces",
"Clear all recorded traces",
self._owner,
self._owner.handle_clear_traces,
icon=IconsHelper.get(MediaActions.Cancel),
).htmx(target=f"#{self._id}")
def refresh(self):
return Command(
"ProfilerRefresh",
"Refresh traces",
self._owner,
self._owner.handle_refresh,
icon=arrow_clockwise20_regular,
).htmx(target=f"#{self._id}")
def select_trace(self, trace_id: str):
return Command(
"ProfilerSelectTrace",
"Display trace details",
self._owner,
self._owner.handle_select_trace,
kwargs={"trace_id": trace_id},
).htmx(target=f"#tr_{trace_id}")
class Profiler(SingleInstance):
"""In-application profiler UI.
Displays all recorded traces in a scrollable list (left) and trace
details in a resizable panel (right). The toolbar provides enable /
disable toggle and clear actions via icon-only buttons.
Attributes:
commands: Commands exposed by this control.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
self._panel.set_side_visible("right", True)
self._selected_id: str | None = None
self._detail_view: str = "tree"
self.commands = Commands(self)
logger.debug(f"Profiler created with id={self._id}")
# ------------------------------------------------------------------
# Command handlers
# ------------------------------------------------------------------
def handle_toggle_enable(self):
"""Toggle profiler.enabled and re-render."""
profiler.enabled = not profiler.enabled
logger.debug(f"Profiler enabled set to {profiler.enabled}")
return self
def handle_clear_traces(self):
"""Clear the trace buffer and re-render."""
profiler.clear()
logger.debug("Profiler traces cleared from UI")
return self
def handle_select_trace(self, trace_id: str):
"""Select a trace row and re-render to show it highlighted."""
if self._selected_id is not None:
old_trace = next(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
else:
old_trace = None
self._selected_id = trace_id
trace = next(trace for trace in profiler.traces if trace.trace_id == trace_id)
return (self._mk_trace_item(trace),
self._mk_trace_item(old_trace),
self._panel.set_right(self._mk_right_panel(trace)))
def handle_toggle_detail_view(self):
"""Toggle detail panel between tree and pie view."""
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
logger.debug(f"Profiler detail view set to {self._detail_view}")
return self
def handle_refresh(self):
"""Refresh the trace list without changing selection."""
return self
# ------------------------------------------------------------------
# Private rendering helpers
# ------------------------------------------------------------------
def _duration_cls(self, duration_ms: float) -> str:
"""Return the CSS modifier class for a given duration."""
if duration_ms < 20:
return "mf-profiler-fast"
if duration_ms < 100:
return "mf-profiler-medium"
return "mf-profiler-slow"
def _mk_toolbar(self):
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
enable_icon = (
IconsHelper.get(MediaActions.Pause)
if profiler.enabled
else IconsHelper.get(MediaActions.Play)
)
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
overhead = (
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
f"Total: {profiler.total_overhead_ms:.3f} ms "
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
)
return Div(
mk.icon(
enable_icon,
command=self.commands.toggle_enable(),
tooltip=enable_tooltip,
),
mk.icon(
command=self.commands.clear_traces(),
tooltip="Clear traces",
cls="mf-profiler-btn-danger",
),
mk.icon(
command=self.commands.refresh(),
),
Span(overhead, cls="mf-profiler-overhead"),
cls="mf-profiler-toolbar",
id=f"tb_{self._id}",
)
def _mk_trace_item(self, trace: ProfilingTrace):
if trace is None:
return None
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
duration_cls = self._duration_cls(trace.total_duration_ms)
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
return mk.mk(
Div(
Div(
Span(trace.command_name, cls="mf-profiler-cmd"),
Span(trace.command_description, cls="mf-profiler-cmd-description"),
cls="mf-profiler-cmd-cell",
),
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
Span(ts, cls="mf-profiler-ts"),
cls=row_cls,
id=f"tr_{trace.trace_id}",
),
command=self.commands.select_trace(trace.trace_id),
)
def _mk_trace_list(self):
"""Build the trace list with one clickable row per recorded trace."""
traces = profiler.traces
if not traces:
return Div("No traces recorded.", cls="mf-profiler-empty")
rows = [self._mk_trace_item(trace) for trace in reversed(traces)]
return Div(
Div(
Span("Command", cls="mf-profiler-col-header"),
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
cls="mf-profiler-list-header",
),
Div(*rows, cls="mf-profiler-list-body"),
cls="mf-profiler-list",
)
def _mk_detail_placeholder(self):
"""Placeholder shown in the right panel before a trace is selected."""
return Div("Select a trace to view details.", cls="mf-profiler-empty")
def _mk_detail_header(self, trace: "ProfilingTrace"):
"""Build the detail panel header with title and tree/pie toggle.
Args:
trace: The selected trace.
Returns:
A FT element for the detail header.
"""
duration_cls = self._duration_cls(trace.total_duration_ms)
title = Div(
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
cls="mf-profiler-detail-title",
)
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
toggle = Div(
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree",
cls=tree_cls),
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)",
cls=pie_cls),
cls="mf-profiler-view-toggle",
)
return Div(title, toggle, cls="mf-profiler-detail-header")
def _mk_detail_body(self, trace: "ProfilingTrace"):
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
Args:
trace: The selected trace.
Returns:
A FT element for the detail body.
"""
from types import SimpleNamespace
meta_props = Properties(
self,
conf=PropertiesConf(
obj=trace,
groups={"Metadata": {
"command": "command_name",
"description": "command_description",
"duration_ms": "total_duration_ms",
"timestamp": "timestamp",
}},
),
_id="-detail-meta",
)
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
kwargs_props = Properties(
self,
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
_id="-detail-kwargs",
)
span_props = None
if trace.root_span is not None:
span_props = Properties(
self,
conf=PropertiesConf(
obj=trace,
groups={"Span breakdown": {"root_span": "root_span"}},
types={ProfilingSpan: _span_tree_renderer},
),
_id="-detail-spans",
)
if self._detail_view == "pie":
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
def _mk_detail_panel(self, trace: "ProfilingTrace"):
"""Build the full detail panel for a selected trace.
Args:
trace: The selected trace.
Returns:
A FT element for the detail panel.
"""
return Div(
self._mk_detail_header(trace),
self._mk_detail_body(trace),
cls="mf-profiler-detail",
)
def _mk_right_panel(self, trace: "ProfilingTrace"):
"""Build the right panel with a trace detail view."""
return (
self._mk_detail_panel(trace)
if trace is not None
else self._mk_detail_placeholder()
)
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self):
selected_trace = None
if self._selected_id is not None:
selected_trace = next(
(t for t in profiler.traces if t.trace_id == self._selected_id), None
)
self._panel.set_main(self._mk_trace_list())
self._panel.set_right(self._mk_right_panel(selected_trace))
return Div(
self._mk_toolbar(),
self._panel,
id=self._id,
cls="mf-profiler",
)
def __ft__(self):
return self.render()

View File

@@ -1,21 +1,42 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from fasthtml.components import Div
from myutils.ProxyObject import ProxyObject
from myfasthtml.core.instances import MultipleInstance
@dataclass
class PropertiesConf:
"""Declarative configuration for the Properties control.
Attributes:
obj: The Python object whose attributes are displayed.
groups: Mapping of group name to ProxyObject spec.
types: Mapping of Python type to renderer callable.
Each renderer has the signature ``(value, obj) -> FT``.
"""
obj: Any = None
groups: Optional[dict] = None
types: Optional[dict] = field(default=None)
class Properties(MultipleInstance):
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
def __init__(self, parent, conf: PropertiesConf = None, _id=None):
super().__init__(parent, _id=_id)
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
def set_obj(self, obj, groups: dict = None):
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
self.conf = conf or PropertiesConf()
self._refresh()
def set_conf(self, conf: PropertiesConf):
self.conf = conf
self._refresh()
def _refresh(self):
self._types = self.conf.types or {}
self._properties_by_group = self._create_properties_by_group()
def _mk_group_content(self, properties: dict):
return Div(
*[
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
],
cls="mf-properties-group-content"
)
def _mk_property_value(self, value):
for t, renderer in self._types.items():
if isinstance(value, t):
return renderer(value, self.conf.obj)
if isinstance(value, dict):
return self._mk_group_content(value)
if isinstance(value, (list, tuple)):
return self._mk_group_content({i: item for i, item in enumerate(value)})
return Div(str(value),
cls="mf-properties-value",
title=str(value))
def _render_group_content(self, proxy) -> Div:
"""Render a group's content.
When the group contains exactly one property whose type is registered in
``conf.types``, the type renderer replaces the entire group content (not
just the value cell). This lets custom renderers (e.g. span trees) fill
the full card width without a key/value row wrapper.
Otherwise, the standard key/value row layout is used.
Args:
proxy: ProxyObject for this group.
Returns:
A FT element containing the group content.
"""
properties = proxy.as_dict()
if len(properties) == 1:
k, v = next(iter(properties.items()))
for t, renderer in self._types.items():
if isinstance(v, t):
return renderer(v, self.conf.obj)
return self._mk_group_content(properties)
def render(self):
return Div(
*[
Div(
Div(
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
self._mk_group_content(proxy.as_dict()),
self._render_group_content(proxy),
cls="mf-properties-group-container"
),
cls="mf-properties-group-card"
)
for group_name, proxy in self.properties_by_group.items()
for group_name, proxy in self._properties_by_group.items()
],
id=self._id,
cls="mf-properties"
)
def _create_properties_by_group(self):
if self.groups is None:
return {None: ProxyObject(self.obj, {"*": ""})}
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
if self.conf.groups is None:
return {None: ProxyObject(self.conf.obj, {"*": ""})}
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
def __ft__(self):
return self.render()

View File

@@ -9,7 +9,7 @@ import uuid
from dataclasses import dataclass, field
from typing import Optional
from fasthtml.components import Div, Input, Span
from fasthtml.components import Div, Input
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper
@@ -115,7 +115,7 @@ class Commands(BaseCommands):
return Command("StartRename",
f"Start renaming {node_id}",
self._owner,
self._owner._start_rename,
self._owner.handle_start_rename,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-StartRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -125,7 +125,7 @@ class Commands(BaseCommands):
return Command("SaveRename",
f"Save rename for {node_id}",
self._owner,
self._owner._save_rename,
self._owner.handle_save_rename,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -135,7 +135,7 @@ class Commands(BaseCommands):
return Command("CancelRename",
"Cancel rename",
self._owner,
self._owner._cancel_rename,
self._owner.handle_cancel_rename,
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -144,7 +144,7 @@ class Commands(BaseCommands):
return Command("DeleteNode",
f"Delete node {node_id}",
self._owner,
self._owner._delete_node,
self._owner.handle_delete_node,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
).htmx(target=f"#{self._owner.get_id()}")
@@ -154,7 +154,7 @@ class Commands(BaseCommands):
return Command("SelectNode",
f"Select node {node_id}",
self._owner,
self._owner._select_node,
self._owner.handle_select_node,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
).htmx(target=f"#{self._owner.get_id()}")
@@ -185,6 +185,10 @@ class TreeView(MultipleInstance):
self._state = TreeViewState(self)
self.conf = conf or TreeViewConf()
self.commands = Commands(self)
self._keyboard = Keyboard(self, {"esc":
{"command": self.commands.cancel_rename(),
"require_inside": False}},
_id="-keyboard"),
if items:
self._state.items = items
@@ -293,7 +297,7 @@ class TreeView(MultipleInstance):
return self._state.items[node_id].bag
except KeyError:
return None
def get_state(self) -> TreeViewState:
return self._state
@@ -346,7 +350,7 @@ class TreeView(MultipleInstance):
return self
def _start_rename(self, node_id: str):
def handle_start_rename(self, node_id: str):
"""Start renaming a node (sets editing state and selection)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -355,7 +359,7 @@ class TreeView(MultipleInstance):
self._state.editing = node_id
return self
def _save_rename(self, node_id: str, node_label: str):
def handle_save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label."""
logger.debug(f"_save_rename {node_id=}, {node_label=}")
if node_id not in self._state.items:
@@ -365,13 +369,13 @@ class TreeView(MultipleInstance):
self._state.editing = None
return self
def _cancel_rename(self):
def handle_cancel_rename(self):
"""Cancel renaming operation."""
logger.debug("_cancel_rename")
self._state.editing = None
return self
def _delete_node(self, node_id: str):
def handle_delete_node(self, node_id: str):
"""Delete a node (only if it has no children)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -397,7 +401,7 @@ class TreeView(MultipleInstance):
return self
def _select_node(self, node_id: str):
def handle_select_node(self, node_id: str):
"""Select a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -511,7 +515,7 @@ class TreeView(MultipleInstance):
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": {"command": self.commands.cancel_rename(), "require_inside": False}}, _id="-keyboard"),
self._keyboard,
id=self._id,
cls="mf-treeview"
)

View File

@@ -174,7 +174,7 @@ class Command:
# Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]:
for index, r in enumerate(all_ret[1:]):
if hasattr(r, "__ft__"):
if not hasattr(r, 'attrs') and hasattr(r, "__ft__"):
r = r.__ft__()
all_ret[index + 1] = r
if (hasattr(r, 'attrs')

View File

@@ -2,6 +2,8 @@ from enum import Enum
NO_DEFAULT_VALUE = object()
PROFILER_MAX_TRACES = 500
ROUTE_ROOT = "/myfasthtml"
# Datagrid
@@ -31,6 +33,13 @@ class ColumnType(Enum):
Formula = "Formula"
class MediaActions(Enum):
Play = "Play"
Pause = "Pause"
Stop = "Stop"
Cancel = "Cancel"
def get_columns_types() -> list[ColumnType]:
return [c for c in ColumnType if not c.value.endswith("_")]

View File

@@ -15,12 +15,12 @@ from myfasthtml.core.formatting.dataclasses import RulePreset
from myfasthtml.core.formatting.presets import (
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
)
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.instances import UniqueInstance, InstancesManager
logger = logging.getLogger(__name__)
class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
class DatagridMetadataProvider(UniqueInstance, BaseMetadataProvider):
"""Concrete session-scoped metadata provider for DataGrid DSL engines.
Implements BaseMetadataProvider by delegating live data queries to
@@ -36,8 +36,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
all_tables_formats: Global format rules applied to all tables.
"""
def __init__(self, parent=None, session: Optional[dict] = None,
_id: Optional[str] = None):
def __init__(self, parent=None, session: Optional[dict] = None, _id: Optional[str] = None):
super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()

View File

@@ -31,7 +31,8 @@ class FormattingEngine:
style_presets: dict = None,
formatter_presets: dict = None,
rule_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
lookup_resolver: Callable[[str, str, str], dict] = None,
rule_presets_provider: Callable[[], dict] = None,
):
"""
Initialize the FormattingEngine.
@@ -41,11 +42,20 @@ class FormattingEngine:
formatter_presets: Custom formatter presets. If None, uses defaults.
rule_presets: Named rule presets (list of FormatRule dicts). If None, uses defaults.
lookup_resolver: Function for resolving enum datagrid sources.
rule_presets_provider: Callable returning the current rule_presets dict.
When provided, takes precedence over rule_presets on every apply_format call.
Use this to keep the engine in sync with a shared provider.
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
self._rule_presets_provider = rule_presets_provider
def _get_rule_presets(self) -> dict:
if self._rule_presets_provider is not None:
return self._rule_presets_provider()
return self._rule_presets
def apply_format(
self,
@@ -99,8 +109,8 @@ class FormattingEngine:
"""
Replace any FormatRule that references a rule preset with the preset's rules.
A rule is a rule preset reference when its formatter has a preset name
that exists in rule_presets (and not in formatter_presets).
A rule is a rule preset reference when its formatter or style has a preset name
that exists in rule_presets.
Args:
rules: Original list of FormatRule
@@ -108,22 +118,26 @@ class FormattingEngine:
Returns:
Expanded list with preset references replaced by their FormatRules
"""
rule_presets = self._get_rule_presets()
expanded = []
for rule in rules:
preset_name = self._get_rule_preset_name(rule)
preset_name = self._get_rule_preset_name(rule, rule_presets)
if preset_name:
expanded.extend(self._rule_presets[preset_name].rules)
expanded.extend(rule_presets[preset_name].rules)
else:
expanded.append(rule)
return expanded
def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
"""Return the preset name if the rule's formatter references a rule preset, else None."""
if rule.formatter is None:
return None
preset = getattr(rule.formatter, "preset", None)
if preset and preset in self._rule_presets:
return preset
def _get_rule_preset_name(self, rule: FormatRule, rule_presets: dict) -> str | None:
"""Return the preset name if the rule references a rule preset via format() or style(), else None."""
if rule.formatter is not None:
preset = getattr(rule.formatter, "preset", None)
if preset and preset in rule_presets:
return preset
if rule.style is not None:
preset = getattr(rule.style, "preset", None)
if preset and preset in rule_presets:
return preset
return None
def _get_matching_rules(

View File

@@ -1,3 +1,4 @@
import inspect
import logging
import uuid
from typing import Optional, Literal
@@ -5,7 +6,7 @@ from typing import Optional, Literal
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BoundCommand, Command
from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal, debug_session
VERBOSE_VERBOSE = False
@@ -32,11 +33,23 @@ class BaseInstance:
if VERBOSE_VERBOSE:
logger.debug(f"Creating new instance of type {cls.__name__}")
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
sig = inspect.signature(cls.__init__)
bound = sig.bind_partial(None, *args, **kwargs) # None pour 'self'
bound.apply_defaults()
arguments = bound.arguments
parent = arguments.get("parent", None)
session = arguments.get("session", None)
_id = arguments.get("_id", None)
if VERBOSE_VERBOSE:
logger.debug(f" parent={parent}, session={session}, _id={_id}")
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
# for UniqueInstance, the parent is always the ultimate root parent
if issubclass(cls, UniqueInstance):
parent = BaseInstance.get_ultimate_root_parent(parent)
if VERBOSE_VERBOSE:
logger.debug(f" UniqueInstance detected. parent is set to ultimate root {parent=}")
# Compute _id
_id = cls.compute_id(_id, parent)
@@ -163,7 +176,7 @@ class BaseInstance:
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
if _id is None:
prefix = cls.compute_prefix()
if issubclass(cls, SingleInstance):
if issubclass(cls, (SingleInstance, UniqueInstance)):
_id = prefix
else:
_id = f"{prefix}-{str(uuid.uuid4())}"
@@ -173,6 +186,17 @@ class BaseInstance:
return f"{parent.get_id()}{_id}"
return _id
@staticmethod
def get_ultimate_root_parent(instance):
if instance is None:
return None
parent = instance
while True:
if parent.get_parent() is None:
return parent
parent = parent.get_parent()
class SingleInstance(BaseInstance):
@@ -200,7 +224,7 @@ class UniqueInstance(BaseInstance):
_id: Optional[str] = None,
auto_register: bool = True,
on_init=None):
super().__init__(parent, session, _id, auto_register)
super().__init__(BaseInstance.get_ultimate_root_parent(parent), session, _id, auto_register)
if on_init is not None:
on_init()
@@ -230,7 +254,7 @@ class InstancesManager:
"""
key = (InstancesManager.get_session_id(session), instance.get_id())
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
if key in InstancesManager.instances and not isinstance(instance, UniqueInstance):
raise DuplicateInstanceError(instance)
InstancesManager.instances[key] = instance

View File

@@ -0,0 +1,560 @@
import functools
import inspect
import logging
import sys
import time
from collections import deque
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from uuid import uuid4
logger = logging.getLogger("Profiler")
# ---------------------------------------------------------------------------
# No-op sentinel — used when profiler is disabled
# ---------------------------------------------------------------------------
class _NullSpan:
"""No-op span returned when profiler is disabled."""
def set(self, key: str, value) -> '_NullSpan':
return self
def __enter__(self) -> '_NullSpan':
return self
def __exit__(self, *args):
pass
def __call__(self, fn):
return fn
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class CumulativeSpan:
"""Aggregated span for loops — one entry regardless of iteration count.
Attributes:
name: Span name.
count: Number of iterations recorded.
total_ms: Cumulative duration in milliseconds.
min_ms: Shortest recorded iteration in milliseconds.
max_ms: Longest recorded iteration in milliseconds.
"""
name: str
count: int = 0
total_ms: float = 0.0
min_ms: float = float('inf')
max_ms: float = 0.0
@property
def avg_ms(self) -> float:
"""Average duration per iteration in milliseconds."""
return self.total_ms / self.count if self.count > 0 else 0.0
def record(self, duration_ms: float):
"""Record one iteration.
Args:
duration_ms: Duration of this iteration in milliseconds.
"""
self.count += 1
self.total_ms += duration_ms
if duration_ms < self.min_ms:
self.min_ms = duration_ms
if duration_ms > self.max_ms:
self.max_ms = duration_ms
@dataclass
class ProfilingSpan:
"""A named timing segment.
Attributes:
name: Span name.
data: Arbitrary metadata attached via span.set().
children: Nested spans and cumulative spans.
duration_ms: Duration of this span in milliseconds (set on finish).
"""
name: str
data: dict = field(default_factory=dict)
children: list = field(default_factory=list)
_start: float = field(default_factory=time.perf_counter, repr=False)
duration_ms: float = field(default=0.0)
def set(self, key: str, value) -> 'ProfilingSpan':
"""Attach metadata to this span.
Args:
key: Metadata key.
value: Metadata value.
Returns:
Self, for chaining.
"""
self.data[key] = value
return self
def finish(self):
"""Stop timing and record the duration."""
self.duration_ms = (time.perf_counter() - self._start) * 1000
@dataclass
class ProfilingTrace:
"""One complete command execution, from route handler to response.
Attributes:
command_name: Name of the executed command.
command_description: Human-readable description of the command.
command_id: UUID of the command.
kwargs: Arguments received from the client.
timestamp: When the command was received.
root_span: Top-level span wrapping the full execution.
total_duration_ms: Total server-side duration in milliseconds.
"""
command_name: str
command_description: str
command_id: str
kwargs: dict
timestamp: datetime
root_span: Optional[ProfilingSpan] = None
total_duration_ms: float = 0.0
trace_id: str = field(default_factory=lambda: str(uuid4()))
# ---------------------------------------------------------------------------
# Context managers / decorators
# ---------------------------------------------------------------------------
class _ActiveSpan:
"""Context manager and decorator for a single named span.
When used as a context manager, returns the ProfilingSpan so callers can
attach metadata via ``span.set()``. When used as a decorator, captures
function arguments automatically.
"""
def __init__(self, manager: 'ProfilingManager', name: str, args: dict = None):
self._manager = manager
self._name = name
self._args = args
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
if not self._manager.enabled:
return _NullSpan()
overhead_start = time.perf_counter()
self._span = ProfilingSpan(name=self._name)
if self._args:
self._span.data.update(self._args)
self._token = self._manager.push_span(self._span)
self._manager.record_overhead(time.perf_counter() - overhead_start)
return self._span
def __exit__(self, *args):
if self._span is not None:
overhead_start = time.perf_counter()
self._manager.pop_span(self._span, self._token)
self._manager.record_overhead(time.perf_counter() - overhead_start)
def __call__(self, fn):
"""Use as decorator — enabled check deferred to call time."""
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
captured = manager.capture_args(fn, args, kwargs)
with _ActiveSpan(manager, name, captured):
return fn(*args, **kwargs)
return wrapper
class _CumulativeActiveSpan:
"""Context manager and decorator for cumulative spans.
Finds or creates a CumulativeSpan in the current parent and records
each iteration without adding a new child entry.
"""
def __init__(self, manager: 'ProfilingManager', name: str):
self._manager = manager
self._name = name
self._cumulative_span: Optional[CumulativeSpan] = None
self._iter_start: float = 0.0
def _get_or_create(self) -> CumulativeSpan:
parent = self._manager.current_span()
if isinstance(parent, ProfilingSpan):
for child in parent.children:
if isinstance(child, CumulativeSpan) and child.name == self._name:
return child
cumulative_span = CumulativeSpan(name=self._name)
parent.children.append(cumulative_span)
return cumulative_span
return CumulativeSpan(name=self._name)
def __enter__(self) -> CumulativeSpan:
if not self._manager.enabled:
return _NullSpan()
self._cumulative_span = self._get_or_create()
self._iter_start = time.perf_counter()
return self._cumulative_span
def __exit__(self, *args):
if self._cumulative_span is not None:
duration_ms = (time.perf_counter() - self._iter_start) * 1000
self._cumulative_span.record(duration_ms)
def __call__(self, fn):
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with _CumulativeActiveSpan(manager, name):
return fn(*args, **kwargs)
return wrapper
class _CommandSpan:
"""Context manager that creates both a ProfilingTrace and its root span.
Used exclusively by the route handler to wrap the full command execution.
"""
def __init__(self,
manager: 'ProfilingManager',
command_name: str,
command_description: str,
command_id: str,
kwargs: dict):
self._manager = manager
self._command_name = command_name
self._command_description = command_description
self._command_id = command_id
self._kwargs = kwargs
self._trace: Optional[ProfilingTrace] = None
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
self._trace = ProfilingTrace(
command_name=self._command_name,
command_description=self._command_description,
command_id=self._command_id,
kwargs=dict(self._kwargs) if self._kwargs else {},
timestamp=datetime.now(),
)
self._span = ProfilingSpan(name=self._command_name)
self._token = self._manager.push_span(self._span)
return self._span
def __exit__(self, *args):
self._manager.pop_span(self._span, self._token)
self._trace.root_span = self._span
self._trace.total_duration_ms = self._span.duration_ms
self._manager.add_trace(self._trace)
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class ProfilingManager:
"""Global in-memory profiling manager.
All probe mechanisms check ``enabled`` at call time, so the profiler can
be toggled without restarting the server. Use the module-level ``profiler``
singleton rather than instantiating this class directly.
"""
def __init__(self, max_traces: int = None):
from myfasthtml.core.constants import PROFILER_MAX_TRACES
self.enabled: bool = False
self._traces: deque = deque(maxlen=max_traces or PROFILER_MAX_TRACES)
self._current_span: ContextVar[Optional[ProfilingSpan]] = ContextVar(
'profiler_current_span', default=None
)
self._overhead_samples: list = []
# --- Span lifecycle ---
def push_span(self, span: ProfilingSpan) -> object:
"""Register a span as the current context and attach it to the parent.
Args:
span: The span to activate.
Returns:
A reset token to pass to pop_span().
"""
parent = self._current_span.get()
if isinstance(parent, ProfilingSpan):
parent.children.append(span)
return self._current_span.set(span)
def pop_span(self, span: ProfilingSpan, token: object) -> None:
"""Finish a span and restore the previous context.
Args:
span: The span to finish.
token: The reset token returned by push_span().
"""
span.finish()
self._current_span.reset(token)
def add_trace(self, trace: ProfilingTrace) -> None:
"""Add a completed trace to the buffer.
Args:
trace: The trace to store.
"""
self._traces.appendleft(trace)
def record_overhead(self, duration_s: float) -> None:
"""Record a span boundary overhead sample.
Args:
duration_s: Duration in seconds of the profiler's own housekeeping.
"""
self._overhead_samples.append(duration_s * 1e6)
if len(self._overhead_samples) > 1000:
self._overhead_samples = self._overhead_samples[-1000:]
@staticmethod
def capture_args(fn, args, kwargs) -> dict:
"""Capture function arguments as a truncated string dict.
Args:
fn: The function whose signature is inspected.
args: Positional arguments passed to the function.
kwargs: Keyword arguments passed to the function.
Returns:
Dict of parameter names to string-truncated values.
"""
try:
sig = inspect.signature(fn)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
params = dict(bound.arguments)
params.pop('self', None)
params.pop('cls', None)
return {k: str(v)[:200] for k, v in params.items()}
except Exception:
return {}
# --- Public interface ---
@property
def traces(self) -> list[ProfilingTrace]:
"""All recorded traces, most recent first."""
return list(self._traces)
def current_span(self) -> Optional[ProfilingSpan]:
"""Return the active span in the current async context.
Can be called from anywhere within a span to attach metadata::
profiler.current_span().set("row_count", len(rows))
"""
return self._current_span.get()
def clear(self):
"""Empty the trace buffer."""
self._traces.clear()
logger.debug("Profiler traces cleared.")
# --- Probe mechanisms ---
def span(self, name: str, args: dict = None) -> _ActiveSpan:
"""Context manager and decorator for a named span.
The enabled check is deferred to call/enter time, so this can be used
as a static decorator without concern for startup order.
Args:
name: Span name.
args: Optional metadata to attach immediately.
Returns:
An object usable as a context manager or decorator.
"""
return _ActiveSpan(self, name, args)
def cumulative_span(self, name: str) -> _CumulativeActiveSpan:
"""Context manager and decorator for loop spans.
Aggregates all iterations into a single entry (count, total, min, max, avg).
Args:
name: Span name.
Returns:
An object usable as a context manager or decorator.
"""
return _CumulativeActiveSpan(self, name)
def command_span(self,
command_name: str,
command_description: str,
command_id: str,
kwargs: dict) -> '_CommandSpan | _NullSpan':
"""Context manager for the route handler.
Creates a ProfilingTrace and its root span together. When the context
exits, the trace is added to the buffer.
Args:
command_name: Human-readable command name.
command_description: Human-readable description of the command.
command_id: UUID string of the command.
kwargs: Client arguments received by the route handler.
Returns:
An object usable as a context manager.
"""
if not self.enabled:
return _NullSpan()
return _CommandSpan(self, command_name, command_description, command_id, kwargs)
def trace_all(self, cls=None, *, exclude: list = None):
"""Class decorator — statically wraps all non-dunder methods with spans.
Wrapping happens at class definition time; the enabled check is deferred
to call time via _ActiveSpan.__call__.
Args:
cls: The class to instrument (when used without parentheses).
exclude: List of method names to skip.
Usage::
@profiler.trace_all
class MyClass: ...
@profiler.trace_all(exclude=["render"])
class MyClass: ...
"""
_exclude = set(exclude or [])
def decorator(klass):
for attr_name, method in inspect.getmembers(klass, predicate=inspect.isfunction):
if attr_name in _exclude:
continue
if attr_name.startswith('__') and attr_name.endswith('__'):
continue
setattr(klass, attr_name, _ActiveSpan(self, attr_name)(method))
return klass
if cls is not None:
return decorator(cls)
return decorator
def trace_calls(self, fn):
"""Function decorator — traces all sub-calls via sys.setprofile().
Use for exploration when the bottleneck location is unknown.
sys.setprofile() is scoped to this function's execution only;
the global profiler is restored on exit.
The root span for ``fn`` itself is created before setprofile is
activated so that profiler internals are not captured as children.
Args:
fn: The function to instrument.
"""
manager = self
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
call_stack: list[tuple[ProfilingSpan, object]] = []
# Skip the first call event (fn itself — already represented by root_span)
skip_first = [True]
def _profile(frame, event, arg):
if event == 'call':
if skip_first[0]:
skip_first[0] = False
return
span = ProfilingSpan(name=frame.f_code.co_name)
token = manager.push_span(span)
call_stack.append((span, token))
elif event in ('return', 'exception'):
if call_stack:
span, token = call_stack.pop()
manager.pop_span(span, token)
# Build root span BEFORE activating setprofile so that profiler
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
# captured as children.
captured = manager.capture_args(fn, args, kwargs)
root_span = ProfilingSpan(name=fn.__name__)
root_span.data.update(captured)
root_token = manager.push_span(root_span)
old_profile = sys.getprofile()
sys.setprofile(_profile)
try:
result = fn(*args, **kwargs)
finally:
sys.setprofile(old_profile)
manager.pop_span(root_span, root_token)
return result
return wrapper
# --- Overhead measurement ---
@property
def overhead_per_span_us(self) -> float:
"""Average overhead per span boundary in microseconds."""
if not self._overhead_samples:
return 0.0
return sum(self._overhead_samples) / len(self._overhead_samples)
@property
def total_overhead_ms(self) -> float:
"""Estimated total overhead across all recorded traces."""
total_spans = sum(
self._count_spans(t.root_span) for t in self._traces if t.root_span
)
return (total_spans * self.overhead_per_span_us * 2) / 1000
def _count_spans(self, span: ProfilingSpan) -> int:
if span is None:
return 0
count = 1
for child in span.children:
if isinstance(child, ProfilingSpan):
count += self._count_spans(child)
return count
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
profiler = ProfilingManager()

View File

@@ -11,6 +11,7 @@ from rich.table import Table
from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.profiler import profiler
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
@@ -379,8 +380,9 @@ def post(session, c_id: str, client_response: dict = None):
command = CommandsManager.get_command(c_id)
if command:
logger.debug(f"Executing command {command.name}.")
return command.execute(client_response)
with profiler.command_span(command.name, command.description, c_id, client_response or {}):
return command.execute(client_response)
raise ValueError(f"Command with ID '{c_id}' not found.")

View File

@@ -72,6 +72,12 @@ class EndsWith(AttrPredicate):
class Contains(AttrPredicate):
def __init__(self, *value, _word=False):
"""
Initializes the instance with the given value and optional private attribute `_word`.
:param value:
:param _word: Matches the entire word if True, otherwise matches any substring.
"""
super().__init__(value)
self._word = _word

View File

@@ -0,0 +1,163 @@
"""Unit tests for the Keyboard control."""
import pytest
from fasthtml.components import Div
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import make_html_id
from myfasthtml.test.matcher import matches, find, find_one, AnyValue, TestScript
from .conftest import root_instance
@pytest.fixture
def cmd():
return Command("test_keyboard_cmd", "Test keyboard command", None, lambda: None)
class TestKeyboardBehaviour:
"""Tests for Keyboard behavior and logic."""
def test_i_can_add_combination_with_default_enabled(self, root_instance, cmd):
"""Test that enabled defaults to True when not specified in add().
Why this matters:
- All combinations should be active by default without requiring explicit opt-in.
"""
kb = Keyboard(root_instance)
kb.add("esc", cmd)
assert kb.combinations["esc"]["enabled"] is True
def test_i_can_add_combination_with_enabled_false(self, root_instance, cmd):
"""Test that enabled=False is correctly stored in the combination definition.
Why this matters:
- Combinations can be declared inactive at init time, which controls the
initial data-enabled value in the rendered DOM.
"""
kb = Keyboard(root_instance)
kb.add("esc", cmd, enabled=False)
assert kb.combinations["esc"]["enabled"] is False
class TestKeyboardRender:
"""Tests for Keyboard HTML rendering."""
@pytest.fixture
def keyboard(self, root_instance):
return Keyboard(root_instance)
def test_keyboard_layout_is_rendered(self, keyboard, cmd):
"""Test that render() returns a tuple of (Script, Div).
Why these elements matter:
- tuple length 2: render() must produce exactly a script and a control div
- script tag: the JavaScript call that registers keyboard shortcuts
- div tag: the DOM control div used by JS to check enabled state at runtime
"""
keyboard.add("esc", cmd)
result = keyboard.render()
assert len(result) == 2
script, control_div = result
assert script.tag == "script"
assert control_div.tag == "div"
def test_i_can_render_script_with_control_div_id(self, keyboard, cmd):
"""Test that the rendered script includes controlDivId as the second argument.
Why this matters:
- The JS function add_keyboard_support() now requires 3 args: elementId,
controlDivId, combinationsJson. The controlDivId links the keyboard
registry entry to its DOM control div for enabled-state lookups.
"""
keyboard.add("esc", cmd)
script, _ = keyboard.render()
expected_prefix = (
f"add_keyboard_support('{keyboard._parent.get_id()}', '{keyboard.get_id()}', "
)
assert matches(script, TestScript(expected_prefix))
def test_i_can_render_control_div_attributes(self, keyboard, cmd):
"""Test that the control div has the correct id and name attributes.
Why these attributes matter:
- id=keyboard.get_id(): the JS uses this ID to look up enabled state
- name="keyboard": semantic marker for readability and DOM inspection
"""
keyboard.add("esc", cmd)
_, control_div = keyboard.render()
expected = Div(id=keyboard.get_id(), name="keyboard")
assert matches(control_div, expected)
def test_i_can_render_one_child_per_combination(self, keyboard, cmd):
"""Test that the control div contains exactly one child div per combination.
Why this matters:
- Each combination needs its own div so the JS can check its enabled
state independently via data-combination attribute lookup.
"""
keyboard.add("esc", cmd)
keyboard.add("ctrl+s", cmd)
_, control_div = keyboard.render()
children = find(control_div, Div(data_combination=AnyValue()))
assert len(children) == 2, "Should have one child div per registered combination"
@pytest.mark.parametrize("enabled, expected_value", [
(True, "true"),
(False, "false"),
])
def test_i_can_render_combination_enabled_state(self, keyboard, cmd, enabled, expected_value):
"""Test that data-enabled reflects the enabled flag passed to add().
Why this matters:
- The JS reads data-enabled at keypress time to decide whether to
trigger the combination. The rendered value must match the Python flag.
"""
keyboard.add("esc", cmd, enabled=enabled)
_, control_div = keyboard.render()
child = find_one(control_div, Div(data_combination="esc"))
assert matches(child, Div(data_enabled=expected_value))
def test_i_can_render_child_id_sanitizes_combination(self, keyboard, cmd):
"""Test that the child div id is derived from make_html_id on the combination string.
Why this matters:
- Combination strings like 'ctrl+s' contain characters invalid in HTML IDs.
make_html_id() sanitizes them ('+''-'), enabling targeted OOB swaps
via mk_enable/mk_disable without referencing the full control div.
"""
keyboard.add("ctrl+s", cmd)
_, control_div = keyboard.render()
expected_id = f"{keyboard.get_id()}-{make_html_id('ctrl+s')}"
children = find(control_div, Div(id=expected_id))
assert len(children) == 1, f"Expected exactly one child with id '{expected_id}'"
@pytest.mark.parametrize("method_name, expected_enabled", [
("mk_enable", "true"),
("mk_disable", "false"),
])
def test_i_can_mk_enable_and_disable(self, keyboard, method_name, expected_enabled):
"""Test that mk_enable/mk_disable return a correct OOB swap div.
Why these attributes matter:
- id: must match the child div id so HTMX replaces the right element
- data-combination: allows the JS to identify the combination
- data-enabled: the new state to apply
- hx-swap-oob: triggers the out-of-band swap without a full page update
"""
result = getattr(keyboard, method_name)("esc")
expected = Div(
id=f"{keyboard.get_id()}-{make_html_id('esc')}",
data_combination="esc",
data_enabled=expected_enabled,
hx_swap_oob="true"
)
assert matches(result, expected)

View File

@@ -79,14 +79,6 @@ def datagrid_with_full_data(datagrids_manager):
return dg
@pytest.fixture
def datagrid_no_edition(datagrid_with_data):
"""DataGrid with edition disabled (no RowSelection column, no add-column button)."""
dg = datagrid_with_data
dg._settings.enable_edition = False
dg._init_columns()
return dg
class TestDataGridBehaviour:
def test_i_can_create_empty_datagrid(self, datagrids_manager):
@@ -150,24 +142,20 @@ class TestDataGridBehaviour:
# Element ID Parsing
# ------------------------------------------------------------------
def test_i_can_get_pos_from_cell_element_id(self, datagrid):
"""Test that _get_pos_from_element_id correctly parses (col, row) from a cell ID.
@pytest.mark.parametrize("element_id_template, expected", [
("tcell_{id}-3-7", (3, 7)),
("trow_{id}-5", None),
(None, None),
])
def test_i_can_get_pos_from_element_id(self, datagrid, element_id_template, expected):
"""Test that _get_pos_from_element_id returns the correct (col, row) position or None.
The position tuple (col, row) is used for cell navigation and selection
state tracking. Correct parsing is required for keyboard navigation and
mouse selection to target the right cell.
- Cell IDs ('tcell_…') carry (col, row) indices required for cell navigation.
- Row IDs ('trow_…') have no cell position; None signals no cell can be derived.
- None input is a safe no-op; callers must handle it without raising.
"""
element_id = f"tcell_{datagrid._id}-3-7"
assert datagrid._get_pos_from_element_id(element_id) == (3, 7)
def test_i_can_get_pos_returns_none_for_non_cell_id(self, datagrid):
"""Test that _get_pos_from_element_id returns None for row IDs and None input.
Row and column IDs don't carry a (col, row) position. Returning None
signals that no cell-level position can be derived.
"""
assert datagrid._get_pos_from_element_id(f"trow_{datagrid._id}-5") is None
assert datagrid._get_pos_from_element_id(None) is None
element_id = element_id_template.format(id=datagrid._id) if element_id_template else None
assert datagrid._get_pos_from_element_id(element_id) == expected
# ------------------------------------------------------------------
# Static ID Conversions
@@ -293,14 +281,14 @@ class TestDataGridBehaviour:
# Selection and Interaction
# ------------------------------------------------------------------
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid):
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid_with_data):
"""Test that pressing ESC resets both the focused cell and extra selections.
ESC is the standard 'deselect all' shortcut. Both selected and
extra_selected must be cleared so the grid visually deselects everything
and subsequent navigation starts from a clean state.
"""
dg = datagrid
dg = datagrid_with_data
dg._state.selection.selected = (1, 2)
dg._state.selection.extra_selected.append(("range", (0, 0, 2, 2)))
@@ -318,7 +306,7 @@ class TestDataGridBehaviour:
dg = datagrid
dg._state.selection.selected = (1, 2)
dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
dg.handle_on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
assert dg._state.selection.selected == (1, 2)
@@ -332,7 +320,7 @@ class TestDataGridBehaviour:
dg = datagrid
cell_id = f"tcell_{dg._id}-2-5"
dg.on_click("click", is_inside=True, cell_id=cell_id)
dg.handle_on_click("click", is_inside=True, cell_id=cell_id)
assert dg._state.selection.selected == (2, 5)
@@ -384,6 +372,102 @@ class TestDataGridBehaviour:
assert col_def.type == ColumnType.Number
assert col_def.formula == "{age} + 1"
# ------------------------------------------------------------------
# Keyboard Navigation
# ------------------------------------------------------------------
# Column layout for datagrid_with_data (enable_edition=True):
# _state.columns : [name (idx 0), age (idx 1), active (idx 2)]
# _columns : [RowSelection_ (pos 0), name (pos 1), age (pos 2), active (pos 3)]
# Navigable positions: [1, 2, 3] — RowSelection_ (pos 0) always excluded.
def test_i_can_get_navigable_col_positions(self, datagrid_with_data):
"""RowSelection_ (pos 0) must be excluded; data columns (1, 2, 3) must be included."""
positions = datagrid_with_data._get_navigable_col_positions()
assert positions == [1, 2, 3]
def test_i_can_get_navigable_col_positions_with_hidden_column(self, datagrid_with_data):
"""Hidden columns must be excluded from navigable positions.
_state.columns[1] is 'age' → maps to _columns pos 2.
After hiding, navigable positions must be [1, 3].
"""
datagrid_with_data._state.columns[1].visible = False # hide 'age' (pos 2 in _columns)
datagrid_with_data._init_columns()
positions = datagrid_with_data._get_navigable_col_positions()
assert positions == [1, 3]
@pytest.mark.parametrize("start_pos, direction, expected_pos", [
# Normal navigation — right / left
((1, 0), "right", (2, 0)),
((2, 0), "right", (3, 0)),
((3, 0), "left", (2, 0)),
((2, 0), "left", (1, 0)),
# Normal navigation — down / up
((1, 0), "down", (1, 1)),
((1, 1), "down", (1, 2)),
((1, 2), "up", (1, 1)),
((1, 1), "up", (1, 0)),
# Boundaries — must stay in place
((3, 0), "right", (3, 0)),
((1, 0), "left", (1, 0)),
((1, 2), "down", (1, 2)),
((1, 0), "up", (1, 0)),
])
def test_i_can_navigate(self, datagrid_with_data, start_pos, direction, expected_pos):
"""Navigation moves to the expected position or stays at boundary."""
result = datagrid_with_data._navigate(start_pos, direction)
assert result == expected_pos
def test_i_can_navigate_right_skipping_invisible_column(self, datagrid_with_data):
"""→ from col 1 must skip hidden col 2 (age) and land on col 3 (active)."""
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
datagrid_with_data._init_columns()
result = datagrid_with_data._navigate((1, 0), "right")
assert result == (3, 0)
def test_i_can_navigate_left_skipping_invisible_column(self, datagrid_with_data):
"""← from col 3 must skip hidden col 2 (age) and land on col 1 (name)."""
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
datagrid_with_data._init_columns()
result = datagrid_with_data._navigate((3, 0), "left")
assert result == (1, 0)
def test_i_can_navigate_down_skipping_filtered_row(self, datagrid_with_data):
"""↓ from row 0 must skip filtered-out row 1 (Bob) and land on row 2 (Charlie).
Filter keeps age values "25" and "35" only → row 1 (age=30) is excluded.
Visible row indices become [0, 2].
"""
datagrid_with_data._state.filtered["age"] = ["25", "35"]
result = datagrid_with_data._navigate((1, 0), "down")
assert result == (1, 2)
@pytest.mark.parametrize("combination, start_pos, expected_pos", [
("arrowright", (1, 0), (2, 0)),
("arrowleft", (2, 0), (1, 0)),
("arrowdown", (1, 0), (1, 1)),
("arrowup", (1, 1), (1, 0)),
])
def test_i_can_navigate_with_arrow_keys(self, datagrid_with_data, combination, start_pos, expected_pos):
"""Arrow key presses update selection.selected to the expected position."""
datagrid_with_data._state.selection.selected = start_pos
datagrid_with_data.on_key_pressed(combination=combination, has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == expected_pos
def test_i_can_navigate_from_last_selected_when_no_selection(self, datagrid_with_data):
"""When selected is None, navigation starts from last_selected."""
datagrid_with_data._state.selection.selected = None
datagrid_with_data._state.selection.last_selected = (1, 1)
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == (2, 1)
def test_i_can_navigate_from_origin_when_no_selection_and_no_last_selected(self, datagrid_with_data):
"""When both selected and last_selected are None, navigation starts from (0, 0)."""
datagrid_with_data._state.selection.selected = None
datagrid_with_data._state.selection.last_selected = None
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == (1, 0)
class TestDataGridRender:
@@ -488,60 +572,28 @@ class TestDataGridRender:
)
assert matches(html, expected)
def test_i_can_render_extra_selected_row(self, datagrid):
"""Test that a row extra-selection entry renders as a Div with selection_type='row'.
@pytest.mark.parametrize("sel_type, element_id_template", [
("row", "trow_{id}-3"),
("column", "tcol_{id}-2"),
("range", (0, 0, 2, 2)),
])
def test_i_can_render_extra_selected_entry(self, datagrid, sel_type, element_id_template):
"""Test that each extra-selection type renders as a child Div with the correct attributes.
Why these elements matter:
- selection_type='row': JS applies the row-stripe highlight to the entire row
- element_id: the DOM ID of the row element that JS will highlight
- selection_type: tells JS which highlight strategy to apply (row stripe,
column stripe, or range rectangle)
- element_id: the DOM target JS will highlight; strings are used as-is,
tuples (range bounds) are stringified so JS can parse the coordinates
"""
dg = datagrid
row_element_id = f"trow_{dg._id}-3"
dg._state.selection.extra_selected.append(("row", row_element_id))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="row", element_id=row_element_id),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_extra_selected_column(self, datagrid):
"""Test that a column extra-selection entry renders as a Div with selection_type='column'.
element_id = element_id_template.format(id=dg._id) if isinstance(element_id_template, str) else element_id_template
dg._state.selection.extra_selected.append((sel_type, element_id))
Why these elements matter:
- selection_type='column': JS applies the column-stripe highlight to the entire column
- element_id: the DOM ID of the column header element that JS will highlight
"""
dg = datagrid
col_element_id = f"tcol_{dg._id}-2"
dg._state.selection.extra_selected.append(("column", col_element_id))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="column", element_id=col_element_id),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_extra_selected_range(self, datagrid):
"""Test that a range extra-selection entry renders with the tuple stringified as element_id.
Why these elements matter:
- selection_type='range': JS draws a rectangular highlight over the cell region
- element_id=str(tuple): the range bounds (min_col, min_row, max_col, max_row)
are passed as a string; JS parses this to locate all cells in the rectangle
"""
dg = datagrid
range_bounds = (0, 0, 2, 2)
dg._state.selection.extra_selected.append(("range", range_bounds))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="range", element_id=f"{range_bounds}"),
Div(selection_type=sel_type, element_id=f"{element_id}"),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
@@ -612,60 +664,34 @@ class TestDataGridRender:
col_headers = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
assert len(col_headers) == 3, "Should have one resizable header cell per visible data column"
def test_i_can_render_row_selection_header_in_edition_mode(self, datagrid_with_data):
"""Test that a RowSelection header cell is rendered when edition mode is enabled.
@pytest.mark.parametrize("css_cls, edition_enabled, expected_count", [
("dt2-row-selection", True, 1),
("dt2-row-selection", False, 0),
("dt2-add-column", True, 1),
("dt2-add-column", False, 0),
])
def test_i_can_render_header_edition_elements_visibility(
self, datagrid_with_data, css_cls, edition_enabled, expected_count):
"""Test that edition-specific header elements are present only when edition is enabled.
Why these elements matter:
- dt2-row-selection: the selection checkbox column is only meaningful in edition
mode where rows can be individually selected for bulk operations; JS uses this
cell to anchor the row-selection toggle handler
- exactly 1 cell: a second dt2-row-selection would double the checkbox column
- dt2-row-selection: the checkbox column is only meaningful in edition mode;
rendering it in read-only mode would create an orphan misaligned column
- dt2-add-column: the '+' icon exposes mutation UI; it must be hidden in
read-only grids to prevent users from adding columns unintentionally
- expected_count 1 vs 0: exactly one element when enabled, none when disabled,
prevents both missing controls and duplicated ones
"""
dg = datagrid_with_data
dg._settings.enable_edition = edition_enabled
dg._init_columns()
html = dg.mk_headers()
row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection")))
assert len(row_sel_cells) == 1, "Edition mode must render exactly one row-selection header cell"
def test_i_cannot_render_row_selection_header_without_edition_mode(self, datagrid_no_edition):
"""Test that no RowSelection header cell is rendered when edition mode is disabled.
Why this matters:
- Without edition, there is no row selection column in _columns; rendering one
would create an orphan cell misaligned with the body rows
"""
dg = datagrid_no_edition
html = dg.mk_headers()
row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection")))
assert len(row_sel_cells) == 0, "Without edition mode, no row-selection header cell should be rendered"
def test_i_can_render_add_column_button_in_edition_mode(self, datagrid_with_data):
"""Test that the add-column button is appended to the header in edition mode.
Why this element matters:
- dt2-add-column: the '+' icon at the end of the header lets users add new
columns interactively; it must be present in edition mode and absent otherwise
to avoid exposing mutation UI in read-only grids
"""
dg = datagrid_with_data
html = dg.mk_headers()
add_col_cells = find(html, Div(cls=Contains("dt2-add-column")))
assert len(add_col_cells) == 1, "Edition mode must render exactly one add-column button"
def test_i_cannot_render_add_column_button_without_edition_mode(self, datagrid_no_edition):
"""Test that no add-column button is rendered when edition mode is disabled.
Why this matters:
- Read-only grids must not expose mutation controls; the absence of dt2-add-column
guarantees that the JS handler for toggling the column editor is never reachable
"""
dg = datagrid_no_edition
html = dg.mk_headers()
add_col_cells = find(html, Div(cls=Contains("dt2-add-column")))
assert len(add_col_cells) == 0, "Without edition mode, no add-column button should be rendered"
elements = find(html, Div(cls=Contains(css_cls)))
assert len(elements) == expected_count, (
f"{'Edition' if edition_enabled else 'Read-only'} mode must render "
f"exactly {expected_count} '{css_cls}' element(s)"
)
def test_i_can_render_headers_in_column_order(self, datagrid_with_data):
"""Test that resizable header cells appear in the same order as self._columns.
@@ -787,10 +813,213 @@ class TestDataGridRender:
))
assert len(handles) == 3, "Each data column must have exactly one resize handle with correct command IDs"
# ------------------------------------------------------------------
# Table structure
# ------------------------------------------------------------------
def test_i_can_render_table_wrapper(self, datagrid_with_data):
"""Test that mk_table_wrapper renders with correct ID, class and 3 main sections.
Why these elements matter:
- id=tw_{id}: used by JS to position custom scrollbars over the table
- cls Contains 'dt2-table-wrapper': CSS hook for relative positioning that lets
the scrollbars overlay use absolute coordinates over the table
- tsm_{id}: selection manager lives inside the wrapper so it survives partial
re-renders that target the wrapper
- t_{id}: the table with header, body and footer
- dt2-scrollbars: custom scrollbar overlay (structure tested separately)
"""
dg = datagrid_with_data
html = dg.mk_table_wrapper()
expected = Div(
Div(id=f"tsm_{dg._id}"), # selection manager
Div(id=f"t_{dg._id}"), # table
Div(cls=Contains("dt2-scrollbars")), # scrollbars overlay
id=f"tw_{dg._id}",
cls=Contains("dt2-table-wrapper"),
)
assert matches(html, expected)
def test_i_can_render_table(self, datagrid_with_data):
"""Test that mk_table renders with correct ID, class and 3 container sections.
Why these elements matter:
- id=t_{id}: targeted by on_column_changed and render_partial('table') swaps
- cls Contains 'dt2-table': CSS grid container that aligns header, body and
footer columns
- dt2-header-container: wraps the header row with no-scroll behaviour
- tb_{id}: body wrapper, targeted by get_page for lazy-load row appends and by
render_partial('body') for full body swaps on filter/sort
- dt2-footer-container: wraps the aggregation footer with no-scroll behaviour
"""
dg = datagrid_with_data
html = dg.mk_table()
expected = Div(
Div(cls=Contains("dt2-header-container")), # header container
Div(id=f"tb_{dg._id}"), # body wrapper
Div(cls=Contains("dt2-footer-container")), # footer container
id=f"t_{dg._id}",
cls=Contains("dt2-table"),
)
assert matches(html, expected)
def test_i_can_render_table_has_scrollbars(self, datagrid_with_data):
"""Test that the scrollbars overlay contains both vertical and horizontal tracks.
Why these elements matter:
- dt2-scrollbars-vertical-wrapper / dt2-scrollbars-horizontal-wrapper: JS resizes
these wrappers to match the live table dimensions on each render
- dt2-scrollbars-vertical / dt2-scrollbars-horizontal: the visible scrollbar
thumbs that JS moves on scroll; missing either disables that scroll axis
"""
dg = datagrid_with_data
html = dg.mk_table_wrapper()
# Step 1: Find and validate the vertical scrollbar wrapper
vertical = find_one(html, Div(cls=Contains("dt2-scrollbars-vertical-wrapper")))
assert matches(vertical, Div(
Div(cls=Contains("dt2-scrollbars-vertical")),
cls=Contains("dt2-scrollbars-vertical-wrapper"),
))
# Step 2: Find and validate the horizontal scrollbar wrapper
horizontal = find_one(html, Div(cls=Contains("dt2-scrollbars-horizontal-wrapper")))
assert matches(horizontal, Div(
Div(cls=Contains("dt2-scrollbars-horizontal")),
cls=Contains("dt2-scrollbars-horizontal-wrapper"),
))
# ------------------------------------------------------------------
# render_partial fragments
# ------------------------------------------------------------------
def test_i_can_render_partial_body(self, datagrid_with_data):
"""Test that render_partial('body') returns (selection_manager, body_wrapper).
Why these elements matter:
- 2 elements: both the body and the selection manager are sent back together
so the cell highlight is updated in the same response as the body swap
- tsm_{id}: refreshes the cell highlight after the body is replaced
- tb_{id}: the HTMX target for filter and sort re-renders
- hx-on::after-settle Contains 'initDataGrid': re-initialises JS scroll and
resize logic after the new body is inserted into the DOM
"""
dg = datagrid_with_data
result = dg.render_partial("body")
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('body') must return (selection_manager, body_wrapper)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify body wrapper ID, class and after-settle attribute
assert matches(result[1], Div(id=f"tb_{dg._id}", cls=Contains("dt2-body-container")))
assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), (
"Body wrapper must carry hx-on::after-settle with initDataGrid to re-init JS after swap"
)
def test_i_can_render_partial_table(self, datagrid_with_data):
"""Test that render_partial('table') returns (selection_manager, table).
Why these elements matter:
- 2 elements: body and selection manager are sent back together so the cell
highlight is updated in the same response as the table swap
- t_{id}: full table swap used by on_column_changed when columns are added,
hidden, or reordered; an incorrect ID would leave the old table in the DOM
- hx-on::after-settle Contains 'initDataGrid': re-initialises column resize
and drag-and-drop after the new table structure is inserted
"""
dg = datagrid_with_data
result = dg.render_partial("table")
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('table') must return (selection_manager, table)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify table ID, class and after-settle attribute
assert matches(result[1], Div(id=f"t_{dg._id}", cls=Contains("dt2-table")))
assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), (
"Table must carry hx-on::after-settle with initDataGrid to re-init JS after column swap"
)
def test_i_can_render_partial_header(self, datagrid_with_data):
"""Test that render_partial('header') returns a single header element with setColumnWidth.
Why these elements matter:
- not a tuple: header swaps are triggered by reset_column_width which uses a
direct HTMX target (#th_{id}); returning a tuple would break the swap
- th_{id}: the HTMX target for the header swap after auto-size
- hx-on::after-settle Contains 'setColumnWidth': applies the new pixel width
to all body cells via JS after the header is swapped in
- col_id in after-settle: JS needs the column ID to target the correct cells
"""
dg = datagrid_with_data
col_id = dg._state.columns[0].col_id
result = dg.render_partial("header", col_id=col_id, optimal_width=200)
# Step 1: Verify it is a single element, not a tuple
assert not isinstance(result, tuple), "render_partial('header') must return a single element"
# Step 2: Verify header ID and class
assert matches(result, Div(id=f"th_{dg._id}", cls=Contains("dt2-header")))
# Step 3: Verify after-settle contains setColumnWidth and the column ID
after_settle = result.attrs.get("hx-on::after-settle", "")
assert "setColumnWidth" in after_settle, (
"Header must carry hx-on::after-settle with setColumnWidth to resize body cells"
)
assert col_id in after_settle, (
"hx-on::after-settle must include the column ID so JS targets the correct column"
)
def test_i_can_render_partial_cell_by_pos(self, datagrid_with_data):
"""Test that render_partial('cell', pos=...) returns (selection_manager, cell).
Why these elements matter:
- 2 elements: cell content and selection manager are sent back together so
the focus highlight is updated in the same response as the cell swap
- tsm_{id}: refreshes the focus highlight after the cell is replaced
- tcell_{id}-{col}-{row}: the HTMX swap target for individual cell updates
(edition entry/exit); an incorrect ID leaves the old cell content in the DOM
"""
dg = datagrid_with_data
name_col = next(c for c in dg._columns if c.title == "name")
col_pos = dg._columns.index(name_col)
result = dg.render_partial("cell", pos=(col_pos, 0))
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('cell', pos=...) must return (selection_manager, cell)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify cell ID and class
assert matches(result[1], Div(
id=f"tcell_{dg._id}-{col_pos}-0",
cls=Contains("dt2-cell"),
))
def test_i_can_render_partial_cell_with_no_position(self, datagrid_with_data):
"""Test that render_partial() with no position returns only (selection_manager,).
Why this matters:
- 1 element only: when no valid cell position can be resolved, only the
selection manager is returned to refresh the highlight state
- no cell element: no position means no cell to update in the DOM
"""
dg = datagrid_with_data
result = dg.render_partial()
assert len(result) == 1, "render_partial() with no position must return only (selection_manager,)"
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# ------------------------------------------------------------------
# Body
# ------------------------------------------------------------------
def test_i_can_render_body_wrapper(self, datagrid_with_data):
"""Test that the body wrapper renders with the correct ID and CSS class.

View File

@@ -0,0 +1,429 @@
"""Unit tests for DataGridFormattingManager."""
import shutil
import uuid
import pytest
from fasthtml.common import Div, Form, Input, Span
from myfasthtml.controls.DataGridFormattingManager import DataGridFormattingManager
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.controls.Menu import Menu
from myfasthtml.controls.Panel import Panel
from myfasthtml.core.formatting.dataclasses import FormatRule, RulePreset, Style
from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS
from myfasthtml.test.matcher import Contains, TestObject, find_one, matches
from .conftest import root_instance
@pytest.fixture(autouse=True)
def cleanup_db():
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
@pytest.fixture
def fmgr(root_instance):
uid = uuid.uuid4().hex[:8]
return DataGridFormattingManager(root_instance, _id=f"fmgr-{uid}")
@pytest.fixture
def user_preset():
return RulePreset(name="my_preset", description="My preset", rules=[], dsl="")
class TestDataGridFormattingManagerBehaviour:
"""Tests for DataGridFormattingManager behavior and logic."""
# ------------------------------------------------------------------
# Helper methods
# ------------------------------------------------------------------
def test_i_can_get_all_presets_includes_builtins_and_user(self, fmgr, user_preset):
"""Test that _get_all_presets() returns builtins first, then user presets.
Why: Builtins must appear before user presets to maintain consistent ordering
in the search list.
"""
fmgr._state.presets.append(user_preset)
all_presets = fmgr._get_all_presets()
assert all_presets[:len(DEFAULT_RULE_PRESETS)] == list(DEFAULT_RULE_PRESETS.values())
assert all_presets[-1] == user_preset
def test_i_can_check_builtin_preset(self, fmgr):
"""Test that _is_builtin() returns True for a builtin preset name."""
builtin_name = next(iter(DEFAULT_RULE_PRESETS))
assert fmgr._is_builtin(builtin_name) is True
def test_i_cannot_check_user_preset_as_builtin(self, fmgr, user_preset):
"""Test that _is_builtin() returns False for a user preset name."""
fmgr._state.presets.append(user_preset)
assert fmgr._is_builtin(user_preset.name) is False
def test_i_can_get_selected_preset(self, fmgr, user_preset):
"""Test that _get_selected_preset() returns the correct preset when one is selected."""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
result = fmgr._get_selected_preset()
assert result == user_preset
def test_i_get_none_when_no_preset_selected(self, fmgr):
"""Test that _get_selected_preset() returns None when no preset is selected."""
fmgr._state.selected_name = None
assert fmgr._get_selected_preset() is None
def test_i_can_get_user_preset_by_name(self, fmgr, user_preset):
"""Test that _get_user_preset() finds an existing user preset by name."""
fmgr._state.presets.append(user_preset)
result = fmgr._get_user_preset(user_preset.name)
assert result == user_preset
def test_i_get_none_for_missing_user_preset(self, fmgr):
"""Test that _get_user_preset() returns None for an unknown preset name."""
result = fmgr._get_user_preset("nonexistent")
assert result is None
# ------------------------------------------------------------------
# Mode changes
# ------------------------------------------------------------------
def test_i_can_switch_to_new_mode(self, fmgr):
"""Test that handle_new_preset() sets ns_mode to 'new'.
Why: The mode controls which form/view is rendered in _mk_main_content().
"""
fmgr.handle_new_preset()
assert fmgr._state.ns_mode == "new"
def test_i_can_cancel_to_view_mode(self, fmgr):
"""Test that handle_cancel() resets ns_mode to 'view'."""
fmgr._state.ns_mode = "new"
fmgr.handle_cancel()
assert fmgr._state.ns_mode == "view"
def test_i_can_switch_to_rename_mode_for_user_preset(self, fmgr, user_preset):
"""Test that handle_rename_preset() sets ns_mode to 'rename' for a user preset."""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
fmgr.handle_rename_preset()
assert fmgr._state.ns_mode == "rename"
def test_i_cannot_rename_builtin_preset(self, fmgr):
"""Test that handle_rename_preset() does NOT change mode for a builtin preset.
Why: Builtin presets are read-only and should not be renameable.
"""
builtin_name = next(iter(DEFAULT_RULE_PRESETS))
fmgr._state.selected_name = builtin_name
fmgr.handle_rename_preset()
assert fmgr._state.ns_mode == "view"
def test_i_cannot_rename_when_no_selection(self, fmgr):
"""Test that handle_rename_preset() does NOT change mode without a selection."""
fmgr._state.selected_name = None
fmgr.handle_rename_preset()
assert fmgr._state.ns_mode == "view"
# ------------------------------------------------------------------
# Deletion
# ------------------------------------------------------------------
def test_i_can_delete_user_preset(self, fmgr, user_preset):
"""Test that handle_delete_preset() removes the preset and clears the selection.
Why: Deletion must remove from state.presets and reset selected_name to avoid
referencing a deleted preset.
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
fmgr.handle_delete_preset()
assert user_preset not in fmgr._state.presets
assert fmgr._state.selected_name is None
def test_i_cannot_delete_builtin_preset(self, fmgr):
"""Test that handle_delete_preset() does NOT delete a builtin preset.
Why: Builtin presets are immutable and must always be available.
"""
builtin_name = next(iter(DEFAULT_RULE_PRESETS))
fmgr._state.selected_name = builtin_name
initial_count = len(fmgr._state.presets)
fmgr.handle_delete_preset()
assert len(fmgr._state.presets) == initial_count
assert fmgr._state.selected_name == builtin_name
def test_i_cannot_delete_when_no_selection(self, fmgr):
"""Test that handle_delete_preset() does nothing without a selection."""
fmgr._state.selected_name = None
initial_count = len(fmgr._state.presets)
fmgr.handle_delete_preset()
assert len(fmgr._state.presets) == initial_count
# ------------------------------------------------------------------
# Selection
# ------------------------------------------------------------------
def test_i_can_select_user_preset_as_editable(self, fmgr, user_preset):
"""Test that handle_select_preset() sets readonly=False for user presets.
Why: User presets are editable, so the editor must not be read-only.
"""
fmgr._state.presets.append(user_preset)
fmgr.handle_select_preset(user_preset.name)
assert fmgr._state.selected_name == user_preset.name
assert fmgr._editor.conf.readonly is False
def test_i_can_select_builtin_preset_as_readonly(self, fmgr):
"""Test that handle_select_preset() sets readonly=True for builtin presets.
Why: Builtin presets must be protected from edits.
"""
builtin_name = next(iter(DEFAULT_RULE_PRESETS))
fmgr.handle_select_preset(builtin_name)
assert fmgr._state.selected_name == builtin_name
assert fmgr._editor.conf.readonly is True
# ------------------------------------------------------------------
# Preset creation
# ------------------------------------------------------------------
def test_i_can_confirm_new_preset(self, fmgr):
"""Test that handle_confirm_new() creates a preset, selects it, and returns to 'view'.
Why: After confirmation, the new preset must appear in the list, be selected,
and the mode must return to 'view'.
"""
fmgr._state.ns_mode = "new"
fmgr.handle_confirm_new({"name": "alpha", "description": "Alpha preset"})
names = [p.name for p in fmgr._state.presets]
assert "alpha" in names
assert fmgr._state.selected_name == "alpha"
assert fmgr._state.ns_mode == "view"
def test_i_cannot_confirm_new_preset_with_empty_name(self, fmgr):
"""Test that handle_confirm_new() returns to view without creating if name is empty.
Why: A preset without a name is invalid and must be rejected silently.
"""
fmgr._state.ns_mode = "new"
initial_count = len(fmgr._state.presets)
fmgr.handle_confirm_new({"name": " ", "description": ""})
assert len(fmgr._state.presets) == initial_count
assert fmgr._state.ns_mode == "view"
def test_i_cannot_confirm_new_preset_with_duplicate_name(self, fmgr, user_preset):
"""Test that handle_confirm_new() rejects a name that already exists.
Why: Preset names must be unique across builtins and user presets.
"""
fmgr._state.presets.append(user_preset)
fmgr._state.ns_mode = "new"
initial_count = len(fmgr._state.presets)
fmgr.handle_confirm_new({"name": user_preset.name, "description": ""})
assert len(fmgr._state.presets) == initial_count
assert fmgr._state.ns_mode == "view"
# ------------------------------------------------------------------
# Preset renaming
# ------------------------------------------------------------------
def test_i_can_confirm_rename_preset(self, fmgr, user_preset):
"""Test that handle_confirm_rename() renames the preset and updates the selection.
Why: After renaming, selected_name must point to the new name and the original
name must no longer exist.
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
original_name = user_preset.name
fmgr.handle_confirm_rename({"name": "renamed", "description": "New description"})
assert fmgr._state.selected_name == "renamed"
assert fmgr._get_user_preset("renamed") is not None
assert fmgr._get_user_preset(original_name) is None
def test_i_cannot_confirm_rename_to_existing_name(self, fmgr, user_preset):
"""Test that handle_confirm_rename() rejects a rename to an already existing name.
Why: Allowing duplicate names would break preset lookup by name.
"""
other_preset = RulePreset(name="other_preset", description="", rules=[], dsl="")
fmgr._state.presets.extend([user_preset, other_preset])
fmgr._state.selected_name = user_preset.name
fmgr.handle_confirm_rename({"name": other_preset.name, "description": ""})
assert fmgr._get_user_preset(user_preset.name) is not None
assert fmgr._state.ns_mode == "view"
def test_i_can_confirm_rename_with_same_name(self, fmgr, user_preset):
"""Test that handle_confirm_rename() allows keeping the same name (description-only update).
Why: Renaming to the same name should succeed — it allows updating description only.
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
fmgr.handle_confirm_rename({"name": user_preset.name, "description": "Updated"})
assert fmgr._state.selected_name == user_preset.name
assert fmgr._get_user_preset(user_preset.name).description == "Updated"
class TestDataGridFormattingManagerRender:
"""Tests for DataGridFormattingManager HTML rendering."""
@pytest.fixture
def fmgr(self, root_instance):
uid = uuid.uuid4().hex[:8]
return DataGridFormattingManager(root_instance, _id=f"fmgr-render-{uid}")
@pytest.fixture
def user_preset(self):
return RulePreset(
name="my_preset",
description="My preset",
rules=[FormatRule(style=Style(preset="info"))],
dsl="",
)
def test_render_global_structure(self, fmgr):
"""Test that DataGridFormattingManager renders with correct global structure.
Why these elements matter:
- id=fmgr._id: Root identifier required for HTMX outerHTML swap targeting
- cls Contains "mf-formatting-manager": Root CSS class for layout styling
- Menu + Panel children: Both are always present regardless of state
"""
html = fmgr.render()
expected = Div(
TestObject(Menu), # menu
TestObject(Panel), # panel
id=fmgr._id,
cls=Contains("mf-formatting-manager"),
)
assert matches(html, expected)
@pytest.mark.parametrize("text, expected_cls", [
("format", "badge-secondary"),
("style", "badge-primary"),
("built-in", "badge-ghost"),
])
def test_i_can_render_badge(self, fmgr, text, expected_cls):
"""Test that _mk_badge() renders a Span with the correct badge class.
Why these elements matter:
- Span text content: Identifies the badge type in the UI
- cls Contains expected_cls: Badge variant determines the visual style applied to the cell
"""
badge = fmgr._mk_badge(text)
expected = Span(text, cls=Contains(expected_cls))
assert matches(badge, expected)
def test_i_can_render_editor_placeholder_when_no_preset_selected(self, fmgr):
"""Test that _mk_editor_view() shows a placeholder when no preset is selected.
Why these elements matter:
- cls Contains "mf-fmgr-placeholder": Allows CSS targeting of the empty state
- Text content: Guides the user to select a preset
"""
fmgr._state.selected_name = None
view = fmgr._mk_editor_view()
expected = Div("Select a preset to edit", cls=Contains("mf-fmgr-placeholder"))
assert matches(view, expected)
def test_i_can_render_editor_view_when_preset_selected(self, fmgr, user_preset):
"""Test that _mk_editor_view() renders the preset header and DSL editor.
Why these elements matter:
- cls Contains "mf-fmgr-editor-view": Root class for the editor area
- Header Div: Shows preset metadata above the editor
- DslEditor: The editing surface for the preset DSL
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
view = fmgr._mk_editor_view()
expected = Div(
Div(cls=Contains("mf-fmgr-editor-meta")), # preset header
TestObject(DslEditor),
cls=Contains("mf-fmgr-editor-view"),
)
assert matches(view, expected)
def test_i_can_render_new_form_structure(self, fmgr):
"""Test that _mk_new_form() contains name and description inputs inside a form.
Why these elements matter:
- cls Contains "mf-fmgr-form": Root class for form styling
- Input name="name": Required field for the new preset identifier
- Input name="description": Optional field for preset description
"""
# Step 1: validate root wrapper
form_div = fmgr._mk_new_form()
assert matches(form_div, Div(cls=Contains("mf-fmgr-form")))
# Step 2: find the form and validate inputs
expected_form = Form()
del expected_form.attrs["enctype"]
form = find_one(form_div, expected_form)
find_one(form, Input(name="name"))
find_one(form, Input(name="description"))
def test_i_can_render_rename_form_with_preset_values(self, fmgr, user_preset):
"""Test that _mk_rename_form() pre-fills the name input with the selected preset's name.
Why these elements matter:
- Input value=preset.name: Pre-filled so the user edits rather than retypes the name
- Input name="name": The field submitted on confirm
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
# Step 1: find the form
form_div = fmgr._mk_rename_form()
expected_form = Form()
del expected_form.attrs["enctype"]
form = find_one(form_div, expected_form)
# Step 2: validate pre-filled name input
name_input = find_one(form, Input(name="name"))
assert matches(name_input, Input(name="name", value=user_preset.name))
@pytest.mark.parametrize("mode, expected_cls", [
("new", "mf-fmgr-form"),
("rename", "mf-fmgr-form"),
("view", "mf-fmgr-editor-view"),
])
def test_i_can_render_main_content_per_mode(self, fmgr, user_preset, mode, expected_cls):
"""Test that _mk_main_content() delegates to the correct sub-view per mode.
Why these elements matter:
- cls Contains expected_cls: Verifies that the correct form/view is rendered
based on the current ns_mode state
"""
fmgr._state.presets.append(user_preset)
fmgr._state.selected_name = user_preset.name
fmgr._state.ns_mode = mode
content = fmgr._mk_main_content()
assert matches(content, Div(cls=Contains(expected_cls)))

View File

@@ -72,7 +72,7 @@ class TestDataGridsManagerBehaviour:
"""
# Create a folder and select it
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
datagrids_manager._tree._select_node(folder_id)
datagrids_manager._tree.handle_select_node(folder_id)
result = datagrids_manager.handle_new_grid()
@@ -101,7 +101,7 @@ class TestDataGridsManagerBehaviour:
datagrids_manager._tree.add_node(leaf, parent_id=folder_id)
# Select the leaf
datagrids_manager._tree._select_node(leaf.id)
datagrids_manager._tree.handle_select_node(leaf.id)
result = datagrids_manager.handle_new_grid()

View File

@@ -0,0 +1,255 @@
import shutil
from datetime import datetime
from uuid import uuid4
import pytest
from fasthtml.common import Div, Span
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.core.instances import InstancesManager
from myfasthtml.core.profiler import profiler, ProfilingTrace
from myfasthtml.test.matcher import matches, find, Contains, TestIcon, DoesNotContain, And, TestObject
def make_trace(
command_name: str = "TestCommand",
duration_ms: float = 50.0,
trace_id: str = None,
) -> ProfilingTrace:
"""Create a fake ProfilingTrace for testing purposes."""
return ProfilingTrace(
command_name=command_name,
command_description=f"{command_name} description",
command_id=str(uuid4()),
kwargs={},
timestamp=datetime.now(),
total_duration_ms=duration_ms,
trace_id=trace_id or str(uuid4()),
)
@pytest.fixture(autouse=True)
def reset_profiler():
"""Reset profiler singleton state before and after each test."""
profiler.clear()
profiler.enabled = False
yield
profiler.clear()
profiler.enabled = False
class TestProfilerBehaviour:
"""Tests for Profiler control behavior and logic."""
@pytest.fixture
def profiler_control(self, root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
ctrl = Profiler(root_instance)
yield ctrl
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
def test_i_can_create_profiler(self, profiler_control):
"""Test that Profiler initializes with no trace selected."""
assert profiler_control._selected_id is None
@pytest.mark.parametrize("initial", [
False,
True,
])
def test_i_can_toggle_enable(self, profiler_control, initial):
"""Test that handle_toggle_enable inverts profiler.enabled."""
profiler.enabled = initial
profiler_control.handle_toggle_enable()
assert profiler.enabled == (not initial)
def test_i_can_add_traces(self, profiler_control):
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 60.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
assert len(profiler.traces) == 2
assert profiler.traces == [trace_b, trace_a]
def test_i_can_clear_traces_via_handler(self, profiler_control):
"""Test that handle_clear_traces empties the profiler trace buffer."""
profiler._traces.appendleft(make_trace())
profiler_control.handle_clear_traces()
assert len(profiler.traces) == 0
def test_i_can_select_trace_by_id(self, profiler_control):
"""Test that handle_select_trace stores the given trace_id."""
trace_id = str(uuid4())
profiler_control.handle_select_trace(trace_id)
assert profiler_control._selected_id == trace_id
def test_i_can_select_trace_stable_when_new_trace_added(self, profiler_control):
"""Test that selection by trace_id remains correct when a new trace is prepended.
This validates the fix for the index-shift bug: adding a new trace (appendleft)
must not affect which row appears selected.
"""
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 60.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
profiler_control.handle_select_trace(trace_a.trace_id)
# Add a new trace (simulates a new command executing after selection)
profiler._traces.appendleft(make_trace("NewCommand", 10.0))
# Selection still points to trace_a, unaffected by the new prepended trace
assert profiler_control._selected_id == trace_a.trace_id
@pytest.mark.parametrize("duration_ms, expected_cls", [
(10.0, "mf-profiler-fast"),
(50.0, "mf-profiler-medium"),
(150.0, "mf-profiler-slow"),
])
def test_i_can_get_duration_class(self, profiler_control, duration_ms, expected_cls):
"""Test that _duration_cls returns the correct CSS class for each threshold."""
assert profiler_control._duration_cls(duration_ms) == expected_cls
class TestProfilerRender:
"""Tests for Profiler control HTML rendering."""
@pytest.fixture
def profiler_control(self, root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
ctrl = Profiler(root_instance)
yield ctrl
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
def test_profiler_renders_global_structure(self, profiler_control):
"""Test that Profiler renders with correct global structure.
Why these elements matter:
- id: Required for HTMX targeting (all commands target this id)
- cls Contains "mf-profiler": Root CSS class for layout and styling
- toolbar Div: Always present, contains control actions
- Panel: Always present, hosts trace list and detail panels
"""
html = profiler_control.render()
expected = Div(
Div(cls=Contains("mf-profiler-toolbar")), # toolbar
TestObject(Panel), # panel
id=profiler_control.get_id(),
cls=Contains("mf-profiler"),
)
assert matches(html, expected)
def test_i_can_render_toolbar_when_enabled(self, profiler_control):
"""Test that toolbar shows pause icon when profiler is enabled.
Why these elements matter:
- pause icon: visual indicator that profiler is actively recording
"""
profiler.enabled = True
toolbar = profiler_control._mk_toolbar()
assert matches(toolbar, Div(TestIcon("pause_circle20_regular")))
def test_i_can_render_toolbar_when_disabled(self, profiler_control):
"""Test that toolbar shows play icon when profiler is disabled.
Why these elements matter:
- play icon: visual indicator that profiler is stopped and ready to record
"""
profiler.enabled = False
toolbar = profiler_control._mk_toolbar()
assert matches(toolbar, Div(TestIcon("play_circle20_regular")))
def test_i_can_render_toolbar_clear_button(self, profiler_control):
"""Test that toolbar contains exactly one danger-styled clear button.
Why these elements matter:
- cls Contains "mf-profiler-btn-danger": Ensures the clear button is visually
distinct (red) to warn the user before clearing all traces
"""
toolbar = profiler_control._mk_toolbar()
danger_buttons = find(toolbar, Div(cls=Contains("mf-profiler-btn-danger")))
assert len(danger_buttons) == 1, "Toolbar should contain exactly one danger-styled button"
def test_i_can_render_empty_trace_list(self, profiler_control):
"""Test that an empty-state message is shown when no traces are recorded.
Why these elements matter:
- "No traces recorded.": User-facing feedback when profiler has no data
- cls Contains "mf-profiler-empty": Applies centered empty-state styling
"""
trace_list = profiler_control._mk_trace_list()
assert matches(trace_list, Div("No traces recorded.", cls=Contains("mf-profiler-empty")))
def test_i_can_render_trace_with_name_and_timestamp(self, profiler_control):
"""Test that a trace row shows command name and formatted timestamp.
Why these elements matter:
- Span.mf-profiler-cmd with command_name: primary identifier for the user
- Span.mf-profiler-ts with formatted timestamp: helps correlate traces with events
"""
trace = make_trace("NavigateCell", 50.0)
ts_expected = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
profiler._traces.appendleft(trace)
trace_list = profiler_control._mk_trace_list()
cmd_spans = find(trace_list, Span("NavigateCell", cls=Contains("mf-profiler-cmd", _word=True)))
assert len(cmd_spans) == 1, "Command name should appear exactly once in the trace list"
ts_spans = find(trace_list, Span(ts_expected, cls=Contains("mf-profiler-ts")))
assert len(ts_spans) == 1, "Formatted timestamp should appear exactly once in the trace list"
def test_i_can_render_selected_row_has_selected_class(self, profiler_control):
"""Test that the selected row carries the selected CSS class.
Why these elements matter:
- cls Contains "mf-profiler-row-selected": Visual highlight of the active trace,
matched by trace_id (stable identifier) rather than list index
"""
trace = make_trace("NavigateCell", 50.0)
profiler._traces.appendleft(trace)
profiler_control._selected_id = trace.trace_id
trace_list = profiler_control._mk_trace_list()
selected_rows = find(trace_list, Div(cls=Contains("mf-profiler-row-selected")))
assert len(selected_rows) == 1, "Exactly one row should carry the selected class"
def test_i_can_render_unselected_row_has_no_selected_class(self, profiler_control):
"""Test that non-selected rows do not carry the selected CSS class.
Why these elements matter:
- cls And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected")):
Confirms that only the selected trace is highlighted, not its siblings
"""
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 80.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
profiler_control._selected_id = trace_b.trace_id
trace_list = profiler_control._mk_trace_list()
unselected_rows = find(trace_list,
Div(cls=And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected"))))
assert len(unselected_rows) == 1, "Exactly one row should remain unselected"
@pytest.mark.parametrize("duration_ms, expected_cls", [
(10.0, "mf-profiler-fast"),
(50.0, "mf-profiler-medium"),
(150.0, "mf-profiler-slow"),
])
def test_i_can_render_duration_color_class(self, profiler_control, duration_ms, expected_cls):
"""Test that the duration span carries the correct color class per threshold.
Why these elements matter:
- mf-profiler-fast/medium/slow on the duration Span: color-codes performance
at a glance, consistent with the thresholds defined in _duration_cls()
"""
trace = make_trace("TestCommand", duration_ms)
profiler._traces.appendleft(trace)
trace_list = profiler_control._mk_trace_list()
duration_spans = find(trace_list, Span(cls=Contains(expected_cls)))
assert len(duration_spans) == 1, f"Expected exactly one span with class '{expected_cls}'"

View File

@@ -145,7 +145,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
assert tree_view._state.selected == node.id
@@ -155,7 +155,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id
@@ -164,9 +164,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
tree_view._save_rename(node.id, "New Name")
tree_view.handle_save_rename(node.id, "New Name")
assert tree_view._state.items[node.id].label == "New Name"
assert tree_view._state.editing is None
@@ -176,9 +176,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance)
node = TreeNode(label="Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
tree_view._cancel_rename()
tree_view.handle_cancel_rename()
assert tree_view._state.editing is None
assert tree_view._state.items[node.id].label == "Name"
@@ -193,7 +193,7 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id)
# Delete child (leaf node)
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
assert child.id not in tree_view._state.items
assert child.id not in parent.children
@@ -225,7 +225,7 @@ class TestTreeviewBehaviour:
# Try to delete parent (has children)
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
tree_view._delete_node(parent.id)
tree_view.handle_delete_node(parent.id)
def test_i_cannot_add_sibling_to_root(self, root_instance):
"""Test that adding sibling to root node raises an error."""
@@ -243,7 +243,7 @@ class TestTreeviewBehaviour:
# Try to select node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id")
tree_view.handle_select_node("nonexistent_id")
def test_add_node_prevents_duplicate_children(self, root_instance):
"""Test that add_node prevents adding duplicate child IDs."""
@@ -317,11 +317,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id)
# Select the child
tree_view._select_node(child.id)
tree_view.handle_select_node(child.id)
assert tree_view._state.selected == child.id
# Delete the selected child
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
# Selection should be cleared
assert tree_view._state.selected is None
@@ -340,7 +340,7 @@ class TestTreeviewBehaviour:
assert parent.id in tree_view._state.opened
# Delete the child (making parent a leaf)
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
# Now delete the parent (now a leaf node)
# First remove it from root by creating a grandparent
@@ -349,7 +349,7 @@ class TestTreeviewBehaviour:
parent.parent = grandparent.id
grandparent.children.append(parent.id)
tree_view._delete_node(parent.id)
tree_view.handle_delete_node(parent.id)
# Parent should be removed from opened list
assert parent.id not in tree_view._state.opened
@@ -360,7 +360,7 @@ class TestTreeviewBehaviour:
# Try to start rename on node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._start_rename("nonexistent_id")
tree_view.handle_start_rename("nonexistent_id")
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
"""Test that saving rename for nonexistent node raises error."""
@@ -368,7 +368,7 @@ class TestTreeviewBehaviour:
# Try to save rename for node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._save_rename("nonexistent_id", "New Name")
tree_view.handle_save_rename("nonexistent_id", "New Name")
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
"""Test that adding sibling to nonexistent node raises error."""
@@ -597,11 +597,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node2)
# Start editing node1
tree_view._start_rename(node1.id)
tree_view.handle_start_rename(node1.id)
assert tree_view._state.editing == node1.id
# Select node2
tree_view._select_node(node2.id)
tree_view.handle_select_node(node2.id)
# Edit mode should be cancelled
assert tree_view._state.editing is None
@@ -615,11 +615,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node)
# Start editing the node
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id
# Select the same node
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
# Edit mode should be cancelled
assert tree_view._state.editing is None
@@ -784,7 +784,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Selected Node", type="file")
tree_view.add_node(node)
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
rendered = tree_view.render()
selected_container = find_one(rendered, Div(data_node_id=node.id))
@@ -814,7 +814,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
rendered = tree_view.render()
editing_container = find_one(rendered, Div(data_node_id=node.id))
@@ -1009,7 +1009,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
# Step 1: Extract the input element
rendered = tree_view.render()

View File

@@ -41,4 +41,4 @@ def db_manager(parent):
@pytest.fixture
def dsm(parent, db_manager):
return DataServicesManager(parent, parent._session)
return DataServicesManager(parent)

View File

@@ -350,3 +350,32 @@ class TestPresets:
assert "background-color: purple" in css.css
assert "color: yellow" in css.css
def test_i_can_expand_rule_preset_via_style(self):
"""A rule preset referenced via style() must be expanded like via format().
Why: style("traffic_light") should expand the traffic_light rule preset
(which has conditional style rules) instead of looking up "traffic_light"
as a style preset name (where it doesn't exist).
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=-5)
assert css is not None
assert css.cls == "mf-formatting-error"
def test_i_can_expand_rule_preset_via_style_with_no_match(self):
"""A rule preset via style() with a non-matching condition returns no style.
Why: traffic_light has style("error") only if value < 0.
A positive value should produce no style output.
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=10)
assert css is not None
assert css.cls == "mf-formatting-success"

678
tests/core/test_profiler.py Normal file
View File

@@ -0,0 +1,678 @@
import time
from datetime import datetime
import pytest
from myfasthtml.core.profiler import (
ProfilingManager,
ProfilingSpan,
CumulativeSpan,
ProfilingTrace,
_NullSpan,
)
@pytest.fixture(autouse=True)
def fresh_profiler():
"""Provide a fresh ProfilingManager for each test."""
p = ProfilingManager(max_traces=10)
p.enabled = True
return p
# ---------------------------------------------------------------------------
# TestProfilingModels — data model tests (ProfilingSpan, CumulativeSpan)
# ---------------------------------------------------------------------------
class TestProfilingModels:
def test_i_can_compute_avg_ms_when_no_iterations(self):
"""Test that avg_ms returns 0.0 on a fresh CumulativeSpan to avoid division by zero."""
cum = CumulativeSpan(name="empty")
assert cum.avg_ms == 0.0
assert cum.total_ms == 0.0
assert cum.count == 0
assert cum.min_ms == float('inf')
assert cum.max_ms == 0
@pytest.mark.parametrize("durations, expected_min, expected_max, expected_total, expected_avg", [
([10.0, 5.0, 8.0], 5.0, 10.0, 23.0, 23.0 / 3),
([1.0], 1.0, 1.0, 1.0, 1.0),
([3.0, 3.0, 3.0], 3.0, 3.0, 9.0, 3.0),
])
def test_i_can_aggregate_iterations_in_cumulative_span(
self, durations, expected_min, expected_max, expected_total, expected_avg
):
"""Test that CumulativeSpan correctly aggregates all metrics across iterations."""
cum = CumulativeSpan(name="probe")
for d in durations:
cum.record(d)
assert cum.count == len(durations)
assert cum.min_ms == expected_min
assert cum.max_ms == expected_max
assert cum.total_ms == expected_total
assert cum.avg_ms == pytest.approx(expected_avg)
def test_i_can_chain_set_calls_on_span(self, fresh_profiler):
"""Test that set() returns self to allow fluent chaining."""
p = fresh_profiler
with p.span("query") as s:
result = s.set("table", "orders").set("rows", 42)
assert result is s
assert s.data["table"] == "orders"
assert s.data["rows"] == 42
def test_i_can_access_span_data_after_context_exits(self, fresh_profiler):
"""Test that span data and duration persist after the with block has exited."""
p = fresh_profiler
with p.span("query") as s:
s.set("key", "value")
assert s.data["key"] == "value"
assert s.duration_ms > 0
# ---------------------------------------------------------------------------
# TestSpan — profiler.span() context manager and decorator
# ---------------------------------------------------------------------------
class TestSpan:
def test_i_can_create_a_span(self, fresh_profiler):
"""Test that a span is created with correct name and measured duration."""
p = fresh_profiler
with p.span("my_span") as s:
time.sleep(0.01)
assert isinstance(s, ProfilingSpan)
assert s.name == "my_span"
assert s.duration_ms >= 10
def test_i_can_nest_spans(self, fresh_profiler):
"""Test that a span created inside another becomes its child."""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("child") as child:
pass
assert len(parent.children) == 1
assert parent.children[0] is child
assert child.name == "child"
def test_i_can_have_sibling_spans(self, fresh_profiler):
"""Test that consecutive spans under the same parent are recorded as siblings."""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("child_a"):
pass
with p.span("child_b"):
pass
assert len(parent.children) == 2
assert parent.children[0].name == "child_a"
assert parent.children[1].name == "child_b"
def test_i_can_create_two_spans_with_same_name_under_same_parent(self, fresh_profiler):
"""Test that two normal spans with the same name under the same parent are two distinct entries.
This contrasts with cumulative spans, which merge same-name entries into one aggregated entry.
"""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("work"):
pass
with p.span("work"):
pass
assert len(parent.children) == 2, "Normal spans with the same name must remain separate entries"
assert parent.children[0] is not parent.children[1]
def test_i_can_use_same_span_name_under_different_parents(self, fresh_profiler):
"""Test that spans with the same name under different parents are independent objects."""
p = fresh_profiler
with p.span("root"):
with p.span("parent_a") as parent_a:
with p.span("work"):
pass
with p.span("parent_b") as parent_b:
with p.span("work"):
pass
span_a = parent_a.children[0]
span_b = parent_b.children[0]
assert span_a is not span_b, "Same name under different parents must be separate objects"
def test_i_can_use_span_as_decorator(self, fresh_profiler):
"""Test that @span wraps a function and captures all positional arguments."""
p = fresh_profiler
@p.span("decorated")
def my_func(x, y):
return x + y
with p.span("root") as root:
result = my_func(1, 2)
assert result == 3
assert len(root.children) == 1
child = root.children[0]
assert child.name == "decorated"
assert child.data.get("x") == "1"
assert child.data.get("y") == "2"
def test_i_can_capture_all_args_with_span_decorator(self, fresh_profiler):
"""Test that all positional and keyword arguments are captured by the @span decorator."""
p = fresh_profiler
@p.span("compute")
def my_func(x, y, z=10):
return x + y + z
with p.span("root") as root:
my_func(1, 2, z=3)
child = root.children[0]
assert child.data.get("x") == "1"
assert child.data.get("y") == "2"
assert child.data.get("z") == "3"
def test_i_can_exclude_self_from_captured_args_with_span_decorator(self, fresh_profiler):
"""Test that 'self' is not included in the captured args of a decorated method."""
p = fresh_profiler
class MyClass:
@p.span("method")
def my_method(self, value):
return value
with p.span("root") as root:
MyClass().my_method(42)
child = root.children[0]
assert "self" not in child.data
assert child.data.get("value") == "42"
def test_i_can_use_span_decorator_without_parent(self, fresh_profiler):
"""Test that a decorated function runs correctly with no active parent span."""
p = fresh_profiler
@p.span("solo")
def my_func():
return 42
result = my_func()
assert result == 42
def test_i_can_attach_data_to_span_via_context_manager(self, fresh_profiler):
"""Test that metadata can be attached to a span inside the with block."""
p = fresh_profiler
with p.span("query") as s:
s.set("row_count", 42)
s.set("table", "users")
assert s.data["row_count"] == 42
assert s.data["table"] == "users"
def test_i_can_attach_data_via_current_span(self, fresh_profiler):
"""Test that current_span().set() attaches metadata to the active span from anywhere."""
p = fresh_profiler
@p.span("process")
def process():
p.current_span().set("result", "ok")
with p.span("root") as root:
process()
child = root.children[0]
assert child.data["result"] == "ok"
def test_i_can_pass_args_to_span_context_manager(self, fresh_profiler):
"""Test that metadata can be pre-attached to a span via the args parameter."""
p = fresh_profiler
with p.span("query", args={"table": "orders"}) as s:
pass
assert s.data["table"] == "orders"
def test_i_can_get_none_from_current_span_when_no_span_active(self, fresh_profiler):
"""Test that current_span() returns None when called outside any span context."""
p = fresh_profiler
assert p.current_span() is None
def test_i_can_get_innermost_span_via_current_span(self, fresh_profiler):
"""Test that current_span() returns the innermost active span in nested contexts."""
p = fresh_profiler
with p.span("outer"):
with p.span("inner") as inner:
assert p.current_span() is inner
# ---------------------------------------------------------------------------
# TestCumulativeSpan — profiler.cumulative_span() context manager and decorator
# ---------------------------------------------------------------------------
class TestCumulativeSpan:
def test_i_can_use_cumulative_span(self, fresh_profiler):
"""Test that repeated iterations are aggregated into a single child entry."""
p = fresh_profiler
with p.span("loop") as loop_span:
for _ in range(5):
with p.cumulative_span("item"):
time.sleep(0.001)
assert len(loop_span.children) == 1
cum = loop_span.children[0]
assert isinstance(cum, CumulativeSpan)
assert cum.count == 5
assert cum.total_ms >= 5
assert cum.min_ms <= cum.avg_ms <= cum.max_ms
def test_i_can_use_cumulative_span_as_decorator(self, fresh_profiler):
"""Test that @cumulative_span aggregates all decorated function calls."""
p = fresh_profiler
@p.cumulative_span("item")
def process_item(x):
return x * 2
with p.span("loop") as loop_span:
for i in range(3):
process_item(i)
assert len(loop_span.children) == 1
cum = loop_span.children[0]
assert cum.count == 3
def test_i_can_continue_using_the_cumulative_span(self, fresh_profiler):
"""Test that the same cumulative span accumulates across separate loop blocks."""
p = fresh_profiler
with p.span("parent") as parent:
for _ in range(3):
with p.cumulative_span("reads"):
pass
for _ in range(2):
with p.cumulative_span("reads"):
pass
assert len(parent.children) == 1
reads = parent.children[0]
assert reads.count == 5
def test_i_can_have_two_cumulative_spans_with_different_names(self, fresh_profiler):
"""Test that two cumulative spans with different names create separate entries in the parent."""
p = fresh_profiler
with p.span("parent") as parent:
for _ in range(3):
with p.cumulative_span("reads"):
pass
for _ in range(2):
with p.cumulative_span("writes"):
pass
assert len(parent.children) == 2
reads = next(c for c in parent.children if c.name == "reads")
writes = next(c for c in parent.children if c.name == "writes")
assert reads.count == 3
assert writes.count == 2
def test_i_can_use_same_cumulative_span_name_under_different_parents(self, fresh_profiler):
"""Test that cumulative spans with the same name under different parents are independent."""
p = fresh_profiler
with p.span("root"):
with p.span("parent_a") as parent_a:
for _ in range(3):
with p.cumulative_span("items"):
pass
with p.span("parent_b") as parent_b:
for _ in range(2):
with p.cumulative_span("items"):
pass
items_a = parent_a.children[0]
items_b = parent_b.children[0]
assert items_a is not items_b, "Same name under different parents must be separate objects"
assert items_a.count == 3
assert items_b.count == 2
# ---------------------------------------------------------------------------
# TestCommandSpan — profiler.command_span() and trace recording
# ---------------------------------------------------------------------------
class TestCommandSpan:
def test_i_can_record_a_trace_via_command_span(self, fresh_profiler):
"""Test that command_span creates a complete trace with root span and children."""
p = fresh_profiler
with p.command_span("NavigateCell", "", "abc-123", {"row": "5"}):
with p.span("callback"):
time.sleep(0.01)
assert len(p.traces) == 1
trace = p.traces[0]
assert isinstance(trace, ProfilingTrace)
assert trace.command_name == "NavigateCell"
assert trace.command_id == "abc-123"
assert trace.kwargs == {"row": "5"}
assert trace.total_duration_ms >= 10
assert len(trace.root_span.children) == 1
assert trace.root_span.children[0].name == "callback"
def test_i_cannot_record_trace_when_profiler_disabled(self, fresh_profiler):
"""Test that command_span is a no-op when the profiler is disabled."""
p = fresh_profiler
p.enabled = False
with p.command_span("cmd", "", "id", {}):
pass
assert len(p.traces) == 0
def test_i_can_record_up_to_max_traces(self, fresh_profiler):
"""Test that the trace buffer respects the max_traces limit (FIFO eviction)."""
p = fresh_profiler
for i in range(15):
with p.command_span(f"cmd_{i}", "", str(i), {}):
pass
assert len(p.traces) == 10, "Buffer should cap at max_traces=10"
def test_i_can_access_trace_timestamp(self, fresh_profiler):
"""Test that a recorded trace contains a valid datetime timestamp."""
p = fresh_profiler
before = datetime.now()
with p.command_span("MyCmd", "", "id-001", {}):
pass
after = datetime.now()
trace = p.traces[0]
assert before <= trace.timestamp <= after
def test_i_can_verify_kwargs_are_copied_in_command_span(self, fresh_profiler):
"""Test that mutating the original kwargs dict does not affect the recorded trace."""
p = fresh_profiler
kwargs = {"row": "5", "col": "2"}
with p.command_span("MyCmd", "", "id-001", kwargs):
pass
kwargs["row"] = "99"
trace = p.traces[0]
assert trace.kwargs["row"] == "5"
def test_i_can_record_command_description_in_trace(self, fresh_profiler):
"""Test that the command description passed to command_span is stored in the trace."""
p = fresh_profiler
with p.command_span("NavigateCell", "Navigate to adjacent cell", "abc-123", {}):
pass
trace = p.traces[0]
assert trace.command_description == "Navigate to adjacent cell"
def test_i_can_record_empty_description_in_trace(self, fresh_profiler):
"""Test that an empty description is stored as-is in the trace."""
p = fresh_profiler
with p.command_span("MyCmd", "", "id-001", {}):
pass
trace = p.traces[0]
assert trace.command_description == ""
# ---------------------------------------------------------------------------
# TestTraceAll — profiler.trace_all() class decorator
# ---------------------------------------------------------------------------
class TestTraceAll:
def test_i_can_use_trace_all_on_class(self, fresh_profiler):
"""Test that trace_all wraps all non-dunder methods of a class."""
p = fresh_profiler
@p.trace_all
class MyClass:
def method_a(self):
return "a"
def method_b(self):
return "b"
def __repr__(self):
return "MyClass()"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
obj.method_b()
assert len(root.children) == 2
assert root.children[0].name == "method_a"
assert root.children[1].name == "method_b"
def test_i_can_use_trace_all_with_exclude(self, fresh_profiler):
"""Test that excluded methods are not wrapped by trace_all."""
p = fresh_profiler
@p.trace_all(exclude=["method_b"])
class MyClass:
def method_a(self):
return "a"
def method_b(self):
return "b"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
obj.method_b()
assert len(root.children) == 1
assert root.children[0].name == "method_a"
def test_i_can_confirm_trace_all_skips_dunder_methods(self, fresh_profiler):
"""Test that trace_all does not wrap dunder methods like __repr__."""
p = fresh_profiler
call_log = []
@p.trace_all
class MyClass:
def __repr__(self):
call_log.append("repr")
return "MyClass()"
def method_a(self):
return "a"
obj = MyClass()
with p.span("root") as root:
repr(obj)
obj.method_a()
child_names = [c.name for c in root.children]
assert "method_a" in child_names
assert "__repr__" not in child_names
assert "repr" in call_log
def test_i_can_use_trace_all_with_parentheses_and_no_exclude(self, fresh_profiler):
"""Test that @profiler.trace_all() with parentheses and no args behaves like @profiler.trace_all."""
p = fresh_profiler
@p.trace_all()
class MyClass:
def method_a(self):
return "a"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
assert len(root.children) == 1
assert root.children[0].name == "method_a"
# ---------------------------------------------------------------------------
# TestTraceCalls — profiler.trace_calls() function decorator
# ---------------------------------------------------------------------------
class TestTraceCalls:
def test_i_can_use_trace_calls_on_function(self, fresh_profiler):
"""Test that trace_calls traces all sub-calls as children of the decorated function span.
Verifies both direct children (helper_a, helper_b under main_func) and nested hierarchy
(grandchild under helper_a, not under main_func).
"""
p = fresh_profiler
def grandchild():
return 0
def helper_a():
grandchild()
return 1
def helper_b():
return 2
@p.trace_calls
def main_func():
helper_a()
helper_b()
return 42
with p.span("root") as root:
main_func()
assert len(root.children) == 1
main_span = root.children[0]
assert main_span.name == "main_func"
assert len(main_span.children) == 2, "main_func should have exactly 2 direct children"
child_names = [c.name for c in main_span.children]
assert "helper_a" in child_names
assert "helper_b" in child_names
helper_a_span = next(c for c in main_span.children if c.name == "helper_a")
assert len(helper_a_span.children) == 1, "grandchild must be nested under helper_a, not main_func"
assert helper_a_span.children[0].name == "grandchild"
def test_i_cannot_use_trace_calls_when_disabled(self, fresh_profiler):
"""Test that trace_calls creates no spans when the profiler is disabled at call time."""
p = fresh_profiler
@p.trace_calls
def main_func():
return 99
with p.span("root") as root:
p.enabled = False
main_func()
p.enabled = True
assert len(root.children) == 0, "trace_calls should not create spans when profiler is disabled"
def test_i_can_verify_trace_calls_captures_function_args(self, fresh_profiler):
"""Test that trace_calls captures the decorated function's arguments in the root span data."""
p = fresh_profiler
@p.trace_calls
def compute(x, y):
return x + y
with p.span("root") as root:
compute(3, 7)
main_span = root.children[0]
assert main_span.name == "compute"
assert main_span.data.get("x") == "3"
assert main_span.data.get("y") == "7"
# ---------------------------------------------------------------------------
# TestProfilingManager — enable/disable, clear, overhead
# ---------------------------------------------------------------------------
class TestProfilingManager:
def test_i_can_enable_disable_profiler(self, fresh_profiler):
"""Test that disabling the profiler makes span() return a no-op NullSpan."""
p = fresh_profiler
p.enabled = False
with p.span("ignored") as s:
pass
assert isinstance(s, _NullSpan)
def test_i_can_use_decorator_when_profiler_is_disabled(self, fresh_profiler):
"""Test that a @span decorated function still executes correctly when profiler is disabled."""
p = fresh_profiler
@p.span("my_span")
def my_func():
return "result"
p.enabled = False
result = my_func()
assert result == "result"
def test_i_can_toggle_profiler_at_runtime(self, fresh_profiler):
"""Test that spans are captured only while the profiler is enabled.
Three phases: enabled -> disabled -> re-enabled, verifying capture behavior at each step.
"""
p = fresh_profiler
@p.span("traced")
def my_func():
return 1
# Phase 1: enabled — span must be captured
with p.span("root") as root:
my_func()
assert len(root.children) == 1, "Span should be captured when profiler is enabled"
# Phase 2: disabled — p.span() returns NullSpan, nothing captured
p.enabled = False
with p.span("root_disabled") as root_disabled:
my_func()
assert isinstance(root_disabled, _NullSpan), "p.span() should return NullSpan when disabled"
# Phase 3: re-enabled — span must be captured again
p.enabled = True
with p.span("root_reenabled") as root_reenabled:
my_func()
assert len(root_reenabled.children) == 1, "Span should be captured again after re-enabling"
def test_i_can_clear_traces(self, fresh_profiler):
"""Test that clear() empties the trace buffer completely."""
p = fresh_profiler
with p.command_span("cmd", "", "uuid-1", {}):
pass
with p.command_span("cmd", "", "uuid-2", {}):
pass
assert len(p.traces) == 2
p.clear()
assert len(p.traces) == 0
def test_i_can_measure_overhead(self, fresh_profiler):
"""Test that overhead metrics are populated after spans are recorded."""
p = fresh_profiler
for _ in range(20):
with p.span("probe"):
pass
assert p.overhead_per_span_us >= 0
assert p.total_overhead_ms >= 0
def test_i_can_get_zero_overhead_when_no_samples(self, fresh_profiler):
"""Test that overhead_per_span_us returns 0.0 when no spans have been recorded."""
p = fresh_profiler
assert p.overhead_per_span_us == 0.0
def test_i_can_get_zero_total_overhead_when_buffer_empty(self, fresh_profiler):
"""Test that total_overhead_ms returns 0.0 when the trace buffer is empty."""
p = fresh_profiler
assert p.total_overhead_ms == 0.0