First version of Profiler control with right part
This commit is contained in:
@@ -162,6 +162,169 @@
|
|||||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
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
|
Empty state
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
|
|||||||
hide_icon,
|
hide_icon,
|
||||||
Div(content, id=self._ids.content(side)),
|
Div(content, id=self._ids.content(side)),
|
||||||
cls=panel_cls,
|
cls=panel_cls,
|
||||||
style=f"width: {self._state.left_width}px;",
|
style=f"width: {self._state.right_width}px;",
|
||||||
id=self._ids.panel(side)
|
id=self._ids.panel(side)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,113 @@ from fasthtml.components import Div, Span
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
|
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.profiler import profiler
|
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||||||
from myfasthtml.icons.fluent import arrow_clockwise20_regular
|
from myfasthtml.icons.fluent import (
|
||||||
|
arrow_clockwise20_regular,
|
||||||
|
data_pie24_regular,
|
||||||
|
text_bullet_list_tree20_filled,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("Profiler")
|
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):
|
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):
|
def toggle_enable(self):
|
||||||
return Command(
|
return Command(
|
||||||
"ProfilerToggleEnable",
|
"ProfilerToggleEnable",
|
||||||
@@ -67,7 +162,9 @@ class Profiler(SingleInstance):
|
|||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
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._selected_id: str | None = None
|
||||||
|
self._detail_view: str = "tree"
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
logger.debug(f"Profiler created with id={self._id}")
|
logger.debug(f"Profiler created with id={self._id}")
|
||||||
|
|
||||||
@@ -92,8 +189,14 @@ class Profiler(SingleInstance):
|
|||||||
self._selected_id = trace_id
|
self._selected_id = trace_id
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
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):
|
def handle_refresh(self):
|
||||||
"""Select a trace row and re-render to show it highlighted."""
|
"""Refresh the trace list without changing selection."""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -149,7 +252,7 @@ class Profiler(SingleInstance):
|
|||||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for trace in traces:
|
for trace in reversed(traces):
|
||||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
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"
|
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||||
@@ -184,13 +287,115 @@ class Profiler(SingleInstance):
|
|||||||
"""Placeholder shown in the right panel before a trace is selected."""
|
"""Placeholder shown in the right panel before a trace is selected."""
|
||||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
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,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Render
|
# Render
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def render(self):
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
right_panel = (
|
||||||
|
self._mk_detail_panel(selected_trace)
|
||||||
|
if selected_trace is not None
|
||||||
|
else self._mk_detail_placeholder()
|
||||||
|
)
|
||||||
|
|
||||||
self._panel.set_main(self._mk_trace_list())
|
self._panel.set_main(self._mk_trace_list())
|
||||||
self._panel.set_right(self._mk_detail_placeholder())
|
self._panel.set_right(right_panel)
|
||||||
return Div(
|
return Div(
|
||||||
self._mk_toolbar(),
|
self._mk_toolbar(),
|
||||||
self._panel,
|
self._panel,
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fasthtml.components import Div
|
from fasthtml.components import Div
|
||||||
from myutils.ProxyObject import ProxyObject
|
from myutils.ProxyObject import ProxyObject
|
||||||
|
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
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):
|
class Properties(MultipleInstance):
|
||||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
def __init__(self, parent, obj=None, groups: dict = None, conf: PropertiesConf = None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.obj = obj
|
if conf is not None:
|
||||||
self.groups = groups
|
self.obj = conf.obj
|
||||||
|
self.groups = conf.groups
|
||||||
|
self._types = conf.types or {}
|
||||||
|
else:
|
||||||
|
self.obj = obj
|
||||||
|
self.groups = groups
|
||||||
|
self._types = {}
|
||||||
self.properties_by_group = self._create_properties_by_group()
|
self.properties_by_group = self._create_properties_by_group()
|
||||||
|
|
||||||
def set_obj(self, obj, groups: dict = None):
|
def set_obj(self, obj, groups: dict = None):
|
||||||
@@ -30,6 +55,10 @@ class Properties(MultipleInstance):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_property_value(self, value):
|
def _mk_property_value(self, value):
|
||||||
|
for t, renderer in self._types.items():
|
||||||
|
if isinstance(value, t):
|
||||||
|
return renderer(value, self.obj)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return self._mk_group_content(value)
|
return self._mk_group_content(value)
|
||||||
|
|
||||||
@@ -40,13 +69,37 @@ class Properties(MultipleInstance):
|
|||||||
cls="mf-properties-value",
|
cls="mf-properties-value",
|
||||||
title=str(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
|
||||||
|
``_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.obj)
|
||||||
|
return self._mk_group_content(properties)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
*[
|
*[
|
||||||
Div(
|
Div(
|
||||||
Div(
|
Div(
|
||||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
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-container"
|
||||||
),
|
),
|
||||||
cls="mf-properties-group-card"
|
cls="mf-properties-group-card"
|
||||||
|
|||||||
Reference in New Issue
Block a user