Compare commits
16 Commits
a4ebd6d61b
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea551bc1a | |||
| 3bcf50f55f | |||
| 7f099b14f6 | |||
| 0e1087a614 | |||
| d3c0381e34 | |||
| b8fd4e5ed1 | |||
| 72d6cce6ff | |||
| f887267362 | |||
| 853bc4abae | |||
| 2fcc225414 | |||
| ef9f269a49 | |||
| 0951680466 | |||
| 0c9c8bc7fa | |||
| feb9da50b2 | |||
| f773fd1611 | |||
| af83f4b6dc |
@@ -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
|
||||
)
|
||||
```
|
||||
|
||||
@@ -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
118
docs/Datagrid Tests.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# DataGrid Tests — Backlog
|
||||
|
||||
Source file: `tests/controls/test_datagrid.py`
|
||||
|
||||
Legend: ✅ Done — ⬜ Pending
|
||||
|
||||
---
|
||||
|
||||
## TestDataGridBehaviour
|
||||
|
||||
### Edition flow
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|---|--------|-----------------------------------------------------------|----------------------------------------------------------|
|
||||
| 1 | ⬜ | `test_i_can_convert_edition_value_for_number` | `"3.14"` → `float`, `"5"` → `int` |
|
||||
| 2 | ⬜ | `test_i_can_convert_edition_value_for_bool` | `"true"`, `"1"`, `"yes"` → `True`; others → `False` |
|
||||
| 3 | ⬜ | `test_i_can_convert_edition_value_for_text` | String value returned unchanged |
|
||||
| 4 | ⬜ | `test_i_can_handle_start_edition` | Sets `edition.under_edition` and returns a cell render |
|
||||
| 5 | ⬜ | `test_i_cannot_handle_start_edition_when_already_editing` | Second call while `under_edition` is set is a no-op |
|
||||
| 6 | ⬜ | `test_i_can_handle_save_edition` | Writes value to data service and clears `under_edition` |
|
||||
| 7 | ⬜ | `test_i_cannot_handle_save_edition_when_not_editing` | Returns partial render without touching the data service |
|
||||
|
||||
### Column management
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------------|----------------------------------------------------------------|
|
||||
| 8 | ⬜ | `test_i_can_add_new_column` | Appends column to `_state.columns` and `_columns` |
|
||||
| 9 | ⬜ | `test_i_can_handle_columns_reorder` | Reorders `_state.columns` according to provided list |
|
||||
| 10 | ⬜ | `test_i_can_handle_columns_reorder_ignores_unknown_ids` | Unknown IDs skipped; known columns not in list appended at end |
|
||||
|
||||
### Mouse selection
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-------------------------------------------------|---------------------------------------------------------------|
|
||||
| 11 | ⬜ | `test_i_can_on_mouse_selection_sets_range` | Sets `extra_selected` with `("range", ...)` from two cell IDs |
|
||||
| 12 | ⬜ | `test_i_cannot_on_mouse_selection_when_outside` | `is_inside=False` leaves `extra_selected` unchanged |
|
||||
|
||||
### Key pressed
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|--------------------------------------------------|----------------------------------------------------------------------------------------------|
|
||||
| 13 | ⬜ | `test_i_can_on_key_pressed_enter_starts_edition` | `enter` on selected cell enters edition when `enable_edition=True` and nothing under edition |
|
||||
|
||||
### Click
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------|-----------------------------------------------------------------|
|
||||
| 14 | ⬜ | `test_i_can_on_click_second_click_enters_edition` | Second click on already-selected cell triggers `_enter_edition` |
|
||||
|
||||
### Filtering / sorting
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|--------------------------------------------|-------------------------------------------------------------------------------------|
|
||||
| 15 | ⬜ | `test_i_can_filter_grid` | `filter()` updates `_state.filtered`; filtered DataFrame excludes non-matching rows |
|
||||
| 16 | ⬜ | `test_i_can_apply_sort` | `_apply_sort` returns rows in correct order when a sort definition is present |
|
||||
| 17 | ⬜ | `test_i_can_apply_filter_by_column_values` | Column filter (non-FILTER_INPUT) keeps only matching rows |
|
||||
|
||||
### Format rules priority
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|----------------------------------------------------------------------|------------------------------------------------------------------|
|
||||
| 18 | ⬜ | `test_i_can_get_format_rules_cell_level_takes_priority` | Cell format overrides row, column and table format |
|
||||
| 19 | ⬜ | `test_i_can_get_format_rules_row_level_takes_priority_over_column` | Row format overrides column and table when no cell format |
|
||||
| 20 | ⬜ | `test_i_can_get_format_rules_column_level_takes_priority_over_table` | Column format overrides table when no cell or row format |
|
||||
| 21 | ⬜ | `test_i_can_get_format_rules_falls_back_to_table_format` | Table format returned when no cell, row or column format defined |
|
||||
|
||||
---
|
||||
|
||||
## TestDataGridRender
|
||||
|
||||
### Table structure
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 22 | ✅ | `test_i_can_render_table_wrapper` | ID `tw_{id}`, class `dt2-table-wrapper`, 3 sections: selection manager, table, scrollbars |
|
||||
| 23 | ✅ | `test_i_can_render_table` | ID `t_{id}`, class `dt2-table`, 3 containers: header, body wrapper, footer |
|
||||
| 24 | ✅ | `test_i_can_render_table_has_scrollbars` | Scrollbars overlay contains vertical and horizontal tracks |
|
||||
|
||||
### render_partial fragments
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|---------------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| 25 | ✅ | `test_i_can_render_partial_body` | Returns `(selection_manager, body_wrapper)` — body wrapper has `hx-on::after-settle` |
|
||||
| 26 | ✅ | `test_i_can_render_partial_table` | Returns `(selection_manager, table)` — table has `hx-on::after-settle` |
|
||||
| 27 | ✅ | `test_i_can_render_partial_header` | Returns header with `hx-on::after-settle` containing `setColumnWidth` |
|
||||
| 28 | ✅ | `test_i_can_render_partial_cell_by_pos` | Returns `(selection_manager, cell)` for a specific `(col, row)` position |
|
||||
| 29 | ✅ | `test_i_can_render_partial_cell_with_no_position` | Returns only `(selection_manager,)` when no `pos` or `cell_id` given |
|
||||
|
||||
### Edition cell
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------|
|
||||
| 30 | ⬜ | `test_i_can_render_body_cell_in_edition_mode` | When `edition.under_edition` matches, `mk_body_cell` returns an input cell with class `dt2-cell-edition` |
|
||||
|
||||
### Cell content — search highlighting
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------------------------------------|-----------------------------------------------------------------|
|
||||
| 31 | ⬜ | `test_i_can_render_body_cell_content_with_search_highlight` | Matching keyword produces a `Span` with class `dt2-highlight-1` |
|
||||
| 32 | ⬜ | `test_i_can_render_body_cell_content_with_no_highlight_when_keyword_absent` | Non-matching keyword produces no `dt2-highlight-1` span |
|
||||
|
||||
### Footer
|
||||
|
||||
| # | Status | Test | Description |
|
||||
|----|--------|-----------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| 33 | ⬜ | `test_i_can_render_footers_wrapper` | `mk_footers` renders with ID `tf_{id}` and class `dt2-footer` |
|
||||
| 34 | ⬜ | `test_i_can_render_aggregation_cell_sum` | `mk_aggregation_cell` with `FooterAggregation.Sum` renders the sum value |
|
||||
| 35 | ⬜ | `test_i_cannot_render_aggregation_cell_for_hidden_column` | Hidden column returns `Div(cls="dt2-col-hidden")` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Class | Total | ✅ Done | ⬜ Pending |
|
||||
|-------------------------|--------|--------|-----------|
|
||||
| `TestDataGridBehaviour` | 21 | 0 | 21 |
|
||||
| `TestDataGridRender` | 14 | 8 | 6 |
|
||||
| **Total** | **35** | **8** | **27** |
|
||||
@@ -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
355
docs/Profiler.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Profiler — Design & Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Performance issues were identified during keyboard navigation in the DataGrid (173ms server-side
|
||||
per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bottleneck is
|
||||
server-side. A persistent, in-application profiling system is needed for continuous analysis
|
||||
across sessions and future investigations.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Phase | Item | Status |
|
||||
|-------|------|--------|
|
||||
| **Phase 1 — Core** | `profiler.py` — data model + probe mechanisms | ✅ Done |
|
||||
| **Phase 1 — Core** | `tests/core/test_profiler.py` — full test suite (7 classes) | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `utils.py` — Level A `command_span` | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `commands.py` — Level B phases | ⏳ Deferred |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — global layout (toolbar + list) | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — detail panel (span tree + pie) | ⏳ Pending |
|
||||
| **Phase 2 — Controls** | CSS `profiler.css` | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `ProfilerPieChart.py` | ⏳ Future |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Data Collection Strategy
|
||||
|
||||
Two complementary levels:
|
||||
|
||||
- **Level A** (route handler): One trace per `/myfasthtml/commands` call. Captures total
|
||||
server-side duration including lookup, execution, and HTMX swap overhead.
|
||||
- **Level B** (granular spans): Decomposition of each trace into named phases. Activated
|
||||
by placing probes in the code.
|
||||
|
||||
Both levels are active simultaneously. Level A gives the global picture; Level B gives the
|
||||
breakdown.
|
||||
|
||||
### Probe Mechanisms
|
||||
|
||||
Four complementary mechanisms, chosen based on the context:
|
||||
|
||||
#### 1. Context manager — partial block instrumentation
|
||||
|
||||
```python
|
||||
with profiler.span("oob_swap"):
|
||||
# only this block is timed
|
||||
result = build_oob_elements(...)
|
||||
```
|
||||
|
||||
Metadata can be attached during execution:
|
||||
|
||||
```python
|
||||
with profiler.span("query") as span:
|
||||
rows = db.query(...)
|
||||
span.set("row_count", len(rows))
|
||||
```
|
||||
|
||||
#### 2. Decorator — full function instrumentation
|
||||
|
||||
```python
|
||||
@profiler.span("callback")
|
||||
def execute_callback(self, client_response):
|
||||
...
|
||||
```
|
||||
|
||||
Function arguments are captured automatically. Metadata can be attached via `current_span()`:
|
||||
|
||||
```python
|
||||
@profiler.span("process")
|
||||
def process(self, rows):
|
||||
result = do_work(rows)
|
||||
profiler.current_span().set("row_count", len(result))
|
||||
return result
|
||||
```
|
||||
|
||||
#### 3. Cumulative span — loop instrumentation
|
||||
|
||||
For loops with many iterations. Aggregates instead of creating one span per iteration.
|
||||
|
||||
```python
|
||||
for row in rows:
|
||||
with profiler.cumulative_span("process_row"):
|
||||
process(row)
|
||||
|
||||
# or as a decorator
|
||||
@profiler.cumulative_span("process_row")
|
||||
def process_row(self, row):
|
||||
...
|
||||
```
|
||||
|
||||
Exposes: `count`, `total`, `min`, `max`, `avg`. Single entry in the trace tree regardless of
|
||||
iteration count.
|
||||
|
||||
#### 4. `trace_all` — class-level static instrumentation
|
||||
|
||||
Wraps all methods of a class at definition time. No runtime overhead beyond the spans themselves.
|
||||
|
||||
```python
|
||||
@profiler.trace_all
|
||||
class DataGrid(MultipleInstance):
|
||||
def navigate_cell(self, ...): # auto-spanned
|
||||
...
|
||||
|
||||
# Exclude specific methods
|
||||
@profiler.trace_all(exclude=["__ft__", "render"])
|
||||
class DataGrid(MultipleInstance):
|
||||
...
|
||||
```
|
||||
|
||||
Implementation: uses `inspect` to iterate over methods and wraps each with `@profiler.span()`.
|
||||
No `sys.settrace()` involved — pure static wrapping.
|
||||
|
||||
#### 5. `trace_calls` — sub-call exploration
|
||||
|
||||
Traces all function calls made within a single function, recursively. Used for exploration
|
||||
when the bottleneck location is unknown.
|
||||
|
||||
```python
|
||||
@profiler.trace_calls
|
||||
def navigate_cell(self, ...):
|
||||
self._update_selection() # auto-traced as child span
|
||||
self._compute_visible() # auto-traced as child span
|
||||
db.query(...) # auto-traced as child span
|
||||
```
|
||||
|
||||
Implementation: uses `sys.setprofile()` scoped to the decorated function's execution only.
|
||||
Overhead is localized to that function's call stack. This is an exploration tool — use it
|
||||
to identify hotspots, then replace with explicit probes.
|
||||
|
||||
### Span Hierarchy
|
||||
|
||||
Hierarchy is determined by code nesting via a `ContextVar` stack (async-safe). No explicit
|
||||
parent references required.
|
||||
|
||||
```python
|
||||
with profiler.span("execute"): # root
|
||||
with profiler.span("callback"): # child of execute
|
||||
result = self.callback(...)
|
||||
with profiler.span("oob_swap"): # sibling of callback
|
||||
...
|
||||
```
|
||||
|
||||
When a command calls another command, the second command's spans automatically become children
|
||||
of the first command's active span.
|
||||
|
||||
`profiler.current_span()` provides access to the active span from anywhere in the call stack.
|
||||
|
||||
### Storage
|
||||
|
||||
- **Scope**: Global (all sessions). Profiling measures server behavior, not per-user state.
|
||||
- **Structure**: `deque` with a configurable maximum size.
|
||||
- **Default size**: 500 traces (constant `PROFILER_MAX_TRACES`).
|
||||
- **Eviction**: Oldest traces are dropped when the buffer is full (FIFO).
|
||||
- **Persistence**: In-memory only. Lost on server restart.
|
||||
|
||||
### Toggle and Clear
|
||||
|
||||
- `profiler.enabled` — boolean flag. When `False`, all probe mechanisms are no-ops (zero overhead).
|
||||
- `profiler.clear()` — empties the trace buffer.
|
||||
- Both are controllable from the UI control.
|
||||
|
||||
### Overhead Measurement
|
||||
|
||||
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
|
||||
Exposes:
|
||||
|
||||
- `overhead_per_span_us` — average cost of one span boundary in microseconds
|
||||
- `total_overhead_ms` — estimated total overhead across all active spans
|
||||
|
||||
Visible in the UI to verify the profiler does not bias measurements significantly.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
ProfilingTrace
|
||||
command_name: str
|
||||
command_id: str
|
||||
kwargs: dict
|
||||
timestamp: datetime
|
||||
total_duration_ms: float
|
||||
root_span: ProfilingSpan
|
||||
|
||||
ProfilingSpan
|
||||
name: str
|
||||
start: float (perf_counter)
|
||||
duration_ms: float
|
||||
data: dict (attached via span.set())
|
||||
children: list[ProfilingSpan | CumulativeSpan]
|
||||
|
||||
CumulativeSpan
|
||||
name: str
|
||||
count: int
|
||||
total_ms: float
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
avg_ms: float
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Hooks
|
||||
|
||||
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
|
||||
|
||||
```python
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
with profiler.command_span(command.name, c_id, client_response or {}):
|
||||
return command.execute(client_response)
|
||||
```
|
||||
|
||||
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
|
||||
|
||||
Planned breakdown inside `Command.execute()`:
|
||||
|
||||
```python
|
||||
def execute(self, client_response=None):
|
||||
with profiler.span("before_commands"):
|
||||
...
|
||||
with profiler.span("callback"):
|
||||
result = self.callback(...)
|
||||
with profiler.span("after_commands"):
|
||||
...
|
||||
with profiler.span("oob_swap"):
|
||||
...
|
||||
```
|
||||
|
||||
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
|
||||
|
||||
---
|
||||
|
||||
## UI Control Design
|
||||
|
||||
### Control name: `Profiler` (SingleInstance)
|
||||
|
||||
Single entry point. Replaces the earlier `ProfilerList` name.
|
||||
|
||||
**Files:**
|
||||
- `src/myfasthtml/controls/Profiler.py`
|
||||
- `src/myfasthtml/assets/core/profiler.css`
|
||||
|
||||
### Layout
|
||||
|
||||
Split view using `Panel`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
|
||||
├──────────────────────┬──────────────────────────────┤
|
||||
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
|
||||
│ ──────────────────────│ ─────────────────────────────│
|
||||
│ NavigateCell 173ms … │ [Metadata card] │
|
||||
│ NavigateCell 168ms … │ [kwargs card] │
|
||||
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
|
||||
│ … │ │
|
||||
└──────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
|
||||
Direct `mk.icon()` calls:
|
||||
|
||||
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
|
||||
- **Clear**: delete icon, always red
|
||||
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
|
||||
|
||||
Overhead metrics displayed as plain text on the right side of the toolbar.
|
||||
|
||||
### Trace list (left panel)
|
||||
|
||||
Three columns: command name / duration (color-coded) / timestamp.
|
||||
Click on a row → update right panel via HTMX.
|
||||
|
||||
**Duration color thresholds:**
|
||||
- Green (`mf-profiler-fast`): < 20 ms
|
||||
- Orange (`mf-profiler-medium`): 20–100 ms
|
||||
- Red (`mf-profiler-slow`): > 100 ms
|
||||
|
||||
### Detail panel (right)
|
||||
|
||||
Two view modes, toggled by icons in the detail panel header:
|
||||
|
||||
1. **Tree view** (default): Properties-style cards (Metadata, kwargs) + span breakdown with
|
||||
proportional bars and indentation. Cumulative spans show `×N · min/avg/max` badge.
|
||||
2. **Pie view**: `ProfilerPieChart` control (future) — distribution of time across spans
|
||||
at the current zoom level.
|
||||
|
||||
The `Properties` control is used as-is for Metadata and kwargs cards.
|
||||
The span breakdown is custom rendering (not a `Properties` instance).
|
||||
|
||||
### Font conventions
|
||||
|
||||
- Labels, headings, command names: `--font-sans` (DaisyUI default)
|
||||
- Values (durations, timestamps, kwargs values): `--font-mono`
|
||||
- Consistent with `properties.css` (`mf-properties-value` uses `--default-mono-font-family`)
|
||||
|
||||
### Visual reference
|
||||
|
||||
Mockups available in `examples/`:
|
||||
- `profiler_mockup.html` — first iteration (monospace font everywhere)
|
||||
- `profiler_mockup_2.html` — **reference** (correct fonts, icon toolbar, tree/pie toggle)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 — Core ✅ Complete
|
||||
|
||||
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
|
||||
2. `ProfilingManager` with all probe mechanisms
|
||||
3. `profiler` singleton
|
||||
4. Hook into `utils.py` (Level A) ✅
|
||||
5. Hook into `commands.py` (Level B) — deferred
|
||||
|
||||
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
|
||||
|
||||
### Phase 2 — Controls
|
||||
|
||||
#### Step 2.1 — Global layout (current) 🔄
|
||||
|
||||
`src/myfasthtml/controls/Profiler.py`:
|
||||
- `SingleInstance` inheriting
|
||||
- Toolbar: `mk.icon()` for enable/disable and clear, overhead text
|
||||
- `Panel` for split layout
|
||||
- Left: trace list table (command / duration / timestamp), click → select_trace command
|
||||
- Right: placeholder (empty until Step 2.2)
|
||||
|
||||
`src/myfasthtml/assets/core/profiler.css`:
|
||||
- All `mf-profiler-*` classes
|
||||
|
||||
#### Step 2.2 — Detail panel ⏳
|
||||
|
||||
Right panel content:
|
||||
- Metadata and kwargs via `Properties`
|
||||
- Span tree: custom `_mk_span_tree()` with bars and cumulative badges
|
||||
- View toggle (tree / pie) in detail header
|
||||
|
||||
#### Step 2.3 — Pie chart ⏳ Future
|
||||
|
||||
`src/myfasthtml/controls/ProfilerPieChart.py`
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Control files: `ProfilerXxx.py`
|
||||
- CSS classes: `mf-profiler-xxx`
|
||||
- Logger: `logging.getLogger("Profiler")`
|
||||
- Constant: `PROFILER_MAX_TRACES = 500` in `src/myfasthtml/core/constants.py`
|
||||
@@ -181,13 +181,13 @@ Users can rename nodes via the edit button:
|
||||
|
||||
```python
|
||||
# Programmatically start rename
|
||||
tree._start_rename("node-id")
|
||||
tree.handle_start_rename("node-id")
|
||||
|
||||
# Save rename
|
||||
tree._save_rename("node-id", "New Label")
|
||||
tree.handle_save_rename("node-id", "New Label")
|
||||
|
||||
# Cancel rename
|
||||
tree._cancel_rename()
|
||||
tree.handle_cancel_rename()
|
||||
```
|
||||
|
||||
### Deleting Nodes
|
||||
@@ -201,7 +201,7 @@ Users can delete nodes via the delete button:
|
||||
|
||||
```python
|
||||
# Programmatically delete node
|
||||
tree._delete_node("node-id") # Raises ValueError if node has children
|
||||
tree.handle_delete_node("node-id") # Raises ValueError if node has children
|
||||
```
|
||||
|
||||
## Content System
|
||||
@@ -449,18 +449,20 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||
root = TreeNode(id="root", label="Tasks", type="folder")
|
||||
tree.add_node(root)
|
||||
|
||||
|
||||
# Function to handle selection
|
||||
def on_node_selected(node_id):
|
||||
# Custom logic when node is selected
|
||||
node = tree._state.items[node_id]
|
||||
tree._select_node(node_id)
|
||||
# Custom logic when node is selected
|
||||
node = tree._state.items[node_id]
|
||||
tree.handle_select_node(node_id)
|
||||
|
||||
# Update a detail panel elsewhere in the UI
|
||||
return Div(
|
||||
H3(f"Selected: {node.label}"),
|
||||
P(f"Type: {node.type}"),
|
||||
P(f"Children: {len(node.children)}")
|
||||
)
|
||||
|
||||
# Update a detail panel elsewhere in the UI
|
||||
return Div(
|
||||
H3(f"Selected: {node.label}"),
|
||||
P(f"Type: {node.type}"),
|
||||
P(f"Children: {len(node.children)}")
|
||||
)
|
||||
|
||||
# Override select command with custom handler
|
||||
# (In practice, you'd extend the Commands class or use event callbacks)
|
||||
|
||||
640
examples/profiler_mockup.html
Normal file
640
examples/profiler_mockup.html
Normal file
@@ -0,0 +1,640 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Profiler — UI Mockup</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base — mirrors DaisyUI dark theme CSS variables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--hcg-bg-main: #0d1117;
|
||||
--hcg-bg-button: rgba(22, 27, 34, 0.92);
|
||||
--hcg-border: #30363d;
|
||||
--hcg-text-muted: rgba(230, 237, 243, 0.5);
|
||||
--hcg-text-primary: #e6edf3;
|
||||
--hcg-node-bg: #1c2128;
|
||||
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
|
||||
|
||||
--profiler-danger: #f85149;
|
||||
--profiler-warn: #e3b341;
|
||||
--profiler-ok: #3fb950;
|
||||
--profiler-accent: #58a6ff;
|
||||
--profiler-muted: rgba(230, 237, 243, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--hcg-bg-main);
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--hcg-node-bg);
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-toolbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--hcg-text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.mf-profiler-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-bg-button);
|
||||
color: var(--hcg-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.mf-profiler-btn:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 80%, var(--profiler-accent) 20%);
|
||||
border-color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.active {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 40%);
|
||||
border-color: var(--profiler-ok);
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.danger {
|
||||
border-color: var(--profiler-danger);
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-btn.danger:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 30%);
|
||||
}
|
||||
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mf-profiler-overhead span b {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Split layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trace list (left) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 110px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 110px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 5%);
|
||||
}
|
||||
|
||||
.mf-profiler-row.selected {
|
||||
background: var(--hcg-node-bg-selected);
|
||||
border-left: 2px solid #f0883e;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-duration {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mf-profiler-duration.fast {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-ts {
|
||||
text-align: right;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Detail panel (right) — Properties-style */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header b {
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Properties-style cards */
|
||||
.mf-properties-group-card {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-properties-group-header {
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
}
|
||||
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-key {
|
||||
padding: 5px 10px;
|
||||
color: var(--hcg-text-muted);
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-properties-value {
|
||||
padding: 5px 10px;
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Span tree */
|
||||
.mf-profiler-span-tree {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-tree-header {
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 140px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(48, 54, 61, 0.6);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--profiler-accent);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.slow {
|
||||
background: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.medium {
|
||||
background: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: var(--hcg-text-muted);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Cumulative span badge */
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
border: 1px solid rgba(88, 166, 255, 0.3);
|
||||
color: var(--profiler-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--hcg-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Toolbar -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-toolbar">
|
||||
<span class="mf-profiler-toolbar-title">Profiler</span>
|
||||
|
||||
<button class="mf-profiler-btn active" onclick="toggleEnabled(this)">● Enabled</button>
|
||||
<button class="mf-profiler-btn danger" onclick="clearTraces()">Clear</button>
|
||||
|
||||
<div class="mf-profiler-overhead">
|
||||
<span>Overhead/span: <b>1.2 µs</b></span>
|
||||
<span>Total overhead: <b>0.04 ms</b></span>
|
||||
<span>Traces: <b id="trace-count">8</b> / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Body: list + detail -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-body">
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Trace list -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-list">
|
||||
<div class="mf-profiler-list-header">
|
||||
<span>Command</span>
|
||||
<span style="text-align:right">Duration</span>
|
||||
<span style="text-align:right">Time</span>
|
||||
</div>
|
||||
<div class="mf-profiler-list-body" id="trace-list">
|
||||
|
||||
<div class="mf-profiler-row selected" onclick="selectRow(this, 0)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">173.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.881</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 1)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">168.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.712</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 2)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration medium">42.7 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:06.501</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 3)">
|
||||
<span class="mf-profiler-cmd">FilterChanged</span>
|
||||
<span class="mf-profiler-duration medium">38.2 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:05.334</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 4)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">12.0 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:04.102</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 5)">
|
||||
<span class="mf-profiler-cmd">SortColumn</span>
|
||||
<span class="mf-profiler-duration fast">8.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:03.770</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 6)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration fast">5.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:02.441</span>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-row" onclick="selectRow(this, 7)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">4.8 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:01.003</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Detail panel -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-detail">
|
||||
<div class="mf-profiler-detail-header">
|
||||
Trace detail — <b>NavigateCell</b>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-detail-body">
|
||||
|
||||
<!-- Metadata (Properties-style) -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">Metadata</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">command</div>
|
||||
<div class="mf-properties-value">NavigateCell</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">total_duration_ms</div>
|
||||
<div class="mf-properties-value" style="color:var(--profiler-danger)">173.4</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">timestamp</div>
|
||||
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kwargs (Properties-style) -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">kwargs</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">row</div>
|
||||
<div class="mf-properties-value">12</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">col</div>
|
||||
<div class="mf-properties-value">3</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">direction</div>
|
||||
<div class="mf-properties-value">down</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Span tree -->
|
||||
<div class="mf-profiler-span-tree">
|
||||
<div class="mf-profiler-span-tree-header">Span breakdown</div>
|
||||
|
||||
<!-- Root span -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:0"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name" style="color:var(--hcg-text-primary);font-weight:600">NavigateCell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">173.4 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- before_commands -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">before_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:1%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">0.8 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- callback -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">callback</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">152.6 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigate_cell (child of callback) -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:32px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">navigate_cell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">149.0 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- process_row (cumulative, child of navigate_cell) -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:48px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">process_row</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms" style="color:var(--profiler-warn)">138.5 ms</span>
|
||||
</div>
|
||||
<span class="mf-profiler-cumulative-badge">×1000 · min 0.1 · avg 0.14 · max 0.4 ms</span>
|
||||
</div>
|
||||
|
||||
<!-- after_commands -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">after_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">10.3 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- oob_swap -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent"
|
||||
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
|
||||
<div class="mf-profiler-span-bar-wrap">
|
||||
<span class="mf-profiler-span-name">oob_swap</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:5%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">9.7 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.mf-profiler-span-tree -->
|
||||
|
||||
</div><!-- /.mf-profiler-detail-body -->
|
||||
</div><!-- /.mf-profiler-detail -->
|
||||
|
||||
</div><!-- /.mf-profiler-body -->
|
||||
|
||||
<script>
|
||||
function selectRow(el, index) {
|
||||
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
|
||||
function toggleEnabled(btn) {
|
||||
const enabled = btn.classList.toggle('active');
|
||||
btn.textContent = enabled ? '● Enabled' : '○ Disabled';
|
||||
}
|
||||
|
||||
function clearTraces() {
|
||||
document.getElementById('trace-list').innerHTML =
|
||||
'<div class="mf-profiler-empty">No traces recorded.</div>';
|
||||
document.getElementById('trace-count').textContent = '0';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
920
examples/profiler_mockup_2.html
Normal file
920
examples/profiler_mockup_2.html
Normal file
@@ -0,0 +1,920 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Profiler — UI Mockup 2</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base — mirrors DaisyUI CSS variables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--hcg-bg-main: #0d1117;
|
||||
--hcg-bg-button: rgba(22, 27, 34, 0.92);
|
||||
--hcg-border: #30363d;
|
||||
--hcg-text-muted: rgba(230, 237, 243, 0.45);
|
||||
--hcg-text-primary: #e6edf3;
|
||||
--hcg-node-bg: #1c2128;
|
||||
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
|
||||
|
||||
--profiler-danger: #f85149;
|
||||
--profiler-warn: #e3b341;
|
||||
--profiler-ok: #3fb950;
|
||||
--profiler-accent: #58a6ff;
|
||||
|
||||
/* Fonts — mirrors myfasthtml.css */
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.8125rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--hcg-bg-main);
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbar — icon-only, no Menu control */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 10px;
|
||||
background: var(--hcg-node-bg);
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--hcg-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Icon button — matches mk.icon() style */
|
||||
.mf-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--hcg-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mf-icon-btn:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 15%);
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-icon-btn.active {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-icon-btn.active:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 20%);
|
||||
}
|
||||
|
||||
.mf-icon-btn.danger {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-icon-btn.danger:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 20%);
|
||||
}
|
||||
|
||||
.mf-icon-btn.view-active {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 25%);
|
||||
color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.mf-icon-btn[data-tip]:hover::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2d333b;
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
color: var(--hcg-text-primary);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mf-profiler-overhead span b {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--profiler-warn);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Split layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Trace list (left) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 76px 100px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 76px 100px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 50%, var(--profiler-accent) 5%);
|
||||
}
|
||||
|
||||
.mf-profiler-row.selected {
|
||||
background: var(--hcg-node-bg-selected);
|
||||
border-left: 2px solid #f0883e;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.mf-profiler-duration {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.fast {
|
||||
color: var(--profiler-ok);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-duration.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-ts {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--hcg-text-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Detail panel (right) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
background: var(--hcg-node-bg);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
color: var(--hcg-text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title span {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--profiler-accent);
|
||||
}
|
||||
|
||||
/* View toggle in detail header */
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Properties-style cards (reuses properties.css variables) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-properties-group-card {
|
||||
background: var(--hcg-node-bg);
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-properties-group-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
|
||||
}
|
||||
|
||||
.mf-properties-key {
|
||||
padding: 4px 10px;
|
||||
color: var(--hcg-text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
border-right: 1px solid var(--hcg-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-properties-value {
|
||||
padding: 4px 10px;
|
||||
color: var(--hcg-text-primary);
|
||||
font-family: var(--font-mono); /* monospace for values */
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mf-properties-value.danger {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Span tree view */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-tree-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid rgba(48, 54, 61, 0.6);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 130px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name.root {
|
||||
font-weight: 600;
|
||||
color: var(--hcg-text-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: rgba(48, 54, 61, 0.7);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--profiler-accent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.slow {
|
||||
background: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.medium {
|
||||
background: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--hcg-text-muted);
|
||||
min-width: 58px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.slow {
|
||||
color: var(--profiler-danger);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.medium {
|
||||
color: var(--profiler-warn);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border: 1px solid rgba(88, 166, 255, 0.25);
|
||||
color: var(--profiler-accent);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pie chart view (placeholder) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.mf-profiler-pie-view {
|
||||
border: 1px solid var(--hcg-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-view.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-view-header {
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
|
||||
var(--hcg-node-bg) 100%
|
||||
);
|
||||
color: var(--hcg-text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hcg-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-placeholder {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* SVG pie slices — static mockup */
|
||||
.mf-profiler-pie-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-pie-legend-pct {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--hcg-text-muted);
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--hcg-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--hcg-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Toolbar — icon-only, no Menu -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-toolbar">
|
||||
|
||||
<!-- Enable / Disable toggle -->
|
||||
<button class="mf-icon-btn active" data-tip="Disable profiler" onclick="toggleEnabled(this)">
|
||||
<!-- Fluent: record_stop (enabled state) -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<circle cx="10" cy="10" r="5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Clear traces -->
|
||||
<button class="mf-icon-btn danger" data-tip="Clear traces" onclick="clearTraces()">
|
||||
<!-- Fluent: delete -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M8.5 4h3a.5.5 0 0 0-1 0h-1a.5.5 0 0 0-1 0Zm-1 0a1.5 1.5 0 0 1 3 0h3a.5.5 0 0 1 0 1h-.554l-.853 8.533A1.5 1.5 0 0 1 10.606 15H9.394a1.5 1.5 0 0 1-1.487-1.467L7.054 5H6.5a.5.5 0 0 1 0-1h1Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mf-profiler-toolbar-sep"></div>
|
||||
|
||||
<!-- Overhead metrics -->
|
||||
<div class="mf-profiler-overhead">
|
||||
<span>Overhead/span: <b>1.2 µs</b></span>
|
||||
<span>Total overhead: <b>0.04 ms</b></span>
|
||||
<span>Traces: <b id="trace-count">8</b> / 500</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Body: list + detail -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="mf-profiler-body">
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Trace list -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-list">
|
||||
<div class="mf-profiler-list-header">
|
||||
<span>Command</span>
|
||||
<span style="text-align:right">Duration</span>
|
||||
<span style="text-align:right">Time</span>
|
||||
</div>
|
||||
<div class="mf-profiler-list-body" id="trace-list">
|
||||
|
||||
<div class="mf-profiler-row selected" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">173.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.881</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration slow">168.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:07.712</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration medium">42.7 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:06.501</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">FilterChanged</span>
|
||||
<span class="mf-profiler-duration medium">38.2 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:05.334</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">12.0 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:04.102</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SortColumn</span>
|
||||
<span class="mf-profiler-duration fast">8.4 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:03.770</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">SelectRow</span>
|
||||
<span class="mf-profiler-duration fast">5.1 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:02.441</span>
|
||||
</div>
|
||||
<div class="mf-profiler-row" onclick="selectRow(this)">
|
||||
<span class="mf-profiler-cmd">NavigateCell</span>
|
||||
<span class="mf-profiler-duration fast">4.8 ms</span>
|
||||
<span class="mf-profiler-ts">14:32:01.003</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<!-- Detail panel -->
|
||||
<!-- ---------------------------------------------------------------- -->
|
||||
<div class="mf-profiler-detail">
|
||||
|
||||
<!-- Header with tree/pie toggle -->
|
||||
<div class="mf-profiler-detail-header">
|
||||
<span class="mf-profiler-detail-title">
|
||||
<span>NavigateCell</span> — 173.4 ms
|
||||
</span>
|
||||
<div class="mf-profiler-view-toggle">
|
||||
<!-- Tree view -->
|
||||
<button class="mf-icon-btn view-active" id="btn-tree" data-tip="Span tree"
|
||||
onclick="switchView('tree')">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h11A1.5 1.5 0 0 1 17 4.5v1A1.5 1.5 0 0 1 15.5 7h-11A1.5 1.5 0 0 1 3 5.5v-1ZM3 10a1.5 1.5 0 0 1 1.5-1.5h6A1.5 1.5 0 0 1 12 10v1a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 3 11v-1Zm0 5.5A1.5 1.5 0 0 1 4.5 14h4a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 18h-4A1.5 1.5 0 0 1 3 16.5v-1Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Pie view -->
|
||||
<button class="mf-icon-btn" id="btn-pie" data-tip="Pie chart"
|
||||
onclick="switchView('pie')">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 2a8 8 0 1 1 0 16A8 8 0 0 1 10 2Zm0 1.5A6.5 6.5 0 1 0 16.5 10H10a.5.5 0 0 1-.5-.5V3.5Zm1 .07V9h5.43A6.51 6.51 0 0 0 11 3.57Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mf-profiler-detail-body">
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">Metadata</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">command</div>
|
||||
<div class="mf-properties-value">NavigateCell</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">total_duration_ms</div>
|
||||
<div class="mf-properties-value danger">173.4</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">timestamp</div>
|
||||
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kwargs -->
|
||||
<div class="mf-properties-group-card">
|
||||
<div class="mf-properties-group-header">kwargs</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">row</div>
|
||||
<div class="mf-properties-value">12</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">col</div>
|
||||
<div class="mf-properties-value">3</div>
|
||||
</div>
|
||||
<div class="mf-properties-row">
|
||||
<div class="mf-properties-key">direction</div>
|
||||
<div class="mf-properties-value">down</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Span tree view -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="mf-profiler-span-tree" id="view-tree">
|
||||
<div class="mf-profiler-span-tree-header">Span breakdown</div>
|
||||
|
||||
<!-- Root -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name root">NavigateCell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">173.4 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- before_commands — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">before_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:0.5%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">0.8 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- callback — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">callback</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">152.6 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigate_cell — depth 2 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">navigate_cell</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms slow">149.0 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- process_row cumulative — depth 3 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">process_row</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms medium">138.5 ms</span>
|
||||
</div>
|
||||
<span class="mf-profiler-cumulative-badge">×1000 · min 0.10 · avg 0.14 · max 0.40 ms</span>
|
||||
</div>
|
||||
|
||||
<!-- after_commands — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">after_commands</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">10.3 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- oob_swap — depth 1 -->
|
||||
<div class="mf-profiler-span-row">
|
||||
<div class="mf-profiler-span-indent" style="width:14px"></div>
|
||||
<div class="mf-profiler-span-body">
|
||||
<span class="mf-profiler-span-name">oob_swap</span>
|
||||
<div class="mf-profiler-span-bar-bg">
|
||||
<div class="mf-profiler-span-bar" style="width:5.6%"></div>
|
||||
</div>
|
||||
<span class="mf-profiler-span-ms">9.7 ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#view-tree -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Pie chart view (placeholder for ProfilerPieChart) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="mf-profiler-pie-view" id="view-pie">
|
||||
<div class="mf-profiler-pie-view-header">Distribution</div>
|
||||
<div class="mf-profiler-pie-placeholder">
|
||||
|
||||
<!-- Static SVG pie mockup -->
|
||||
<svg width="160" height="160" viewBox="0 0 32 32">
|
||||
<!-- process_row: 80% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#e3b341" stroke-width="32"
|
||||
stroke-dasharray="80 100"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- callback overhead: 8% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#58a6ff" stroke-width="32"
|
||||
stroke-dasharray="8 100"
|
||||
stroke-dashoffset="-80"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- after_commands: 6% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#3fb950" stroke-width="32"
|
||||
stroke-dasharray="6 100"
|
||||
stroke-dashoffset="-88"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- oob_swap: 5.6% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#8b949e" stroke-width="32"
|
||||
stroke-dasharray="5.6 100"
|
||||
stroke-dashoffset="-94"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
<!-- before_commands: ~0.4% -->
|
||||
<circle r="16" cx="16" cy="16" fill="transparent"
|
||||
stroke="#6e7681" stroke-width="32"
|
||||
stroke-dasharray="0.4 100"
|
||||
stroke-dashoffset="-99.6"
|
||||
transform="rotate(-90) translate(-32)"/>
|
||||
</svg>
|
||||
|
||||
<div class="mf-profiler-pie-legend">
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#e3b341"></div>
|
||||
<span>process_row</span>
|
||||
<span class="mf-profiler-pie-legend-pct">80.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#58a6ff"></div>
|
||||
<span>callback</span>
|
||||
<span class="mf-profiler-pie-legend-pct">8.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#3fb950"></div>
|
||||
<span>after_commands</span>
|
||||
<span class="mf-profiler-pie-legend-pct">6.0%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#8b949e"></div>
|
||||
<span>oob_swap</span>
|
||||
<span class="mf-profiler-pie-legend-pct">5.6%</span>
|
||||
</div>
|
||||
<div class="mf-profiler-pie-legend-item">
|
||||
<div class="mf-profiler-pie-legend-color" style="background:#6e7681"></div>
|
||||
<span>before_commands</span>
|
||||
<span class="mf-profiler-pie-legend-pct">0.4%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /#view-pie -->
|
||||
|
||||
</div><!-- /.mf-profiler-detail-body -->
|
||||
</div><!-- /.mf-profiler-detail -->
|
||||
|
||||
</div><!-- /.mf-profiler-body -->
|
||||
|
||||
<script>
|
||||
function selectRow(el) {
|
||||
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
|
||||
function toggleEnabled(btn) {
|
||||
const isEnabled = btn.classList.toggle('active');
|
||||
btn.setAttribute('data-tip', isEnabled ? 'Disable profiler' : 'Enable profiler');
|
||||
// Icon swap: filled circle = recording, ring = stopped
|
||||
btn.querySelector('svg').innerHTML = isEnabled
|
||||
? '<circle cx="10" cy="10" r="5"/>'
|
||||
: '<circle cx="10" cy="10" r="5" fill="none" stroke="currentColor" stroke-width="2"/>';
|
||||
}
|
||||
|
||||
function clearTraces() {
|
||||
document.getElementById('trace-list').innerHTML =
|
||||
'<div class="mf-profiler-empty">No traces recorded.</div>';
|
||||
document.getElementById('trace-count').textContent = '0';
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
const treeEl = document.getElementById('view-tree');
|
||||
const pieEl = document.getElementById('view-pie');
|
||||
const btnTree = document.getElementById('btn-tree');
|
||||
const btnPie = document.getElementById('btn-pie');
|
||||
|
||||
if (view === 'tree') {
|
||||
treeEl.style.display = '';
|
||||
pieEl.classList.remove('visible');
|
||||
btnTree.classList.add('view-active');
|
||||
btnPie.classList.remove('view-active');
|
||||
} else {
|
||||
treeEl.style.display = 'none';
|
||||
pieEl.classList.add('visible');
|
||||
btnPie.classList.add('view-active');
|
||||
btnTree.classList.remove('view-active');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
src/app.py
17
src/app.py
@@ -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")
|
||||
|
||||
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* HTMX debug tracing — toggle with window.HTMX_DEBUG = true in the browser console.
|
||||
*
|
||||
* Each request gets a unique ID (#1, #2, ...). Timings are deltas from beforeRequest.
|
||||
*
|
||||
* Full event sequence (→ = network boundary):
|
||||
* beforeRequest — request about to be sent
|
||||
* beforeSend — XHR about to be sent (after HTMX setup)
|
||||
* → network round-trip ←
|
||||
* beforeOnLoad — response received, HTMX about to process it
|
||||
* beforeSwap — DOM swap about to happen
|
||||
* afterSwap — DOM swap done
|
||||
* afterSettle — settle phase complete
|
||||
* afterRequest — full request lifecycle complete
|
||||
* sendError — network error
|
||||
* responseError — non-2xx response
|
||||
*/
|
||||
|
||||
window.HTMX_DEBUG = false;
|
||||
(function () {
|
||||
console.log('Debug HTMX: htmx.logAll();');
|
||||
console.log('Perf HTMX: window.HTMX_DEBUG=true;');
|
||||
})();
|
||||
|
||||
(function () {
|
||||
const EVENTS = [
|
||||
'htmx:beforeRequest',
|
||||
'htmx:beforeSend',
|
||||
'htmx:beforeOnLoad',
|
||||
'htmx:beforeSwap',
|
||||
'htmx:afterSwap',
|
||||
'htmx:afterSettle',
|
||||
'htmx:afterRequest',
|
||||
'htmx:sendError',
|
||||
'htmx:responseError',
|
||||
];
|
||||
|
||||
let counter = 0;
|
||||
const requests = new WeakMap();
|
||||
|
||||
function getInfo(detail) {
|
||||
const key = detail?.requestConfig ?? detail?.xhr ?? null;
|
||||
if (!key || !requests.has(key)) return null;
|
||||
return requests.get(key);
|
||||
}
|
||||
|
||||
EVENTS.forEach(eventName => {
|
||||
document.addEventListener(eventName, (e) => {
|
||||
if (!window.HTMX_DEBUG) return;
|
||||
|
||||
const short = eventName.replace('htmx:', '').padEnd(14);
|
||||
const path = e.detail?.requestConfig?.path ?? e.detail?.pathInfo?.requestPath ?? '';
|
||||
const isError = eventName === 'htmx:sendError' || eventName === 'htmx:responseError';
|
||||
|
||||
let prefix;
|
||||
|
||||
if (eventName === 'htmx:beforeRequest') {
|
||||
const key = e.detail?.requestConfig ?? null;
|
||||
if (key) {
|
||||
const id = ++counter;
|
||||
const now = performance.now();
|
||||
requests.set(key, {id, start: now, last: now});
|
||||
prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`;
|
||||
} else {
|
||||
prefix = `# ? + 0.0ms (Δ 0.0ms)`;
|
||||
}
|
||||
} else {
|
||||
const info = getInfo(e.detail);
|
||||
if (info) {
|
||||
const now = performance.now();
|
||||
const total = (now - info.start).toFixed(1);
|
||||
const step = (now - info.last).toFixed(1);
|
||||
info.last = now;
|
||||
prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`;
|
||||
} else {
|
||||
prefix = `# ? + ?.?ms (Δ ?.?ms)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
console.warn(`[HTMX] ${prefix} ${short}`, path, e.detail);
|
||||
} else {
|
||||
console.debug(`[HTMX] ${prefix} ${short}`, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
340
src/myfasthtml/assets/core/profiler.css
Normal file
340
src/myfasthtml/assets/core/profiler.css
Normal file
@@ -0,0 +1,340 @@
|
||||
/* ================================================================== */
|
||||
/* Profiler Control */
|
||||
/* ================================================================== */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Root wrapper — fills parent, stacks toolbar above panel
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Toolbar
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Danger variant for clear button */
|
||||
.mf-profiler-btn-danger {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-btn-danger:hover {
|
||||
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Overhead metrics — right-aligned text */
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace list — left panel content
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-col-header {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace row
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected {
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Command name + description cell */
|
||||
.mf-profiler-cmd-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd-description {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-style: italic;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Duration cell — monospace, color-coded */
|
||||
.mf-profiler-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-fast {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Timestamp cell */
|
||||
.mf-profiler-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Detail panel — right side
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-cmd {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-view-btn-active {
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Span tree — inside a Properties group card
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 120px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name-root {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: color-mix(in oklab, var(--color-border) 80%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-medium {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-slow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Empty state
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
|
||||
Keep the boundaries updated
|
||||
"""
|
||||
|
||||
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(owner, _id=_id)
|
||||
self._owner = owner
|
||||
self._container_id = container_id or owner.get_id()
|
||||
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._owner = parent
|
||||
self._container_id = container_id or parent.get_id()
|
||||
self._on_resize = on_resize
|
||||
self._commands = Commands(self)
|
||||
self._state = BoundariesState()
|
||||
|
||||
@@ -1,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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
423
src/myfasthtml/controls/Profiler.py
Normal file
423
src/myfasthtml/controls/Profiler.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||||
from myfasthtml.icons.fluent import (
|
||||
arrow_clockwise20_regular,
|
||||
data_pie24_regular,
|
||||
text_bullet_list_tree20_filled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Span tree renderer — module-level, passed via PropertiesConf.types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mk_span_rows(span, depth: int, total_ms: float) -> list:
|
||||
"""Recursively build span rows for the tree view.
|
||||
|
||||
Args:
|
||||
span: A ProfilingSpan or CumulativeSpan to render.
|
||||
depth: Current nesting depth (controls indentation).
|
||||
total_ms: Reference duration used to compute bar widths.
|
||||
|
||||
Returns:
|
||||
List of FT elements, one per span row (depth-first order).
|
||||
"""
|
||||
rows = []
|
||||
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
|
||||
|
||||
if isinstance(span, CumulativeSpan):
|
||||
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.total_ms)
|
||||
badge = Span(
|
||||
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
|
||||
cls="mf-profiler-cumulative-badge",
|
||||
)
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls="mf-profiler-span-name"),
|
||||
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
badge,
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
else:
|
||||
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.duration_ms)
|
||||
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls=name_cls),
|
||||
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
for child in span.children:
|
||||
rows.extend(_mk_span_rows(child, depth + 1, total_ms))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _span_duration_cls(duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a span duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
|
||||
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
|
||||
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
|
||||
|
||||
Args:
|
||||
span: The root span to render as a tree.
|
||||
trace: The parent trace, used to compute proportional bar widths.
|
||||
|
||||
Returns:
|
||||
A FT element containing the full span tree.
|
||||
"""
|
||||
rows = _mk_span_rows(span, 0, trace.total_duration_ms)
|
||||
return Div(*rows, cls="mf-profiler-span-tree-content")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
def toggle_detail_view(self):
|
||||
return Command(
|
||||
"ProfilerToggleDetailView",
|
||||
"Switch between tree and pie view",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_detail_view,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def toggle_enable(self):
|
||||
return Command(
|
||||
"ProfilerToggleEnable",
|
||||
"Enable / Disable profiler",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_enable,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def clear_traces(self):
|
||||
return Command(
|
||||
"ProfilerClearTraces",
|
||||
"Clear all recorded traces",
|
||||
self._owner,
|
||||
self._owner.handle_clear_traces,
|
||||
icon=IconsHelper.get(MediaActions.Cancel),
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def refresh(self):
|
||||
return Command(
|
||||
"ProfilerRefresh",
|
||||
"Refresh traces",
|
||||
self._owner,
|
||||
self._owner.handle_refresh,
|
||||
icon=arrow_clockwise20_regular,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def select_trace(self, trace_id: str):
|
||||
return Command(
|
||||
"ProfilerSelectTrace",
|
||||
"Display trace details",
|
||||
self._owner,
|
||||
self._owner.handle_select_trace,
|
||||
kwargs={"trace_id": trace_id},
|
||||
).htmx(target=f"#tr_{trace_id}")
|
||||
|
||||
|
||||
class Profiler(SingleInstance):
|
||||
"""In-application profiler UI.
|
||||
|
||||
Displays all recorded traces in a scrollable list (left) and trace
|
||||
details in a resizable panel (right). The toolbar provides enable /
|
||||
disable toggle and clear actions via icon-only buttons.
|
||||
|
||||
Attributes:
|
||||
commands: Commands exposed by this control.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||||
self._panel.set_side_visible("right", True)
|
||||
self._selected_id: str | None = None
|
||||
self._detail_view: str = "tree"
|
||||
self.commands = Commands(self)
|
||||
logger.debug(f"Profiler created with id={self._id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_toggle_enable(self):
|
||||
"""Toggle profiler.enabled and re-render."""
|
||||
profiler.enabled = not profiler.enabled
|
||||
logger.debug(f"Profiler enabled set to {profiler.enabled}")
|
||||
return self
|
||||
|
||||
def handle_clear_traces(self):
|
||||
"""Clear the trace buffer and re-render."""
|
||||
profiler.clear()
|
||||
logger.debug("Profiler traces cleared from UI")
|
||||
return self
|
||||
|
||||
def handle_select_trace(self, trace_id: str):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
if self._selected_id is not None:
|
||||
old_trace = next(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
|
||||
else:
|
||||
old_trace = None
|
||||
|
||||
self._selected_id = trace_id
|
||||
trace = next(trace for trace in profiler.traces if trace.trace_id == trace_id)
|
||||
|
||||
return (self._mk_trace_item(trace),
|
||||
self._mk_trace_item(old_trace),
|
||||
self._panel.set_right(self._mk_right_panel(trace)))
|
||||
|
||||
def handle_toggle_detail_view(self):
|
||||
"""Toggle detail panel between tree and pie view."""
|
||||
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
|
||||
logger.debug(f"Profiler detail view set to {self._detail_view}")
|
||||
return self
|
||||
|
||||
def handle_refresh(self):
|
||||
"""Refresh the trace list without changing selection."""
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private rendering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _duration_cls(self, duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a given duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
def _mk_toolbar(self):
|
||||
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
|
||||
enable_icon = (
|
||||
IconsHelper.get(MediaActions.Pause)
|
||||
if profiler.enabled
|
||||
else IconsHelper.get(MediaActions.Play)
|
||||
)
|
||||
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
|
||||
|
||||
overhead = (
|
||||
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
|
||||
f"Total: {profiler.total_overhead_ms:.3f} ms "
|
||||
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
|
||||
)
|
||||
|
||||
return Div(
|
||||
mk.icon(
|
||||
enable_icon,
|
||||
command=self.commands.toggle_enable(),
|
||||
tooltip=enable_tooltip,
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.clear_traces(),
|
||||
tooltip="Clear traces",
|
||||
cls="mf-profiler-btn-danger",
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.refresh(),
|
||||
),
|
||||
Span(overhead, cls="mf-profiler-overhead"),
|
||||
cls="mf-profiler-toolbar",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def _mk_trace_item(self, trace: ProfilingTrace):
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
return mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
id=f"tr_{trace.trace_id}",
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
|
||||
def _mk_trace_list(self):
|
||||
"""Build the trace list with one clickable row per recorded trace."""
|
||||
traces = profiler.traces
|
||||
if not traces:
|
||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||
|
||||
rows = [self._mk_trace_item(trace) for trace in reversed(traces)]
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
Span("Command", cls="mf-profiler-col-header"),
|
||||
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
cls="mf-profiler-list-header",
|
||||
),
|
||||
Div(*rows, cls="mf-profiler-list-body"),
|
||||
cls="mf-profiler-list",
|
||||
)
|
||||
|
||||
def _mk_detail_placeholder(self):
|
||||
"""Placeholder shown in the right panel before a trace is selected."""
|
||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||||
|
||||
def _mk_detail_header(self, trace: "ProfilingTrace"):
|
||||
"""Build the detail panel header with title and tree/pie toggle.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail header.
|
||||
"""
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
title = Div(
|
||||
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
|
||||
Span(f" — {trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
|
||||
cls="mf-profiler-detail-title",
|
||||
)
|
||||
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
|
||||
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
|
||||
toggle = Div(
|
||||
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree",
|
||||
cls=tree_cls),
|
||||
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)",
|
||||
cls=pie_cls),
|
||||
cls="mf-profiler-view-toggle",
|
||||
)
|
||||
return Div(title, toggle, cls="mf-profiler-detail-header")
|
||||
|
||||
def _mk_detail_body(self, trace: "ProfilingTrace"):
|
||||
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail body.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
meta_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Metadata": {
|
||||
"command": "command_name",
|
||||
"description": "command_description",
|
||||
"duration_ms": "total_duration_ms",
|
||||
"timestamp": "timestamp",
|
||||
}},
|
||||
),
|
||||
_id="-detail-meta",
|
||||
)
|
||||
|
||||
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
|
||||
kwargs_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
|
||||
_id="-detail-kwargs",
|
||||
)
|
||||
|
||||
span_props = None
|
||||
if trace.root_span is not None:
|
||||
span_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Span breakdown": {"root_span": "root_span"}},
|
||||
types={ProfilingSpan: _span_tree_renderer},
|
||||
),
|
||||
_id="-detail-spans",
|
||||
)
|
||||
|
||||
if self._detail_view == "pie":
|
||||
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
|
||||
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
|
||||
|
||||
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
|
||||
|
||||
def _mk_detail_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the full detail panel for a selected trace.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail panel.
|
||||
"""
|
||||
return Div(
|
||||
self._mk_detail_header(trace),
|
||||
self._mk_detail_body(trace),
|
||||
cls="mf-profiler-detail",
|
||||
)
|
||||
|
||||
def _mk_right_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the right panel with a trace detail view."""
|
||||
return (
|
||||
self._mk_detail_panel(trace)
|
||||
if trace is not None
|
||||
else self._mk_detail_placeholder()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self):
|
||||
selected_trace = None
|
||||
if self._selected_id is not None:
|
||||
selected_trace = next(
|
||||
(t for t in profiler.traces if t.trace_id == self._selected_id), None
|
||||
)
|
||||
|
||||
self._panel.set_main(self._mk_trace_list())
|
||||
self._panel.set_right(self._mk_right_panel(selected_trace))
|
||||
return Div(
|
||||
self._mk_toolbar(),
|
||||
self._panel,
|
||||
id=self._id,
|
||||
cls="mf-profiler",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,21 +1,42 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertiesConf:
|
||||
"""Declarative configuration for the Properties control.
|
||||
|
||||
Attributes:
|
||||
obj: The Python object whose attributes are displayed.
|
||||
groups: Mapping of group name to ProxyObject spec.
|
||||
types: Mapping of Python type to renderer callable.
|
||||
Each renderer has the signature ``(value, obj) -> FT``.
|
||||
"""
|
||||
|
||||
obj: Any = None
|
||||
groups: Optional[dict] = None
|
||||
types: Optional[dict] = field(default=None)
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
def __init__(self, parent, conf: PropertiesConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
self.conf = conf or PropertiesConf()
|
||||
self._refresh()
|
||||
|
||||
def set_conf(self, conf: PropertiesConf):
|
||||
self.conf = conf
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
self._types = self.conf.types or {}
|
||||
self._properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(value, t):
|
||||
return renderer(value, self.conf.obj)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
|
||||
def _render_group_content(self, proxy) -> Div:
|
||||
"""Render a group's content.
|
||||
|
||||
When the group contains exactly one property whose type is registered in
|
||||
``conf.types``, the type renderer replaces the entire group content (not
|
||||
just the value cell). This lets custom renderers (e.g. span trees) fill
|
||||
the full card width without a key/value row wrapper.
|
||||
|
||||
Otherwise, the standard key/value row layout is used.
|
||||
|
||||
Args:
|
||||
proxy: ProxyObject for this group.
|
||||
|
||||
Returns:
|
||||
A FT element containing the group content.
|
||||
"""
|
||||
properties = proxy.as_dict()
|
||||
if len(properties) == 1:
|
||||
k, v = next(iter(properties.items()))
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(v, t):
|
||||
return renderer(v, self.conf.obj)
|
||||
return self._mk_group_content(properties)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
self._render_group_content(proxy),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
for group_name, proxy in self._properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
if self.conf.groups is None:
|
||||
return {None: ProxyObject(self.conf.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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("_")]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
560
src/myfasthtml/core/profiler.py
Normal file
560
src/myfasthtml/core/profiler.py
Normal 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()
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
163
tests/controls/test_Keyboard.py
Normal file
163
tests/controls/test_Keyboard.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
429
tests/controls/test_datagrids_formatting_manager.py
Normal file
429
tests/controls/test_datagrids_formatting_manager.py
Normal 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)))
|
||||
@@ -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()
|
||||
|
||||
|
||||
255
tests/controls/test_profiler.py
Normal file
255
tests/controls/test_profiler.py
Normal 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}'"
|
||||
@@ -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()
|
||||
|
||||
@@ -41,4 +41,4 @@ def db_manager(parent):
|
||||
|
||||
@pytest.fixture
|
||||
def dsm(parent, db_manager):
|
||||
return DataServicesManager(parent, parent._session)
|
||||
return DataServicesManager(parent)
|
||||
|
||||
@@ -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
678
tests/core/test_profiler.py
Normal 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
|
||||
Reference in New Issue
Block a user