From d44e0a0c01ddf9d749989598782ece2e01455f04 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 8 Feb 2026 19:50:10 +0100 Subject: [PATCH] Fixed command id collision. Added class support in style preset --- docs/DataGrid Formatting System.md | 117 +- docs/Panel.md | 2 +- src/myfasthtml/assets/core/dsleditor.css | 44 + src/myfasthtml/assets/core/myfasthtml.js | 1 + src/myfasthtml/assets/myfasthtml.css | 901 ------ src/myfasthtml/assets/myfasthtml.js | 2427 ----------------- src/myfasthtml/controls/DataGrid.py | 25 +- src/myfasthtml/core/commands.py | 40 +- .../completion/FormattingCompletionEngine.py | 12 +- src/myfasthtml/core/formatting/engine.py | 410 ++- src/myfasthtml/core/formatting/presets.py | 25 +- .../core/formatting/style_resolver.py | 132 +- tests/core/formatting/test_engine.py | 57 +- tests/core/formatting/test_style_resolver.py | 107 +- 14 files changed, 623 insertions(+), 3677 deletions(-) delete mode 100644 src/myfasthtml/assets/myfasthtml.css delete mode 100644 src/myfasthtml/assets/myfasthtml.js diff --git a/docs/DataGrid Formatting System.md b/docs/DataGrid Formatting System.md index 07ec5a8..78f77b9 100644 --- a/docs/DataGrid Formatting System.md +++ b/docs/DataGrid Formatting System.md @@ -250,6 +250,42 @@ manager.add_formatter_preset("CHF", { }) ``` +**CSS Classes in Style Presets:** + +Style presets can include a special `__class__` key to apply CSS classes (DaisyUI, Tailwind, or custom): + +```python +manager.add_style_preset("badge", { + "__class__": "badge badge-primary", + "background-color": "blue", + "color": "white" +}) +``` + +When a preset with `__class__` is applied: +- The CSS classes are added to the element's `class` attribute +- The CSS properties are applied as inline styles +- This allows combining DaisyUI component classes with custom styling + +**Example with DaisyUI badges:** + +```python +# Define badge presets +manager.add_style_preset("status_draft", { + "__class__": "badge badge-neutral" +}) + +manager.add_style_preset("status_approved", { + "__class__": "badge badge-success", + "font-weight": "bold" +}) + +# Use in DSL +column status: + style("status_draft") if value == "draft" + style("status_approved") if value == "approved" +``` + --- ## Layer 1: Formatting Engine @@ -441,7 +477,7 @@ def apply_format( rules: list[FormatRule], cell_value: Any, row_data: dict = None -) -> tuple[str | None, str | None]: +) -> tuple[StyleContainer | None, str | None]: """ Apply format rules to a cell value. @@ -451,8 +487,8 @@ def apply_format( row_data: Dict of {col_id: value} for column references Returns: - Tuple of (css_string, formatted_value): - - css_string: CSS inline style string, or None + Tuple of (style_container, formatted_value): + - style_container: StyleContainer with cls and css attributes, or None - formatted_value: Formatted string, or None """ ``` @@ -478,9 +514,17 @@ rules = [ ] # Apply to cell -css, formatted = engine.apply_format(rules, -1234.56) -# css = "background-color: var(--color-error); color: var(--color-error-content);" +style, formatted = engine.apply_format(rules, -1234.56) +# style = StyleContainer( +# cls=None, +# css="background-color: var(--color-error); color: var(--color-error-content);" +# ) # formatted = "-1 234,56 €" + +# Access CSS string +if style: + css_string = style.css + css_classes = style.cls ``` ### Sub-components @@ -511,11 +555,47 @@ Converts `Style` objects to CSS strings: resolver = StyleResolver() style = Style(preset="error", font_weight="bold") + +# Get CSS properties dict css_dict = resolver.resolve(style) # {"background-color": "var(--color-error)", "color": "var(--color-error-content)", "font-weight": "bold"} +# Get CSS inline string css_string = resolver.to_css_string(style) # "background-color: var(--color-error); color: var(--color-error-content); font-weight: bold;" + +# Get StyleContainer with classes and CSS +container = resolver.to_style_container(style) +# StyleContainer(cls=None, css="background-color: var(--color-error); ...") +``` + +**StyleContainer:** + +The `to_style_container()` method returns a `StyleContainer` object that separates CSS classes from inline styles: + +```python +@dataclass +class StyleContainer: + cls: str | None = None # CSS class names + css: str = None # Inline CSS string +``` + +This is useful when presets include the `__class__` key: + +```python +# Preset with CSS classes +custom_presets = { + "badge": { + "__class__": "badge badge-primary", + "background-color": "blue" + } +} +resolver = StyleResolver(style_presets=custom_presets) +style = Style(preset="badge") + +container = resolver.to_style_container(style) +# container.cls = "badge badge-primary" +# container.css = "background-color: blue;" ``` #### FormatterResolver @@ -1217,9 +1297,12 @@ Used by `DataGridFormattingEditor` to configure the CodeMirror editor. 7. DataGrid renders cells - mk_body_cell_content() applies formatting - FormattingEngine.apply_format(rules, cell_value, row_data) + - Returns (StyleContainer, formatted_value) │ ▼ -8. CSS + formatted value rendered in cell +8. CSS classes + inline styles + formatted value rendered in cell + - StyleContainer.cls applied to class attribute + - StyleContainer.css applied as inline style ``` --- @@ -1235,11 +1318,33 @@ from myfasthtml.controls.DataGridsManager import DataGridsManager manager = DataGridsManager.get_instance(session) +# Style preset with CSS properties only manager.add_style_preset("corporate", { "background-color": "#003366", "color": "#FFFFFF", "font-weight": "bold" }) + +# Style preset with CSS classes (DaisyUI/Tailwind) +manager.add_style_preset("badge_primary", { + "__class__": "badge badge-primary", + "font-weight": "bold" +}) + +# Style preset mixing classes and inline styles +manager.add_style_preset("highlighted", { + "__class__": "badge badge-accent", + "background-color": "#fef08a", + "color": "#854d0e" +}) +``` + +**Usage in DSL:** + +```python +column status: + style("badge_primary") if value == "active" + style("highlighted") if value == "important" ``` #### Add Custom Formatter Presets diff --git a/docs/Panel.md b/docs/Panel.md index 8383a67..0390eb0 100644 --- a/docs/Panel.md +++ b/docs/Panel.md @@ -941,4 +941,4 @@ The Panel component uses JavaScript for manual resizing: - Sends width updates to server via HTMX - Constrains width between 150px and 500px -**File:** `src/myfasthtml/assets/myfasthtml.js` +**File:** `src/myfasthtml/assets/core/myfasthtml.js` diff --git a/src/myfasthtml/assets/core/dsleditor.css b/src/myfasthtml/assets/core/dsleditor.css index bf7b77c..9299870 100644 --- a/src/myfasthtml/assets/core/dsleditor.css +++ b/src/myfasthtml/assets/core/dsleditor.css @@ -207,3 +207,47 @@ border-radius: 0.5rem; overflow: hidden; } + +/* *********************************************** */ +/* ********** Preset Styles *********** */ +/* *********************************************** */ + +.mf-formatting-primary { + background-color: var(--color-primary); + color: var(--color-primary-content) +} + +.mf-formatting-secondary { + background-color: var(--color-secondary); + color: var(--color-secondary-content); +} + +.mf-formatting-accent { + background-color: var(--color-accent); + color: var(--color-accent-content); +} + +.mf-formatting-neutral { + background-color: var(--color-neutral); + color: var(--color-neutral-content); +} + +.mf-formatting-info { + background-color: var(--color-info); + color: var(--color-info-content); +} + +.mf-formatting-success { + background-color: var(--color-success); + color: var(--color-success-content); +} + +.mf-formatting-warning { + background-color: var(--color-warning); + color: var(--color-warning-content); +} + +.mf-formatting-error { + background-color: var(--color-error); + color: var(--color-error-content); +} \ No newline at end of file diff --git a/src/myfasthtml/assets/core/myfasthtml.js b/src/myfasthtml/assets/core/myfasthtml.js index 839aac4..ae3ca8f 100644 --- a/src/myfasthtml/assets/core/myfasthtml.js +++ b/src/myfasthtml/assets/core/myfasthtml.js @@ -375,6 +375,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { } // Make AJAX call with htmx + console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions); htmx.ajax(method, url, htmxOptions); } diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css deleted file mode 100644 index b102296..0000000 --- a/src/myfasthtml/assets/myfasthtml.css +++ /dev/null @@ -1,901 +0,0 @@ -/*:root {*/ -/* --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);*/ -/* --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);*/ -/* --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);*/ -/* --color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000);*/ - -/* --datagrid-resize-zindex: 1;*/ -/* --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';*/ -/* --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;*/ -/* --spacing: 0.25rem;*/ -/* --text-xs: 0.6875rem;*/ -/* --text-sm: 0.875rem;*/ -/* --text-sm--line-height: calc(1.25 / 0.875);*/ -/* --text-xl: 1.25rem;*/ -/* --text-xl--line-height: calc(1.75 / 1.25);*/ -/* --font-weight-medium: 500;*/ -/* --radius-md: 0.375rem;*/ -/* --default-font-family: var(--font-sans);*/ -/* --default-mono-font-family: var(--font-mono);*/ -/* --properties-font-size: var(--text-xs);*/ -/* --mf-tooltip-zindex: 10;*/ -/*}*/ - - -/*.mf-icon-16 {*/ -/* width: 16px;*/ -/* min-width: 16px;*/ -/* height: 16px;*/ -/*}*/ - -/*.mf-icon-20 {*/ -/* width: 20px;*/ -/* min-width: 20px;*/ -/* height: 20px;*/ -/*}*/ - -/*.mf-icon-24 {*/ -/* width: 24px;*/ -/* min-width: 24px;*/ -/* height: 24px;*/ - -/*}*/ - -/*.mf-icon-28 {*/ -/* width: 28px;*/ -/* min-width: 28px;*/ -/* height: 28px;*/ -/*}*/ - -/*.mf-icon-32 {*/ -/* width: 32px;*/ -/* min-width: 32px;*/ -/* height: 32px;*/ -/*}*/ - -/*!**/ -/* * MF Layout Component - CSS Grid Layout*/ -/* * Provides fixed header/footer, collapsible drawers, and scrollable main content*/ -/* * Compatible with DaisyUI 5*/ -/* *!*/ - -/*.mf-button {*/ -/* border-radius: 0.375rem;*/ -/* transition: background-color 0.15s ease;*/ -/*}*/ - -/*.mf-button:hover {*/ -/* background-color: var(--color-base-300);*/ -/*}*/ - - -/*.mf-tooltip-container {*/ -/* background: var(--color-base-200);*/ -/* padding: 5px 10px;*/ -/* border-radius: 4px;*/ -/* pointer-events: none; !* Prevent interfering with mouse events *!*/ -/* font-size: 12px;*/ -/* white-space: nowrap;*/ -/* opacity: 0; !* Default to invisible *!*/ -/* visibility: hidden; !* Prevent interaction when invisible *!*/ -/* transition: opacity 0.3s ease, visibility 0s linear 0.3s; !* Delay visibility removal *!*/ -/* position: fixed; !* Keep it above other content and adjust position *!*/ -/* z-index: var(--mf-tooltip-zindex); !* Ensure it's on top *!*/ -/*}*/ - -/*.mf-tooltip-container[data-visible="true"] {*/ -/* opacity: 1;*/ -/* visibility: visible; !* Show tooltip *!*/ -/* transition: opacity 0.3s ease; !* No delay when becoming visible *!*/ -/*}*/ - -/*!* Main layout container using CSS Grid *!*/ -/*.mf-layout {*/ -/* display: grid;*/ -/* grid-template-areas:*/ -/* "header header header"*/ -/* "left-drawer main right-drawer"*/ -/* "footer footer footer";*/ -/* grid-template-rows: 32px 1fr 32px;*/ -/* grid-template-columns: auto 1fr auto;*/ -/* height: 100vh;*/ -/* width: 100vw;*/ -/* overflow: hidden;*/ -/*}*/ - -/*!* Header - fixed at top *!*/ -/*.mf-layout-header {*/ -/* grid-area: header;*/ -/* display: flex;*/ -/* align-items: center;*/ -/* justify-content: space-between; !* put one item on each side *!*/ -/* gap: 1rem;*/ -/* padding: 0 1rem;*/ -/* background-color: var(--color-base-300);*/ -/* border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ -/* z-index: 30;*/ -/*}*/ - -/*!* Footer - fixed at bottom *!*/ -/*.mf-layout-footer {*/ -/* grid-area: footer;*/ -/* display: flex;*/ -/* align-items: center;*/ -/* padding: 0 1rem;*/ -/* background-color: var(--color-neutral);*/ -/* color: var(--color-neutral-content);*/ -/* border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ -/* z-index: 30;*/ -/*}*/ - -/*!* Main content area - scrollable *!*/ -/*.mf-layout-main {*/ -/* grid-area: main;*/ -/* overflow-y: auto;*/ -/* overflow-x: auto;*/ -/* padding: 0.5rem;*/ -/* background-color: var(--color-base-100);*/ -/*}*/ - -/*!* Drawer base styles *!*/ -/*.mf-layout-drawer {*/ -/* overflow-y: auto;*/ -/* overflow-x: hidden;*/ -/* background-color: var(--color-base-100);*/ -/* transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;*/ -/* width: 250px;*/ -/* padding: 1rem;*/ -/*}*/ - -/*!* Left drawer *!*/ -/*.mf-layout-left-drawer {*/ -/* grid-area: left-drawer;*/ -/* border-right: 1px solid var(--color-border-primary);*/ -/*}*/ - -/*!* Right drawer *!*/ -/*.mf-layout-right-drawer {*/ -/* grid-area: right-drawer;*/ -/* !*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*!*/ -/* border-left: 1px solid var(--color-border-primary);*/ -/*}*/ - -/*!* Collapsed drawer states *!*/ -/*.mf-layout-drawer.collapsed {*/ -/* width: 0;*/ -/* padding: 0;*/ -/* border: none;*/ -/* overflow: hidden;*/ -/*}*/ - -/*!* Toggle buttons positioning *!*/ -/*.mf-layout-toggle-left {*/ -/* margin-right: auto;*/ -/*}*/ - -/*.mf-layout-toggle-right {*/ -/* margin-left: auto;*/ -/*}*/ - -/*!* Smooth scrollbar styling for webkit browsers *!*/ -/*.mf-layout-main::-webkit-scrollbar,*/ -/*.mf-layout-drawer::-webkit-scrollbar {*/ -/* width: 8px;*/ -/* height: 8px;*/ -/*}*/ - -/*.mf-layout-main::-webkit-scrollbar-track,*/ -/*.mf-layout-drawer::-webkit-scrollbar-track {*/ -/* background: var(--color-base-200);*/ -/*}*/ - -/*.mf-layout-main::-webkit-scrollbar-thumb,*/ -/*.mf-layout-drawer::-webkit-scrollbar-thumb {*/ -/* background: color-mix(in oklab, var(--color-base-content) 20%, #0000);*/ -/* border-radius: 4px;*/ -/*}*/ - -/*.mf-layout-main::-webkit-scrollbar-thumb:hover,*/ -/*.mf-layout-drawer::-webkit-scrollbar-thumb:hover {*/ -/* background: color-mix(in oklab, var(--color-base-content) 30%, #0000);*/ -/*}*/ - -/*!* Responsive adjustments for smaller screens *!*/ -/*@media (max-width: 768px) {*/ -/* .mf-layout-drawer {*/ -/* width: 200px;*/ -/* }*/ - -/* .mf-layout-header,*/ -/* .mf-layout-footer {*/ -/* padding: 0 0.5rem;*/ -/* }*/ - -/* .mf-layout-main {*/ -/* padding: 0.5rem;*/ -/* }*/ -/*}*/ - -/*!* Handle layouts with no drawers *!*/ -/*.mf-layout[data-left-drawer="false"] {*/ -/* grid-template-areas:*/ -/* "header header"*/ -/* "main right-drawer"*/ -/* "footer footer";*/ -/* grid-template-columns: 1fr auto;*/ -/*}*/ - -/*.mf-layout[data-right-drawer="false"] {*/ -/* grid-template-areas:*/ -/* "header header"*/ -/* "left-drawer main"*/ -/* "footer footer";*/ -/* grid-template-columns: auto 1fr;*/ -/*}*/ - -/*.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {*/ -/* grid-template-areas:*/ -/* "header"*/ -/* "main"*/ -/* "footer";*/ -/* grid-template-columns: 1fr;*/ -/*}*/ - - -/*!***/ -/* * Layout Drawer Resizer Styles*/ -/* **/ -/* * Styles for the resizable drawer borders with visual feedback*/ -/* *!*/ - -/*!* Ensure drawer has relative positioning and no overflow *!*/ -/*.mf-layout-drawer {*/ -/* position: relative;*/ -/* overflow: hidden;*/ -/*}*/ - -/*!* Content wrapper handles scrolling *!*/ -/*.mf-layout-drawer-content {*/ -/* position: absolute;*/ -/* top: 0;*/ -/* left: 0;*/ -/* right: 0;*/ -/* bottom: 0;*/ -/* overflow-y: auto;*/ -/* overflow-x: hidden;*/ -/* padding: 1rem;*/ -/*}*/ - -/*!* Base resizer styles *!*/ -/*.mf-layout-resizer {*/ -/* position: absolute;*/ -/* top: 0;*/ -/* bottom: 0;*/ -/* width: 4px;*/ -/* background-color: transparent;*/ -/* transition: background-color 0.2s ease;*/ -/* z-index: 100;*/ -/*}*/ - -/*!* Resizer on the right side (for left drawer) *!*/ -/*.mf-layout-resizer-right {*/ -/* right: 0;*/ -/* cursor: col-resize;*/ -/*}*/ - -/*!* Resizer on the left side (for right drawer) *!*/ -/*.mf-layout-resizer-left {*/ -/* left: 0;*/ -/* cursor: col-resize;*/ -/*}*/ - -/*!* Hover state *!*/ -/*.mf-layout-resizer:hover {*/ -/* background-color: rgba(59, 130, 246, 0.3); !* Blue-500 with opacity *!*/ -/*}*/ - -/*!* Active state during resize *!*/ -/*.mf-layout-drawer-resizing .mf-layout-resizer {*/ -/* background-color: rgba(59, 130, 246, 0.5);*/ -/*}*/ - -/*!* Disable transitions during resize for smooth dragging *!*/ -/*.mf-layout-drawer-resizing {*/ -/* transition: none !important;*/ -/*}*/ - -/*!* Prevent text selection during resize *!*/ -/*.mf-layout-resizing {*/ -/* user-select: none;*/ -/* -webkit-user-select: none;*/ -/* -moz-user-select: none;*/ -/* -ms-user-select: none;*/ -/*}*/ - -/*!* Cursor override for entire body during resize *!*/ -/*.mf-layout-resizing * {*/ -/* cursor: col-resize !important;*/ -/*}*/ - -/*!* Visual indicator for resizer on hover - subtle border *!*/ -/*.mf-layout-resizer::before {*/ -/* content: '';*/ -/* position: absolute;*/ -/* top: 50%;*/ -/* transform: translateY(-50%);*/ -/* width: 2px;*/ -/* height: 40px;*/ -/* background-color: rgba(156, 163, 175, 0.4); !* Gray-400 with opacity *!*/ -/* border-radius: 2px;*/ -/* opacity: 0;*/ -/* transition: opacity 0.2s ease;*/ -/*}*/ - -/*.mf-layout-resizer-right::before {*/ -/* right: 1px;*/ -/*}*/ - -/*.mf-layout-resizer-left::before {*/ -/* left: 1px;*/ -/*}*/ - -/*.mf-layout-resizer:hover::before,*/ -/*.mf-layout-drawer-resizing .mf-layout-resizer::before {*/ -/* opacity: 1;*/ -/*}*/ - - -/*.mf-layout-group {*/ -/* font-weight: bold;*/ -/* !*font-size: var(--text-sm);*!*/ -/* overflow: hidden;*/ -/* text-overflow: ellipsis;*/ -/* white-space: nowrap;*/ -/* margin-bottom: 0.2rem;*/ -/*}*/ - -/*!* *********************************************** *!*/ -/*!* *********** Tabs Manager Component ************ *!*/ -/*!* *********************************************** *!*/ - -/*!* Tabs Manager Container *!*/ -/*.mf-tabs-manager {*/ -/* display: flex;*/ -/* flex-direction: column;*/ -/* height: 100%;*/ -/* width: 100%;*/ -/* background-color: var(--color-base-200);*/ -/* color: color-mix(in oklab, var(--color-base-content) 50%, transparent);*/ -/* border-radius: .5rem;*/ -/*}*/ - -/*!* Tabs Header using DaisyUI tabs component *!*/ -/*.mf-tabs-header {*/ -/* display: flex;*/ -/* gap: 0;*/ -/* flex-shrink: 1;*/ -/* min-height: 25px;*/ - -/* overflow-x: hidden;*/ -/* overflow-y: hidden;*/ -/* white-space: nowrap;*/ -/*}*/ - -/*.mf-tabs-header-wrapper {*/ -/* display: flex;*/ -/* justify-content: space-between;*/ -/* align-items: center;*/ - -/* width: 100%;*/ -/* !*overflow: hidden; important *!*/ -/*}*/ - -/*!* Individual Tab Button using DaisyUI tab classes *!*/ -/*.mf-tab-button {*/ -/* display: flex;*/ -/* align-items: center;*/ -/* gap: 0.5rem;*/ -/* padding: 0 0.5rem 0 1rem;*/ -/* cursor: pointer;*/ -/* transition: all 0.2s ease;*/ -/*}*/ - -/*.mf-tab-button:hover {*/ -/* color: var(--color-base-content); !* Change text color on hover *!*/ -/*}*/ - -/*.mf-tab-button.mf-tab-active {*/ -/* --depth: 1;*/ -/* background-color: var(--color-base-100);*/ -/* color: var(--color-base-content);*/ -/* border-radius: .25rem;*/ -/* border-bottom: 4px solid var(--color-primary);*/ -/* box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);*/ -/*}*/ - -/*!* Tab Label *!*/ -/*.mf-tab-label {*/ -/* user-select: none;*/ -/* white-space: nowrap;*/ -/* max-width: 150px;*/ -/*}*/ - -/*!* Tab Close Button *!*/ -/*.mf-tab-close-btn {*/ -/* display: flex;*/ -/* align-items: center;*/ -/* justify-content: center;*/ -/* width: 1rem;*/ -/* height: 1rem;*/ -/* border-radius: 0.25rem;*/ -/* font-size: 1.25rem;*/ -/* line-height: 1;*/ -/* @apply text-base-content/50;*/ -/* transition: all 0.2s ease;*/ -/*}*/ - -/*.mf-tab-close-btn:hover {*/ -/* @apply bg-base-300 text-error;*/ -/*}*/ - -/*!* Tab Content Area *!*/ -/*.mf-tab-content {*/ -/* flex: 1;*/ -/* overflow: auto;*/ -/* height: 100%;*/ -/*}*/ - -/*.mf-tab-content-wrapper {*/ -/* flex: 1;*/ -/* overflow: auto;*/ -/* background-color: var(--color-base-100);*/ -/* padding: 0.5rem;*/ -/* border-top: 1px solid var(--color-border-primary);*/ -/*}*/ - -/*!* Empty Content State *!*/ -/*.mf-empty-content {*/ -/* align-items: center;*/ -/* justify-content: center;*/ -/* height: 100%;*/ -/* @apply text-base-content/50;*/ -/* font-style: italic;*/ -/*}*/ - -/*.mf-vis {*/ -/* width: 100%;*/ -/* height: 100%;*/ -/*}*/ - -/*.mf-search-results {*/ -/* margin-top: 0.5rem;*/ -/* !*max-height: 400px;*!*/ -/* overflow: auto;*/ -/*}*/ - -/*.mf-dropdown-wrapper {*/ -/* position: relative; !* CRUCIAL for the anchor *!*/ -/*}*/ - - -/*.mf-dropdown {*/ -/* display: none;*/ -/* position: absolute;*/ -/* top: 100%;*/ -/* left: 0;*/ -/* z-index: 50;*/ -/* min-width: 200px;*/ -/* padding: 0.5rem;*/ -/* box-sizing: border-box;*/ -/* overflow-x: auto;*/ - -/* !* DaisyUI styling *!*/ -/* background-color: var(--color-base-100);*/ -/* border: 1px solid var(--color-border);*/ -/* border-radius: var(--radius-md);*/ -/* box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000),*/ -/* 0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000);*/ -/*}*/ - -/*.mf-dropdown.is-visible {*/ -/* display: block;*/ -/* opacity: 1;*/ -/*}*/ - -/*!* Dropdown vertical positioning *!*/ -/*.mf-dropdown-below {*/ -/* top: 100%;*/ -/* bottom: auto;*/ -/*}*/ - -/*.mf-dropdown-above {*/ -/* bottom: 100%;*/ -/* top: auto;*/ -/*}*/ - -/*!* Dropdown horizontal alignment *!*/ -/*.mf-dropdown-left {*/ -/* left: 0;*/ -/* right: auto;*/ -/* transform: none;*/ -/*}*/ - -/*.mf-dropdown-right {*/ -/* right: 0;*/ -/* left: auto;*/ -/* transform: none;*/ -/*}*/ - -/*.mf-dropdown-center {*/ -/* left: 50%;*/ -/* right: auto;*/ -/* transform: translateX(-50%);*/ -/*}*/ - -/*!* *********************************************** *!*/ -/*!* ************** TreeView Component ************* *!*/ -/*!* *********************************************** *!*/ - -/*!* TreeView Container *!*/ -/*.mf-treeview {*/ -/* width: 100%;*/ -/* user-select: none;*/ -/*}*/ - -/*!* TreeNode Container *!*/ -/*.mf-treenode-container {*/ -/* width: 100%;*/ -/*}*/ - -/*!* TreeNode Element *!*/ -/*.mf-treenode {*/ -/* display: flex;*/ -/* align-items: center;*/ -/* gap: 0.25rem;*/ -/* padding: 2px 0.5rem;*/ -/* cursor: pointer;*/ -/* transition: background-color 0.15s ease;*/ -/* border-radius: 0.25rem;*/ -/*}*/ - -/*!* Input for Editing *!*/ -/*.mf-treenode-input {*/ -/* flex: 1;*/ -/* padding: 2px 0.25rem;*/ -/* border: 1px solid var(--color-primary);*/ -/* border-radius: 0.25rem;*/ -/* background-color: var(--color-base-100);*/ -/* outline: none;*/ -/*}*/ - - -/*.mf-treenode:hover {*/ -/* background-color: var(--color-base-200);*/ -/*}*/ - -/*.mf-treenode.selected {*/ -/* background-color: var(--color-primary);*/ -/* color: var(--color-primary-content);*/ -/*}*/ - -/*!* Toggle Icon *!*/ -/*.mf-treenode-toggle {*/ -/* flex-shrink: 0;*/ -/* width: 20px;*/ -/* text-align: center;*/ -/* font-size: 0.75rem;*/ -/*}*/ - -/*!* Node Label *!*/ -/*.mf-treenode-label {*/ -/* flex: 1;*/ -/* overflow: hidden;*/ -/* text-overflow: ellipsis;*/ -/* white-space: nowrap;*/ -/*}*/ - - -/*.mf-treenode-input:focus {*/ -/* box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);*/ -/*}*/ - -/*!* Action Buttons - Hidden by default, shown on hover *!*/ -/*.mf-treenode-actions {*/ -/* display: none;*/ -/* gap: 0.1rem;*/ -/* white-space: nowrap;*/ -/* margin-left: 0.5rem;*/ -/*}*/ - -/*.mf-treenode:hover .mf-treenode-actions {*/ -/* display: flex;*/ -/*}*/ - -/*!* *********************************************** *!*/ -/*!* ********** Generic Resizer Classes ************ *!*/ -/*!* *********************************************** *!*/ - -/*!* Generic resizer - used by both Layout and Panel *!*/ -/*.mf-resizer {*/ -/* position: absolute;*/ -/* width: 4px;*/ -/* cursor: col-resize;*/ -/* background-color: transparent;*/ -/* transition: background-color 0.2s ease;*/ -/* z-index: 100;*/ -/* top: 0;*/ -/* bottom: 0;*/ -/*}*/ - -/*.mf-resizer:hover {*/ -/* background-color: rgba(59, 130, 246, 0.3);*/ -/*}*/ - -/*!* Active state during resize *!*/ -/*.mf-resizing .mf-resizer {*/ -/* background-color: rgba(59, 130, 246, 0.5);*/ -/*}*/ - -/*!* Prevent text selection during resize *!*/ -/*.mf-resizing {*/ -/* user-select: none;*/ -/* -webkit-user-select: none;*/ -/* -moz-user-select: none;*/ -/* -ms-user-select: none;*/ -/*}*/ - -/*!* Cursor override for entire body during resize *!*/ -/*.mf-resizing * {*/ -/* cursor: col-resize !important;*/ -/*}*/ - -/*!* Visual indicator for resizer on hover - subtle border *!*/ -/*.mf-resizer::before {*/ -/* content: '';*/ -/* position: absolute;*/ -/* top: 50%;*/ -/* transform: translateY(-50%);*/ -/* width: 2px;*/ -/* height: 40px;*/ -/* background-color: rgba(156, 163, 175, 0.4);*/ -/* border-radius: 2px;*/ -/* opacity: 0;*/ -/* transition: opacity 0.2s ease;*/ -/*}*/ - -/*.mf-resizer:hover::before,*/ -/*.mf-resizing .mf-resizer::before {*/ -/* opacity: 1;*/ -/*}*/ - -/*!* Resizer positioning *!*/ -/*!* Left resizer is on the right side of the left panel *!*/ -/*.mf-resizer-left {*/ -/* right: 0;*/ -/*}*/ - -/*!* Right resizer is on the left side of the right panel *!*/ -/*.mf-resizer-right {*/ -/* left: 0;*/ -/*}*/ - -/*!* Position indicator for resizer *!*/ -/*.mf-resizer-left::before {*/ -/* right: 1px;*/ -/*}*/ - -/*.mf-resizer-right::before {*/ -/* left: 1px;*/ -/*}*/ - -/*!* Disable transitions during resize for smooth dragging *!*/ -/*.mf-item-resizing {*/ -/* transition: none !important;*/ -/*}*/ - -/*!* *********************************************** *!*/ -/*!* *************** Panel Component *************** *!*/ -/*!* *********************************************** *!*/ - -/*.mf-panel {*/ -/* display: flex;*/ -/* width: 100%;*/ -/* height: 100%;*/ -/* overflow: hidden;*/ -/* position: relative;*/ -/*}*/ - -/*!* Common properties for side panels *!*/ -/*.mf-panel-left,*/ -/*.mf-panel-right {*/ -/* position: relative;*/ -/* flex-shrink: 0;*/ -/* width: 250px;*/ -/* min-width: 150px;*/ -/* max-width: 500px;*/ -/* height: 100%;*/ -/* overflow: auto;*/ -/* transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;*/ -/* padding-top: 25px;*/ -/*}*/ - -/*!* Left panel specific *!*/ -/*.mf-panel-left {*/ -/* border-right: 1px solid var(--color-border-primary);*/ -/*}*/ - -/*!* Right panel specific *!*/ -/*.mf-panel-right {*/ -/* border-left: 1px solid var(--color-border-primary);*/ -/* padding-left: 0.5rem;*/ -/*}*/ - -/*.mf-panel-main {*/ -/* flex: 1;*/ -/* height: 100%;*/ -/* overflow: hidden;*/ -/* min-width: 0; !* Important to allow the shrinking of flexbox *!*/ -/*}*/ - -/*!* Hidden state - common for both panels *!*/ -/*.mf-panel-left.mf-hidden,*/ -/*.mf-panel-right.mf-hidden {*/ -/* width: 0;*/ -/* min-width: 0;*/ -/* max-width: 0;*/ -/* overflow: hidden;*/ -/* border: none;*/ -/* padding: 0;*/ -/*}*/ - -/*!* No transition during manual resize - common for both panels *!*/ -/*.mf-panel-left.no-transition,*/ -/*.mf-panel-right.no-transition {*/ -/* transition: none;*/ -/*}*/ - -/*!* Common properties for panel toggle icons *!*/ -/*.mf-panel-hide-icon,*/ -/*.mf-panel-show-icon {*/ -/* position: absolute;*/ -/* top: 0;*/ -/* right: 0;*/ -/* cursor: pointer;*/ -/* z-index: 10;*/ -/* border-radius: 0.25rem;*/ -/*}*/ - -/*.mf-panel-hide-icon:hover,*/ -/*.mf-panel-show-icon:hover {*/ -/* background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));*/ -/*}*/ - -/*!* Show icon positioning *!*/ -/*.mf-panel-show-icon-left {*/ -/* left: 0.5rem;*/ -/*}*/ - -/*.mf-panel-show-icon-right {*/ -/* right: 0.5rem;*/ -/*}*/ - -/*!* Panel with title - grid layout for header + scrollable content *!*/ -/*.mf-panel-body {*/ -/* display: grid;*/ -/* grid-template-rows: auto 1fr;*/ -/* height: 100%;*/ -/* overflow: hidden;*/ -/*}*/ - -/*.mf-panel-header {*/ -/* display: flex;*/ -/* justify-content: space-between;*/ -/* align-items: center;*/ -/* padding: 0.25rem 0.5rem;*/ -/* background-color: var(--color-base-200);*/ -/* border-bottom: 1px solid var(--color-border);*/ -/*}*/ - -/*!* Override absolute positioning for hide icon when inside header *!*/ -/*.mf-panel-header .mf-panel-hide-icon {*/ -/* position: static;*/ -/*}*/ - -/*.mf-panel-content {*/ -/* overflow-y: auto;*/ -/*}*/ - -/*!* Remove padding-top when using title layout *!*/ -/*.mf-panel-left.mf-panel-with-title,*/ -/*.mf-panel-right.mf-panel-with-title {*/ -/* padding-top: 0;*/ -/*}*/ - -/*!* *********************************************** *!*/ -/*!* ************* Properties Component ************ *!*/ -/*!* *********************************************** *!*/ - -/*!*!* Properties container *!*!*/ -/*.mf-properties {*/ -/* display: flex;*/ -/* flex-direction: column;*/ -/* gap: 1rem;*/ -/*}*/ - -/*!*!* Group card - using DaisyUI card styling *!*!*/ -/*.mf-properties-group-card {*/ -/* background-color: var(--color-base-100);*/ -/* border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);*/ -/* border-radius: var(--radius-md);*/ -/* box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);*/ -/* overflow: auto;*/ -/*}*/ - -/*.mf-properties-group-container {*/ -/* display: inline-block; !* important *!*/ -/* min-width: max-content; !* important *!*/ -/* width: 100%;*/ -/*}*/ - - -/*!*!* Group header - gradient using DaisyUI primary color *!*!*/ -/*.mf-properties-group-header {*/ -/* background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);*/ -/* color: var(--color-primary-content);*/ -/* padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);*/ -/* font-weight: 700;*/ -/* font-size: var(--properties-font-size);*/ -/*}*/ - -/*!*!* Group content area *!*!*/ -/*.mf-properties-group-content {*/ -/* display: flex;*/ -/* flex-direction: column;*/ -/* min-width: max-content;*/ -/*}*/ - -/*!*!* Property row *!*!*/ -/*.mf-properties-row {*/ -/* display: grid;*/ -/* grid-template-columns: 6rem 1fr;*/ - -/* align-items: center;*/ -/* padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);*/ - -/* border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);*/ -/* transition: background-color 0.15s ease;*/ - -/* gap: calc(var(--properties-font-size) * 0.75);*/ -/*}*/ - -/*.mf-properties-row:last-child {*/ -/* border-bottom: none;*/ -/*}*/ - -/*.mf-properties-row:hover {*/ -/* background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);*/ -/*}*/ - -/*!*!* Property key - normal font *!*!*/ -/*.mf-properties-key {*/ -/* align-items: start;*/ -/* font-weight: 600;*/ -/* color: color-mix(in oklab, var(--color-base-content) 70%, transparent);*/ -/* flex: 0 0 40%;*/ -/* font-size: var(--properties-font-size);*/ -/* white-space: nowrap;*/ -/* overflow: hidden;*/ -/* text-overflow: ellipsis;*/ -/*}*/ - -/*!*!* Property value - monospace font *!*!*/ -/*.mf-properties-value {*/ -/* font-family: var(--default-mono-font-family);*/ -/* color: var(--color-base-content);*/ -/* flex: 1;*/ -/* text-align: left;*/ -/* font-size: var(--properties-font-size);*/ -/* overflow: hidden;*/ -/* text-overflow: ellipsis;*/ -/* white-space: nowrap;*/ -/*}*/ diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js deleted file mode 100644 index ca8a2ad..0000000 --- a/src/myfasthtml/assets/myfasthtml.js +++ /dev/null @@ -1,2427 +0,0 @@ -// /** -// * Generic Resizer -// * -// * Handles resizing of elements with drag functionality. -// * Communicates with server via HTMX to persist width changes. -// * Works for both Layout drawers and Panel sides. -// */ -// -// /** -// * Initialize resizer functionality for a specific container -// * -// * @param {string} containerId - The ID of the container instance to initialize -// * @param {Object} options - Configuration options -// * @param {number} options.minWidth - Minimum width in pixels (default: 150) -// * @param {number} options.maxWidth - Maximum width in pixels (default: 600) -// */ -// function initResizer(containerId, options = {}) { -// -// const MIN_WIDTH = options.minWidth || 150; -// const MAX_WIDTH = options.maxWidth || 600; -// -// let isResizing = false; -// let currentResizer = null; -// let currentItem = null; -// let startX = 0; -// let startWidth = 0; -// let side = null; -// -// const containerElement = document.getElementById(containerId); -// -// if (!containerElement) { -// console.error(`Container element with ID "${containerId}" not found`); -// return; -// } -// -// /** -// * Initialize resizer functionality for this container instance -// */ -// function initResizers() { -// const resizers = containerElement.querySelectorAll('.mf-resizer'); -// -// resizers.forEach(resizer => { -// // Remove existing listener if any to avoid duplicates -// resizer.removeEventListener('mousedown', handleMouseDown); -// resizer.addEventListener('mousedown', handleMouseDown); -// }); -// } -// -// /** -// * Handle mouse down event on resizer -// */ -// function handleMouseDown(e) { -// e.preventDefault(); -// -// currentResizer = e.target; -// side = currentResizer.dataset.side; -// currentItem = currentResizer.parentElement; -// -// if (!currentItem) { -// console.error('Could not find item element'); -// return; -// } -// -// isResizing = true; -// startX = e.clientX; -// startWidth = currentItem.offsetWidth; -// -// // Add event listeners for mouse move and up -// document.addEventListener('mousemove', handleMouseMove); -// document.addEventListener('mouseup', handleMouseUp); -// -// // Add resizing class for visual feedback -// document.body.classList.add('mf-resizing'); -// currentItem.classList.add('mf-item-resizing'); -// // Disable transition during manual resize -// currentItem.classList.add('no-transition'); -// } -// -// /** -// * Handle mouse move event during resize -// */ -// function handleMouseMove(e) { -// if (!isResizing) return; -// -// e.preventDefault(); -// -// let newWidth; -// -// if (side === 'left') { -// // Left drawer: increase width when moving right -// newWidth = startWidth + (e.clientX - startX); -// } else if (side === 'right') { -// // Right drawer: increase width when moving left -// newWidth = startWidth - (e.clientX - startX); -// } -// -// // Constrain width between min and max -// newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth)); -// -// // Update item width visually -// currentItem.style.width = `${newWidth}px`; -// } -// -// /** -// * Handle mouse up event - end resize and save to server -// */ -// function handleMouseUp(e) { -// if (!isResizing) return; -// -// isResizing = false; -// -// // Remove event listeners -// document.removeEventListener('mousemove', handleMouseMove); -// document.removeEventListener('mouseup', handleMouseUp); -// -// // Remove resizing classes -// document.body.classList.remove('mf-resizing'); -// currentItem.classList.remove('mf-item-resizing'); -// // Re-enable transition after manual resize -// currentItem.classList.remove('no-transition'); -// -// // Get final width -// const finalWidth = currentItem.offsetWidth; -// const commandId = currentResizer.dataset.commandId; -// -// if (!commandId) { -// console.error('No command ID found on resizer'); -// return; -// } -// -// // Send width update to server -// saveWidth(commandId, finalWidth); -// -// // Reset state -// currentResizer = null; -// currentItem = null; -// side = null; -// } -// -// /** -// * Save width to server via HTMX -// */ -// function saveWidth(commandId, width) { -// htmx.ajax('POST', '/myfasthtml/commands', { -// headers: { -// "Content-Type": "application/x-www-form-urlencoded" -// }, -// swap: "outerHTML", -// target: `#${currentItem.id}`, -// values: { -// c_id: commandId, -// width: width -// } -// }); -// } -// -// // Initialize resizers -// initResizers(); -// -// // Re-initialize after HTMX swaps within this container -// containerElement.addEventListener('htmx:afterSwap', function (event) { -// initResizers(); -// }); -// } -// -// function bindTooltipsWithDelegation(elementId) { -// // To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip -// // Then -// // the 'truncate' to show only when the text is truncated -// // the class 'mmt-tooltip' for force the display -// -// console.info("bindTooltips on element " + elementId); -// -// const element = document.getElementById(elementId); -// const tooltipContainer = document.getElementById(`tt_${elementId}`); -// -// -// if (!element) { -// console.error(`Invalid element '${elementId}' container`); -// return; -// } -// -// if (!tooltipContainer) { -// console.error(`Invalid tooltip 'tt_${elementId}' container.`); -// return; -// } -// -// // OPTIMIZATION C: Throttling flag to limit mouseenter processing -// let tooltipRafScheduled = false; -// -// // Add a single mouseenter and mouseleave listener to the parent element -// element.addEventListener("mouseenter", (event) => { -// // Early exit - check mf-no-tooltip FIRST (before any DOM work) -// if (element.hasAttribute("mf-no-tooltip")) { -// return; -// } -// -// // OPTIMIZATION C: Throttle mouseenter events (max 1 per frame) -// if (tooltipRafScheduled) { -// return; -// } -// -// const cell = event.target.closest("[data-tooltip]"); -// if (!cell) { -// return; -// } -// -// // OPTIMIZATION C: Move ALL DOM reads into RAF to avoid forced synchronous layouts -// tooltipRafScheduled = true; -// requestAnimationFrame(() => { -// tooltipRafScheduled = false; -// -// // Check again in case tooltip was disabled during RAF delay -// if (element.hasAttribute("mf-no-tooltip")) { -// return; -// } -// -// // All DOM reads happen here (batched in RAF) -// const content = cell.querySelector(".truncate") || cell; -// const isOverflowing = content.scrollWidth > content.clientWidth; -// const forceShow = cell.classList.contains("mf-tooltip"); -// -// if (isOverflowing || forceShow) { -// const tooltipText = cell.getAttribute("data-tooltip"); -// if (tooltipText) { -// const rect = cell.getBoundingClientRect(); -// const tooltipRect = tooltipContainer.getBoundingClientRect(); -// -// let top = rect.top - 30; // Above the cell -// let left = rect.left; -// -// // Adjust tooltip position to prevent it from going off-screen -// if (top < 0) top = rect.bottom + 5; // Move below if no space above -// if (left + tooltipRect.width > window.innerWidth) { -// left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right -// } -// -// // Apply styles (already in RAF) -// tooltipContainer.textContent = tooltipText; -// tooltipContainer.setAttribute("data-visible", "true"); -// tooltipContainer.style.top = `${top}px`; -// tooltipContainer.style.left = `${left}px`; -// } -// } -// }); -// }, true); // Capture phase required: mouseenter doesn't bubble -// -// element.addEventListener("mouseleave", (event) => { -// const cell = event.target.closest("[data-tooltip]"); -// if (cell) { -// tooltipContainer.setAttribute("data-visible", "false"); -// } -// }, true); // Capture phase required: mouseleave doesn't bubble -// } -// -// function initLayout(elementId) { -// initResizer(elementId); -// bindTooltipsWithDelegation(elementId); -// } -// -// function disableTooltip() { -// const elementId = tooltipElementId -// // console.debug("disableTooltip on element " + elementId); -// -// const element = document.getElementById(elementId); -// if (!element) { -// console.error(`Invalid element '${elementId}' container`); -// return; -// } -// -// element.setAttribute("mmt-no-tooltip", ""); -// } -// -// function enableTooltip() { -// const elementId = tooltipElementId -// // console.debug("enableTooltip on element " + elementId); -// -// const element = document.getElementById(elementId); -// if (!element) { -// console.error(`Invalid element '${elementId}' container`); -// return; -// } -// -// element.removeAttribute("mmt-no-tooltip"); -// } -// -// function initBoundaries(elementId, updateUrl) { -// function updateBoundaries() { -// const container = document.getElementById(elementId); -// if (!container) { -// console.warn("initBoundaries : element " + elementId + " is not found !"); -// return; -// } -// -// const rect = container.getBoundingClientRect(); -// const width = Math.floor(rect.width); -// const height = Math.floor(rect.height); -// console.log("boundaries: ", rect) -// -// // Send boundaries to server -// htmx.ajax('POST', updateUrl, { -// target: '#' + elementId, -// swap: 'outerHTML', -// values: {width: width, height: height} -// }); -// } -// -// // Debounce function -// let resizeTimeout; -// -// function debouncedUpdate() { -// clearTimeout(resizeTimeout); -// resizeTimeout = setTimeout(updateBoundaries, 250); -// } -// -// // Update on load -// setTimeout(updateBoundaries, 100); -// -// // Update on window resize -// const container = document.getElementById(elementId); -// container.addEventListener('resize', debouncedUpdate); -// -// // Cleanup on element removal -// if (container) { -// const observer = new MutationObserver(function (mutations) { -// mutations.forEach(function (mutation) { -// mutation.removedNodes.forEach(function (node) { -// if (node.id === elementId) { -// window.removeEventListener('resize', debouncedUpdate); -// } -// }); -// }); -// }); -// observer.observe(container.parentNode, {childList: true}); -// } -// } -// -// /** -// * Updates the tabs display by showing the active tab content and scrolling to make it visible. -// * This function is called when switching between tabs to update both the content visibility -// * and the tab button states. -// * -// * @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-controller") -// */ -// function updateTabs(controllerId) { -// const controller = document.getElementById(controllerId); -// if (!controller) { -// console.warn(`Controller ${controllerId} not found`); -// return; -// } -// -// const activeTabId = controller.dataset.activeTab; -// if (!activeTabId) { -// console.warn('No active tab ID found'); -// return; -// } -// -// // Extract manager ID from controller ID (remove '-controller' suffix) -// const managerId = controllerId.replace('-controller', ''); -// -// // Hide all tab contents for this manager -// const contentWrapper = document.getElementById(`${managerId}-content-wrapper`); -// if (contentWrapper) { -// contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => { -// content.classList.add('hidden'); -// }); -// -// // Show the active tab content -// const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`); -// if (activeContent) { -// activeContent.classList.remove('hidden'); -// } -// } -// -// // Update active tab button styling -// const header = document.getElementById(`${managerId}-header`); -// if (header) { -// // Remove active class from all tabs -// header.querySelectorAll('.mf-tab-button').forEach(btn => { -// btn.classList.remove('mf-tab-active'); -// }); -// -// // Add active class to current tab -// const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`); -// if (activeButton) { -// activeButton.classList.add('mf-tab-active'); -// -// // Scroll to make active tab visible if needed -// activeButton.scrollIntoView({ -// behavior: 'smooth', -// block: 'nearest', -// inline: 'nearest' -// }); -// } -// } -// } -// -// /** -// * Find the parent element with .dt2-cell class and return its id. -// * Used with hx-vals="js:getCellId()" for DataGrid cell identification. -// * -// * @param {MouseEvent} event - The mouse event -// * @returns {Object} Object with cell_id property, or empty object if not found -// */ -// function getCellId(event) { -// const cell = event.target.closest('.dt2-cell'); -// if (cell && cell.id) { -// return {cell_id: cell.id}; -// } -// return {cell_id: null}; -// } -// -// /** -// * Check if the click was on a dropdown button element. -// * Used with hx-vals="js:getDropdownExtra()" for Dropdown toggle behavior. -// * -// * @param {MouseEvent} event - The mouse event -// * @returns {Object} Object with is_button boolean property -// */ -// function getDropdownExtra(event) { -// const button = event.target.closest('.mf-dropdown-btn'); -// return {is_button: button !== null}; -// } -// -// /** -// * Shared utility function for triggering HTMX actions from keyboard/mouse bindings. -// * Handles dynamic hx-vals with "js:functionName()" syntax. -// * -// * @param {string} elementId - ID of the element -// * @param {Object} config - HTMX configuration object -// * @param {string} combinationStr - The matched combination string -// * @param {boolean} isInside - Whether the focus/click is inside the element -// * @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent) -// */ -// function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { -// const element = document.getElementById(elementId); -// if (!element) return; -// -// const hasFocus = document.activeElement === element; -// -// // Extract HTTP method and URL from hx-* attributes -// let method = 'POST'; // default -// let url = null; -// -// const methodMap = { -// 'hx-post': 'POST', -// 'hx-get': 'GET', -// 'hx-put': 'PUT', -// 'hx-delete': 'DELETE', -// 'hx-patch': 'PATCH' -// }; -// -// for (const [attr, httpMethod] of Object.entries(methodMap)) { -// if (config[attr]) { -// method = httpMethod; -// url = config[attr]; -// break; -// } -// } -// -// if (!url) { -// console.error('No HTTP method attribute found in config:', config); -// return; -// } -// -// // Build htmx.ajax options -// const htmxOptions = {}; -// -// // Map hx-target to target -// if (config['hx-target']) { -// htmxOptions.target = config['hx-target']; -// } -// -// // Map hx-swap to swap -// if (config['hx-swap']) { -// htmxOptions.swap = config['hx-swap']; -// } -// -// // Map hx-vals to values and add combination, has_focus, and is_inside -// const values = {}; -// -// // 1. Merge static hx-vals from command (if present) -// if (config['hx-vals'] && typeof config['hx-vals'] === 'object') { -// Object.assign(values, config['hx-vals']); -// } -// -// // 2. Merge hx-vals-extra (user overrides) -// if (config['hx-vals-extra']) { -// const extra = config['hx-vals-extra']; -// -// // Merge static dict values -// if (extra.dict && typeof extra.dict === 'object') { -// Object.assign(values, extra.dict); -// } -// -// // Call dynamic JS function and merge result -// if (extra.js) { -// try { -// const func = window[extra.js]; -// if (typeof func === 'function') { -// const dynamicValues = func(event, element, combinationStr); -// if (dynamicValues && typeof dynamicValues === 'object') { -// Object.assign(values, dynamicValues); -// } -// } else { -// console.error(`Function "${extra.js}" not found on window`); -// } -// } catch (e) { -// console.error('Error calling dynamic hx-vals function:', e); -// } -// } -// } -// -// values.combination = combinationStr; -// values.has_focus = hasFocus; -// values.is_inside = isInside; -// htmxOptions.values = values; -// -// // Add any other hx-* attributes (like hx-headers, hx-select, etc.) -// for (const [key, value] of Object.entries(config)) { -// if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) { -// // Remove 'hx-' prefix and convert to camelCase -// const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); -// htmxOptions[optionKey] = value; -// } -// } -// -// // Make AJAX call with htmx -// htmx.ajax(method, url, htmxOptions); -// } -// -// /** -// * Create keyboard bindings -// */ -// (function () { -// /** -// * Global registry to store keyboard shortcuts for multiple elements -// */ -// const KeyboardRegistry = { -// elements: new Map(), // elementId -> { tree, element } -// listenerAttached: false, -// currentKeys: new Set(), -// snapshotHistory: [], -// pendingTimeout: null, -// pendingMatches: [], // Array of matches waiting for timeout -// sequenceTimeout: 500 // 500ms timeout for sequences -// }; -// -// /** -// * Normalize key names to lowercase for case-insensitive comparison -// * @param {string} key - The key to normalize -// * @returns {string} - Normalized key name -// */ -// function normalizeKey(key) { -// const keyMap = { -// 'control': 'ctrl', -// 'escape': 'esc', -// 'delete': 'del' -// }; -// -// const normalized = key.toLowerCase(); -// return keyMap[normalized] || normalized; -// } -// -// /** -// * Create a unique string key from a Set of keys for Map indexing -// * @param {Set} keySet - Set of normalized keys -// * @returns {string} - Sorted string representation -// */ -// function setToKey(keySet) { -// return Array.from(keySet).sort().join('+'); -// } -// -// /** -// * Parse a single element (can be a single key or a simultaneous combination) -// * @param {string} element - The element string (e.g., "a" or "Ctrl+C") -// * @returns {Set} - Set of normalized keys -// */ -// function parseElement(element) { -// if (element.includes('+')) { -// // Simultaneous combination -// return new Set(element.split('+').map(k => normalizeKey(k.trim()))); -// } -// // Single key -// return new Set([normalizeKey(element.trim())]); -// } -// -// /** -// * Parse a combination string into sequence elements -// * @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C") -// * @returns {Array} - Array of Sets representing the sequence -// */ -// function parseCombination(combination) { -// // Check if it's a sequence (contains space) -// if (combination.includes(' ')) { -// return combination.split(' ').map(el => parseElement(el.trim())); -// } -// -// // Single element (can be a key or simultaneous combination) -// return [parseElement(combination)]; -// } -// -// /** -// * Create a new tree node -// * @returns {Object} - New tree node -// */ -// function createTreeNode() { -// return { -// config: null, -// combinationStr: null, -// children: new Map() -// }; -// } -// -// /** -// * Build a tree from combinations -// * @param {Object} combinations - Map of combination strings to HTMX config objects -// * @returns {Object} - Root tree node -// */ -// function buildTree(combinations) { -// const root = createTreeNode(); -// -// for (const [combinationStr, config] of Object.entries(combinations)) { -// const sequence = parseCombination(combinationStr); -// let currentNode = root; -// -// for (const keySet of sequence) { -// const key = setToKey(keySet); -// -// if (!currentNode.children.has(key)) { -// currentNode.children.set(key, createTreeNode()); -// } -// -// currentNode = currentNode.children.get(key); -// } -// -// // Mark as end of sequence and store config -// currentNode.config = config; -// currentNode.combinationStr = combinationStr; -// } -// -// return root; -// } -// -// /** -// * Traverse the tree with the current snapshot history -// * @param {Object} treeRoot - Root of the tree -// * @param {Array} snapshotHistory - Array of Sets representing pressed keys -// * @returns {Object|null} - Current node or null if no match -// */ -// function traverseTree(treeRoot, snapshotHistory) { -// let currentNode = treeRoot; -// -// for (const snapshot of snapshotHistory) { -// const key = setToKey(snapshot); -// -// if (!currentNode.children.has(key)) { -// return null; -// } -// -// currentNode = currentNode.children.get(key); -// } -// -// return currentNode; -// } -// -// /** -// * Check if we're inside an input element where typing should work normally -// * @returns {boolean} - True if inside an input-like element -// */ -// function isInInputContext() { -// const activeElement = document.activeElement; -// if (!activeElement) return false; -// -// const tagName = activeElement.tagName.toLowerCase(); -// -// // Check for input/textarea -// if (tagName === 'input' || tagName === 'textarea') { -// return true; -// } -// -// // Check for contenteditable -// if (activeElement.isContentEditable) { -// return true; -// } -// -// return false; -// } -// -// /** -// * Handle keyboard events and trigger matching combinations -// * @param {KeyboardEvent} event - The keyboard event -// */ -// function handleKeyboardEvent(event) { -// const key = normalizeKey(event.key); -// -// // Add key to current pressed keys -// KeyboardRegistry.currentKeys.add(key); -// // console.debug("Received key", key); -// -// // Create a snapshot of current keyboard state -// const snapshot = new Set(KeyboardRegistry.currentKeys); -// -// // Add snapshot to history -// KeyboardRegistry.snapshotHistory.push(snapshot); -// -// // Cancel any pending timeout -// if (KeyboardRegistry.pendingTimeout) { -// clearTimeout(KeyboardRegistry.pendingTimeout); -// KeyboardRegistry.pendingTimeout = null; -// KeyboardRegistry.pendingMatches = []; -// } -// -// // Collect match information for all elements -// const currentMatches = []; -// let anyHasLongerSequence = false; -// let foundAnyMatch = false; -// -// // Check all registered elements for matching combinations -// for (const [elementId, data] of KeyboardRegistry.elements) { -// const element = document.getElementById(elementId); -// if (!element) continue; -// -// // Check if focus is inside this element (element itself or any child) -// const isInside = element.contains(document.activeElement); -// -// const treeRoot = data.tree; -// -// // Traverse the tree with current snapshot history -// const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory); -// -// if (!currentNode) { -// // No match in this tree, continue to next element -// // console.debug("No match in tree for event", key); -// continue; -// } -// -// // We found at least a partial match -// foundAnyMatch = true; -// -// // Check if we have a match (node has a URL) -// const hasMatch = currentNode.config !== null; -// -// // Check if there are longer sequences possible (node has children) -// const hasLongerSequences = currentNode.children.size > 0; -// -// // Track if ANY element has longer sequences possible -// if (hasLongerSequences) { -// anyHasLongerSequence = true; -// } -// -// // Collect matches -// if (hasMatch) { -// currentMatches.push({ -// elementId: elementId, -// config: currentNode.config, -// combinationStr: currentNode.combinationStr, -// isInside: isInside -// }); -// } -// } -// -// // Prevent default if we found any match and not in input context -// if (currentMatches.length > 0 && !isInInputContext()) { -// event.preventDefault(); -// } -// -// // Decision logic based on matches and longer sequences -// if (currentMatches.length > 0 && !anyHasLongerSequence) { -// // We have matches and NO element has longer sequences possible -// // Trigger ALL matches immediately -// for (const match of currentMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); -// } -// -// // Clear history after triggering -// KeyboardRegistry.snapshotHistory = []; -// -// } else if (currentMatches.length > 0 && anyHasLongerSequence) { -// // We have matches but AT LEAST ONE element has longer sequences possible -// // Wait for timeout - ALL current matches will be triggered if timeout expires -// -// KeyboardRegistry.pendingMatches = currentMatches; -// const savedEvent = event; // Save event for timeout callback -// -// KeyboardRegistry.pendingTimeout = setTimeout(() => { -// // Timeout expired, trigger ALL pending matches -// for (const match of KeyboardRegistry.pendingMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); -// } -// -// // Clear state -// KeyboardRegistry.snapshotHistory = []; -// KeyboardRegistry.pendingMatches = []; -// KeyboardRegistry.pendingTimeout = null; -// }, KeyboardRegistry.sequenceTimeout); -// -// } else if (currentMatches.length === 0 && anyHasLongerSequence) { -// // No matches yet but longer sequences are possible -// // Just wait, don't trigger anything -// -// } else { -// // No matches and no longer sequences possible -// // This is an invalid sequence - clear history -// KeyboardRegistry.snapshotHistory = []; -// } -// -// // If we found no match at all, clear the history -// // This handles invalid sequences like "A C" when only "A B" exists -// if (!foundAnyMatch) { -// KeyboardRegistry.snapshotHistory = []; -// } -// -// // Also clear history if it gets too long (prevent memory issues) -// if (KeyboardRegistry.snapshotHistory.length > 10) { -// KeyboardRegistry.snapshotHistory = []; -// } -// } -// -// /** -// * Handle keyup event to remove keys from current pressed keys -// * @param {KeyboardEvent} event - The keyboard event -// */ -// function handleKeyUp(event) { -// const key = normalizeKey(event.key); -// KeyboardRegistry.currentKeys.delete(key); -// } -// -// /** -// * Attach the global keyboard event listener if not already attached -// */ -// function attachGlobalListener() { -// if (!KeyboardRegistry.listenerAttached) { -// document.addEventListener('keydown', handleKeyboardEvent); -// document.addEventListener('keyup', handleKeyUp); -// KeyboardRegistry.listenerAttached = true; -// } -// } -// -// /** -// * Detach the global keyboard event listener -// */ -// function detachGlobalListener() { -// if (KeyboardRegistry.listenerAttached) { -// document.removeEventListener('keydown', handleKeyboardEvent); -// document.removeEventListener('keyup', handleKeyUp); -// KeyboardRegistry.listenerAttached = false; -// -// // Clean up all state -// KeyboardRegistry.currentKeys.clear(); -// KeyboardRegistry.snapshotHistory = []; -// if (KeyboardRegistry.pendingTimeout) { -// clearTimeout(KeyboardRegistry.pendingTimeout); -// KeyboardRegistry.pendingTimeout = null; -// } -// KeyboardRegistry.pendingMatches = []; -// } -// } -// -// /** -// * Add keyboard support to an element -// * @param {string} elementId - The ID of the element -// * @param {string} combinationsJson - JSON string of combinations mapping -// */ -// window.add_keyboard_support = function (elementId, combinationsJson) { -// // Parse the combinations JSON -// const combinations = JSON.parse(combinationsJson); -// -// // Build tree for this element -// const tree = buildTree(combinations); -// -// // Get element reference -// const element = document.getElementById(elementId); -// if (!element) { -// console.error("Element with ID", elementId, "not found!"); -// return; -// } -// -// // Add to registry -// KeyboardRegistry.elements.set(elementId, { -// tree: tree, -// element: element -// }); -// -// // Attach global listener if not already attached -// attachGlobalListener(); -// }; -// -// /** -// * Remove keyboard support from an element -// * @param {string} elementId - The ID of the element -// */ -// window.remove_keyboard_support = function (elementId) { -// // Remove from registry -// if (!KeyboardRegistry.elements.has(elementId)) { -// console.warn("Element with ID", elementId, "not found in keyboard registry!"); -// return; -// } -// -// KeyboardRegistry.elements.delete(elementId); -// -// // If no more elements, detach global listeners -// if (KeyboardRegistry.elements.size === 0) { -// detachGlobalListener(); -// } -// }; -// })(); -// -// /** -// * Create mouse bindings -// */ -// (function () { -// /** -// * Global registry to store mouse shortcuts for multiple elements -// */ -// const MouseRegistry = { -// elements: new Map(), // elementId -> { tree, element } -// listenerAttached: false, -// snapshotHistory: [], -// pendingTimeout: null, -// pendingMatches: [], // Array of matches waiting for timeout -// sequenceTimeout: 500, // 500ms timeout for sequences -// clickHandler: null, -// contextmenuHandler: null -// }; -// -// /** -// * Normalize mouse action names -// * @param {string} action - The action to normalize -// * @returns {string} - Normalized action name -// */ -// function normalizeAction(action) { -// const normalized = action.toLowerCase().trim(); -// -// // Handle aliases -// const aliasMap = { -// 'rclick': 'right_click' -// }; -// -// return aliasMap[normalized] || normalized; -// } -// -// /** -// * Create a unique string key from a Set of actions for Map indexing -// * @param {Set} actionSet - Set of normalized actions -// * @returns {string} - Sorted string representation -// */ -// function setToKey(actionSet) { -// return Array.from(actionSet).sort().join('+'); -// } -// -// /** -// * Parse a single element (can be a simple click or click with modifiers) -// * @param {string} element - The element string (e.g., "click" or "ctrl+click") -// * @returns {Set} - Set of normalized actions -// */ -// function parseElement(element) { -// if (element.includes('+')) { -// // Click with modifiers -// return new Set(element.split('+').map(a => normalizeAction(a))); -// } -// // Simple click -// return new Set([normalizeAction(element)]); -// } -// -// /** -// * Parse a combination string into sequence elements -// * @param {string} combination - The combination string (e.g., "click right_click") -// * @returns {Array} - Array of Sets representing the sequence -// */ -// function parseCombination(combination) { -// // Check if it's a sequence (contains space) -// if (combination.includes(' ')) { -// return combination.split(' ').map(el => parseElement(el.trim())); -// } -// -// // Single element (can be a click or click with modifiers) -// return [parseElement(combination)]; -// } -// -// /** -// * Create a new tree node -// * @returns {Object} - New tree node -// */ -// function createTreeNode() { -// return { -// config: null, -// combinationStr: null, -// children: new Map() -// }; -// } -// -// /** -// * Build a tree from combinations -// * @param {Object} combinations - Map of combination strings to HTMX config objects -// * @returns {Object} - Root tree node -// */ -// function buildTree(combinations) { -// const root = createTreeNode(); -// -// for (const [combinationStr, config] of Object.entries(combinations)) { -// const sequence = parseCombination(combinationStr); -// //console.log("Parsing mouse combination", combinationStr, "=>", sequence); -// let currentNode = root; -// -// for (const actionSet of sequence) { -// const key = setToKey(actionSet); -// -// if (!currentNode.children.has(key)) { -// currentNode.children.set(key, createTreeNode()); -// } -// -// currentNode = currentNode.children.get(key); -// } -// -// // Mark as end of sequence and store config -// currentNode.config = config; -// currentNode.combinationStr = combinationStr; -// } -// -// return root; -// } -// -// /** -// * Traverse the tree with the current snapshot history -// * @param {Object} treeRoot - Root of the tree -// * @param {Array} snapshotHistory - Array of Sets representing mouse actions -// * @returns {Object|null} - Current node or null if no match -// */ -// function traverseTree(treeRoot, snapshotHistory) { -// let currentNode = treeRoot; -// -// for (const snapshot of snapshotHistory) { -// const key = setToKey(snapshot); -// -// if (!currentNode.children.has(key)) { -// return null; -// } -// -// currentNode = currentNode.children.get(key); -// } -// -// return currentNode; -// } -// -// /** -// * Check if we're inside an input element where clicking should work normally -// * @returns {boolean} - True if inside an input-like element -// */ -// function isInInputContext() { -// const activeElement = document.activeElement; -// if (!activeElement) return false; -// -// const tagName = activeElement.tagName.toLowerCase(); -// -// // Check for input/textarea -// if (tagName === 'input' || tagName === 'textarea') { -// return true; -// } -// -// // Check for contenteditable -// if (activeElement.isContentEditable) { -// return true; -// } -// -// return false; -// } -// -// /** -// * Get the element that was actually clicked (from registered elements) -// * @param {Element} target - The clicked element -// * @returns {string|null} - Element ID if found, null otherwise -// */ -// function findRegisteredElement(target) { -// // Check if target itself is registered -// if (target.id && MouseRegistry.elements.has(target.id)) { -// return target.id; -// } -// -// // Check if any parent is registered -// let current = target.parentElement; -// while (current) { -// if (current.id && MouseRegistry.elements.has(current.id)) { -// return current.id; -// } -// current = current.parentElement; -// } -// -// return null; -// } -// -// /** -// * Create a snapshot from mouse event -// * @param {MouseEvent} event - The mouse event -// * @param {string} baseAction - The base action ('click' or 'right_click') -// * @returns {Set} - Set of actions representing this click -// */ -// function createSnapshot(event, baseAction) { -// const actions = new Set([baseAction]); -// -// // Add modifiers if present -// if (event.ctrlKey || event.metaKey) { -// actions.add('ctrl'); -// } -// if (event.shiftKey) { -// actions.add('shift'); -// } -// if (event.altKey) { -// actions.add('alt'); -// } -// -// return actions; -// } -// -// /** -// * Handle mouse events and trigger matching combinations -// * @param {MouseEvent} event - The mouse event -// * @param {string} baseAction - The base action ('click' or 'right_click') -// */ -// function handleMouseEvent(event, baseAction) { -// // Different behavior for click vs right_click -// if (baseAction === 'click') { -// // Click: trigger for ALL registered elements (useful for closing modals/popups) -// handleGlobalClick(event); -// } else if (baseAction === 'right_click') { -// // Right-click: trigger ONLY if clicked on a registered element -// handleElementRightClick(event); -// } -// } -// -// /** -// * Handle global click events (triggers for all registered elements) -// * @param {MouseEvent} event - The mouse event -// */ -// function handleGlobalClick(event) { -// // DEBUG: Measure click handler performance -// const clickStart = performance.now(); -// const elementCount = MouseRegistry.elements.size; -// -// //console.warn(`🖱️ Click handler START: processing ${elementCount} registered elements`); -// -// // Create a snapshot of current mouse action with modifiers -// const snapshot = createSnapshot(event, 'click'); -// -// // Add snapshot to history -// MouseRegistry.snapshotHistory.push(snapshot); -// -// // Cancel any pending timeout -// if (MouseRegistry.pendingTimeout) { -// clearTimeout(MouseRegistry.pendingTimeout); -// MouseRegistry.pendingTimeout = null; -// MouseRegistry.pendingMatches = []; -// } -// -// // Collect match information for ALL registered elements -// const currentMatches = []; -// let anyHasLongerSequence = false; -// let foundAnyMatch = false; -// let iterationCount = 0; -// -// for (const [elementId, data] of MouseRegistry.elements) { -// iterationCount++; -// const element = document.getElementById(elementId); -// if (!element) continue; -// -// // Check if click was inside this element -// const isInside = element.contains(event.target); -// -// const treeRoot = data.tree; -// -// // Traverse the tree with current snapshot history -// const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); -// -// if (!currentNode) { -// // No match in this tree -// continue; -// } -// -// // We found at least a partial match -// foundAnyMatch = true; -// -// // Check if we have a match (node has config) -// const hasMatch = currentNode.config !== null; -// -// // Check if there are longer sequences possible (node has children) -// const hasLongerSequences = currentNode.children.size > 0; -// -// if (hasLongerSequences) { -// anyHasLongerSequence = true; -// } -// -// // Collect matches -// if (hasMatch) { -// currentMatches.push({ -// elementId: elementId, -// config: currentNode.config, -// combinationStr: currentNode.combinationStr, -// isInside: isInside -// }); -// } -// } -// -// // Prevent default only if click was INSIDE a registered element -// // Clicks outside should preserve native behavior (checkboxes, buttons, etc.) -// const anyMatchInside = currentMatches.some(match => match.isInside); -// if (currentMatches.length > 0 && anyMatchInside && !isInInputContext()) { -// event.preventDefault(); -// } -// -// // Decision logic based on matches and longer sequences -// if (currentMatches.length > 0 && !anyHasLongerSequence) { -// // We have matches and NO longer sequences possible -// // Trigger ALL matches immediately -// for (const match of currentMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); -// } -// -// // Clear history after triggering -// MouseRegistry.snapshotHistory = []; -// -// } else if (currentMatches.length > 0 && anyHasLongerSequence) { -// // We have matches but longer sequences are possible -// // Wait for timeout - ALL current matches will be triggered if timeout expires -// -// MouseRegistry.pendingMatches = currentMatches; -// const savedEvent = event; // Save event for timeout callback -// -// MouseRegistry.pendingTimeout = setTimeout(() => { -// // Timeout expired, trigger ALL pending matches -// for (const match of MouseRegistry.pendingMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); -// } -// -// // Clear state -// MouseRegistry.snapshotHistory = []; -// MouseRegistry.pendingMatches = []; -// MouseRegistry.pendingTimeout = null; -// }, MouseRegistry.sequenceTimeout); -// -// } else if (currentMatches.length === 0 && anyHasLongerSequence) { -// // No matches yet but longer sequences are possible -// // Just wait, don't trigger anything -// -// } else { -// // No matches and no longer sequences possible -// // This is an invalid sequence - clear history -// MouseRegistry.snapshotHistory = []; -// } -// -// // If we found no match at all, clear the history -// if (!foundAnyMatch) { -// MouseRegistry.snapshotHistory = []; -// } -// -// // Also clear history if it gets too long (prevent memory issues) -// if (MouseRegistry.snapshotHistory.length > 10) { -// MouseRegistry.snapshotHistory = []; -// } -// -// // Warn if click handler is slow -// const clickDuration = performance.now() - clickStart; -// if (clickDuration > 100) { -// console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`); -// } -// } -// -// /** -// * Handle right-click events (triggers only for clicked element) -// * @param {MouseEvent} event - The mouse event -// */ -// function handleElementRightClick(event) { -// // Find which registered element was clicked -// const elementId = findRegisteredElement(event.target); -// -// if (!elementId) { -// // Right-click wasn't on a registered element - don't prevent default -// // This allows browser context menu to appear -// return; -// } -// -// //console.debug("Right-click on registered element", elementId); -// -// // For right-click, clicked_inside is always true (we only trigger if clicked on element) -// const clickedInside = true; -// -// // Create a snapshot of current mouse action with modifiers -// const snapshot = createSnapshot(event, 'right_click'); -// -// // Add snapshot to history -// MouseRegistry.snapshotHistory.push(snapshot); -// -// // Cancel any pending timeout -// if (MouseRegistry.pendingTimeout) { -// clearTimeout(MouseRegistry.pendingTimeout); -// MouseRegistry.pendingTimeout = null; -// MouseRegistry.pendingMatches = []; -// } -// -// // Collect match information for this element -// const currentMatches = []; -// let anyHasLongerSequence = false; -// let foundAnyMatch = false; -// -// const data = MouseRegistry.elements.get(elementId); -// if (!data) return; -// -// const treeRoot = data.tree; -// -// // Traverse the tree with current snapshot history -// const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); -// -// if (!currentNode) { -// // No match in this tree -// //console.debug("No match in tree for right-click"); -// // Clear history for invalid sequences -// MouseRegistry.snapshotHistory = []; -// return; -// } -// -// // We found at least a partial match -// foundAnyMatch = true; -// -// // Check if we have a match (node has config) -// const hasMatch = currentNode.config !== null; -// -// // Check if there are longer sequences possible (node has children) -// const hasLongerSequences = currentNode.children.size > 0; -// -// if (hasLongerSequences) { -// anyHasLongerSequence = true; -// } -// -// // Collect matches -// if (hasMatch) { -// currentMatches.push({ -// elementId: elementId, -// config: currentNode.config, -// combinationStr: currentNode.combinationStr, -// isInside: true // Right-click only triggers when clicking on element -// }); -// } -// -// // Prevent default if we found any match and not in input context -// if (currentMatches.length > 0 && !isInInputContext()) { -// event.preventDefault(); -// } -// -// // Decision logic based on matches and longer sequences -// if (currentMatches.length > 0 && !anyHasLongerSequence) { -// // We have matches and NO longer sequences possible -// // Trigger ALL matches immediately -// for (const match of currentMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); -// } -// -// // Clear history after triggering -// MouseRegistry.snapshotHistory = []; -// -// } else if (currentMatches.length > 0 && anyHasLongerSequence) { -// // We have matches but longer sequences are possible -// // Wait for timeout - ALL current matches will be triggered if timeout expires -// -// MouseRegistry.pendingMatches = currentMatches; -// const savedEvent = event; // Save event for timeout callback -// -// MouseRegistry.pendingTimeout = setTimeout(() => { -// // Timeout expired, trigger ALL pending matches -// for (const match of MouseRegistry.pendingMatches) { -// triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); -// } -// -// // Clear state -// MouseRegistry.snapshotHistory = []; -// MouseRegistry.pendingMatches = []; -// MouseRegistry.pendingTimeout = null; -// }, MouseRegistry.sequenceTimeout); -// -// } else if (currentMatches.length === 0 && anyHasLongerSequence) { -// // No matches yet but longer sequences are possible -// // Just wait, don't trigger anything -// -// } else { -// // No matches and no longer sequences possible -// // This is an invalid sequence - clear history -// MouseRegistry.snapshotHistory = []; -// } -// -// // If we found no match at all, clear the history -// if (!foundAnyMatch) { -// MouseRegistry.snapshotHistory = []; -// } -// -// // Also clear history if it gets too long (prevent memory issues) -// if (MouseRegistry.snapshotHistory.length > 10) { -// MouseRegistry.snapshotHistory = []; -// } -// } -// -// /** -// * Attach the global mouse event listeners if not already attached -// */ -// function attachGlobalListener() { -// if (!MouseRegistry.listenerAttached) { -// // Store handler references for proper removal -// MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click'); -// MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); -// -// document.addEventListener('click', MouseRegistry.clickHandler); -// document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); -// MouseRegistry.listenerAttached = true; -// } -// } -// -// /** -// * Detach the global mouse event listeners -// */ -// function detachGlobalListener() { -// if (MouseRegistry.listenerAttached) { -// document.removeEventListener('click', MouseRegistry.clickHandler); -// document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); -// MouseRegistry.listenerAttached = false; -// -// // Clean up handler references -// MouseRegistry.clickHandler = null; -// MouseRegistry.contextmenuHandler = null; -// -// // Clean up all state -// MouseRegistry.snapshotHistory = []; -// if (MouseRegistry.pendingTimeout) { -// clearTimeout(MouseRegistry.pendingTimeout); -// MouseRegistry.pendingTimeout = null; -// } -// MouseRegistry.pendingMatches = []; -// } -// } -// -// /** -// * Add mouse support to an element -// * @param {string} elementId - The ID of the element -// * @param {string} combinationsJson - JSON string of combinations mapping -// */ -// window.add_mouse_support = function (elementId, combinationsJson) { -// // Parse the combinations JSON -// const combinations = JSON.parse(combinationsJson); -// -// // Build tree for this element -// const tree = buildTree(combinations); -// -// // Get element reference -// const element = document.getElementById(elementId); -// if (!element) { -// console.error("Element with ID", elementId, "not found!"); -// return; -// } -// -// // Add to registry -// MouseRegistry.elements.set(elementId, { -// tree: tree, -// element: element -// }); -// -// // Attach global listener if not already attached -// attachGlobalListener(); -// }; -// -// /** -// * Remove mouse support from an element -// * @param {string} elementId - The ID of the element -// */ -// window.remove_mouse_support = function (elementId) { -// // Remove from registry -// if (!MouseRegistry.elements.has(elementId)) { -// console.warn("Element with ID", elementId, "not found in mouse registry!"); -// return; -// } -// -// MouseRegistry.elements.delete(elementId); -// -// // If no more elements, detach global listeners -// if (MouseRegistry.elements.size === 0) { -// detachGlobalListener(); -// } -// }; -// })(); -// -// function initDataGrid(gridId) { -// initDataGridScrollbars(gridId); -// initDataGridMouseOver(gridId); -// makeDatagridColumnsResizable(gridId); -// makeDatagridColumnsMovable(gridId); -// updateDatagridSelection(gridId) -// } -// -// -// /** -// * Initialize DataGrid hover effects using event delegation. -// * -// * Optimizations: -// * - Event delegation: 1 listener instead of N×2 (where N = number of cells) -// * - Row mode: O(1) via class toggle on parent row -// * - Column mode: RAF batching + cached cells for efficient class removal -// * - Works with HTMX swaps: listener on stable parent, querySelectorAll finds new cells -// * - No mouseout: hover selection stays visible when leaving the table -// * -// * @param {string} gridId - The DataGrid instance ID -// */ -// function initDataGridMouseOver(gridId) { -// const table = document.getElementById(`t_${gridId}`); -// if (!table) { -// console.error(`Table with id "t_${gridId}" not found.`); -// return; -// } -// -// const wrapper = document.getElementById(`tw_${gridId}`); -// -// // Track hover state -// let currentHoverRow = null; -// let currentHoverColId = null; -// let currentHoverColCells = null; -// -// table.addEventListener('mouseover', (e) => { -// // Skip hover during scrolling -// if (wrapper?.hasAttribute('mf-no-hover')) return; -// -// const cell = e.target.closest('.dt2-cell'); -// if (!cell) return; -// -// const selectionModeDiv = document.getElementById(`tsm_${gridId}`); -// const selectionMode = selectionModeDiv?.getAttribute('selection-mode'); -// -// if (selectionMode === 'row') { -// const rowElement = cell.parentElement; -// if (rowElement !== currentHoverRow) { -// if (currentHoverRow) { -// currentHoverRow.classList.remove('dt2-hover-row'); -// } -// rowElement.classList.add('dt2-hover-row'); -// currentHoverRow = rowElement; -// } -// } else if (selectionMode === 'column') { -// const colId = cell.dataset.col; -// -// // Skip if same column -// if (colId === currentHoverColId) return; -// -// requestAnimationFrame(() => { -// // Remove old column highlight -// if (currentHoverColCells) { -// currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column')); -// } -// -// // Query and add new column highlight -// currentHoverColCells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`); -// currentHoverColCells.forEach(c => c.classList.add('dt2-hover-column')); -// -// currentHoverColId = colId; -// }); -// } -// }); -// -// // Clean up when leaving the table entirely -// table.addEventListener('mouseout', (e) => { -// if (!table.contains(e.relatedTarget)) { -// if (currentHoverRow) { -// currentHoverRow.classList.remove('dt2-hover-row'); -// currentHoverRow = null; -// } -// if (currentHoverColCells) { -// currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column')); -// currentHoverColCells = null; -// currentHoverColId = null; -// } -// } -// }); -// } -// -// /** -// * Initialize DataGrid with CSS Grid layout + Custom Scrollbars -// * -// * Adapted from previous custom scrollbar implementation to work with CSS Grid. -// * - Grid handles layout (no height calculations needed) -// * - Custom scrollbars for visual consistency and positioning control -// * - Vertical scroll: on body container (.dt2-body-container) -// * - Horizontal scroll: on table (.dt2-table) to scroll header, body, footer together -// * -// * @param {string} gridId - The ID of the DataGrid instance -// */ -// function initDataGridScrollbars(gridId) { -// const wrapper = document.getElementById(`tw_${gridId}`); -// -// if (!wrapper) { -// console.error(`DataGrid wrapper "tw_${gridId}" not found.`); -// return; -// } -// -// // Cleanup previous listeners if any -// if (wrapper._scrollbarAbortController) { -// wrapper._scrollbarAbortController.abort(); -// } -// wrapper._scrollbarAbortController = new AbortController(); -// const signal = wrapper._scrollbarAbortController.signal; -// -// -// const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical"); -// const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper"); -// const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal"); -// const horizontalWrapper = wrapper.querySelector(".dt2-scrollbars-horizontal-wrapper"); -// const bodyContainer = wrapper.querySelector(".dt2-body-container"); -// const table = wrapper.querySelector(".dt2-table"); -// -// if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !bodyContainer || !table) { -// console.error("Essential scrollbar or content elements are missing in the datagrid."); -// return; -// } -// -// // OPTIMIZATION: Cache element references to avoid repeated querySelector calls -// const header = table.querySelector(".dt2-header"); -// const body = table.querySelector(".dt2-body"); -// -// // OPTIMIZATION: RequestAnimationFrame flags to throttle visual updates -// let rafScheduledVertical = false; -// let rafScheduledHorizontal = false; -// let rafScheduledUpdate = false; -// -// // OPTIMIZATION: Pre-calculated scroll ratios (updated in updateScrollbars) -// // Allows instant mousedown with zero DOM reads -// let cachedVerticalScrollRatio = 0; -// let cachedHorizontalScrollRatio = 0; -// -// // OPTIMIZATION: Cached scroll positions to avoid DOM reads in mousedown -// // Initialized once at setup, updated in RAF handlers after each scroll change -// let cachedBodyScrollTop = bodyContainer.scrollTop; -// let cachedTableScrollLeft = table.scrollLeft; -// -// /** -// * OPTIMIZED: Batched update function -// * Phase 1: Read all DOM properties (no writes) -// * Phase 2: Calculate all values -// * Phase 3: Write all DOM properties in single RAF -// */ -// const updateScrollbars = () => { -// if (rafScheduledUpdate) return; -// -// rafScheduledUpdate = true; -// -// requestAnimationFrame(() => { -// rafScheduledUpdate = false; -// -// // PHASE 1: Read all DOM properties -// const metrics = { -// bodyScrollHeight: bodyContainer.scrollHeight, -// bodyClientHeight: bodyContainer.clientHeight, -// bodyScrollTop: bodyContainer.scrollTop, -// tableClientWidth: table.clientWidth, -// tableScrollLeft: table.scrollLeft, -// verticalWrapperHeight: verticalWrapper.offsetHeight, -// horizontalWrapperWidth: horizontalWrapper.offsetWidth, -// headerScrollWidth: header ? header.scrollWidth : 0, -// bodyScrollWidth: body ? body.scrollWidth : 0 -// }; -// -// // PHASE 2: Calculate all values -// const contentWidth = Math.max(metrics.headerScrollWidth, metrics.bodyScrollWidth); -// -// // Visibility -// const isVerticalRequired = metrics.bodyScrollHeight > metrics.bodyClientHeight; -// const isHorizontalRequired = contentWidth > metrics.tableClientWidth; -// -// // Scrollbar sizes -// let scrollbarHeight = 0; -// if (metrics.bodyScrollHeight > 0) { -// scrollbarHeight = (metrics.bodyClientHeight / metrics.bodyScrollHeight) * metrics.verticalWrapperHeight; -// } -// -// let scrollbarWidth = 0; -// if (contentWidth > 0) { -// scrollbarWidth = (metrics.tableClientWidth / contentWidth) * metrics.horizontalWrapperWidth; -// } -// -// // Scrollbar positions -// const maxScrollTop = metrics.bodyScrollHeight - metrics.bodyClientHeight; -// let verticalTop = 0; -// if (maxScrollTop > 0) { -// const scrollRatio = metrics.verticalWrapperHeight / metrics.bodyScrollHeight; -// verticalTop = metrics.bodyScrollTop * scrollRatio; -// } -// -// const maxScrollLeft = contentWidth - metrics.tableClientWidth; -// let horizontalLeft = 0; -// if (maxScrollLeft > 0 && contentWidth > 0) { -// const scrollRatio = metrics.horizontalWrapperWidth / contentWidth; -// horizontalLeft = metrics.tableScrollLeft * scrollRatio; -// } -// -// // OPTIMIZATION: Pre-calculate and cache scroll ratios for instant mousedown -// // Vertical scroll ratio -// if (maxScrollTop > 0 && scrollbarHeight > 0) { -// cachedVerticalScrollRatio = maxScrollTop / (metrics.verticalWrapperHeight - scrollbarHeight); -// } else { -// cachedVerticalScrollRatio = 0; -// } -// -// // Horizontal scroll ratio -// if (maxScrollLeft > 0 && scrollbarWidth > 0) { -// cachedHorizontalScrollRatio = maxScrollLeft / (metrics.horizontalWrapperWidth - scrollbarWidth); -// } else { -// cachedHorizontalScrollRatio = 0; -// } -// -// // PHASE 3: Write all DOM properties (already in RAF) -// verticalWrapper.style.display = isVerticalRequired ? "block" : "none"; -// horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none"; -// verticalScrollbar.style.height = `${scrollbarHeight}px`; -// horizontalScrollbar.style.width = `${scrollbarWidth}px`; -// verticalScrollbar.style.top = `${verticalTop}px`; -// horizontalScrollbar.style.left = `${horizontalLeft}px`; -// }); -// }; -// -// // Consolidated drag management -// let isDraggingVertical = false; -// let isDraggingHorizontal = false; -// let dragStartY = 0; -// let dragStartX = 0; -// let dragStartScrollTop = 0; -// let dragStartScrollLeft = 0; -// -// // Vertical scrollbar mousedown -// verticalScrollbar.addEventListener("mousedown", (e) => { -// isDraggingVertical = true; -// dragStartY = e.clientY; -// dragStartScrollTop = cachedBodyScrollTop; -// wrapper.setAttribute("mf-no-tooltip", ""); -// wrapper.setAttribute("mf-no-hover", ""); -// }, {signal}); -// -// // Horizontal scrollbar mousedown -// horizontalScrollbar.addEventListener("mousedown", (e) => { -// isDraggingHorizontal = true; -// dragStartX = e.clientX; -// dragStartScrollLeft = cachedTableScrollLeft; -// wrapper.setAttribute("mf-no-tooltip", ""); -// wrapper.setAttribute("mf-no-hover", ""); -// }, {signal}); -// -// // Consolidated mousemove listener -// document.addEventListener("mousemove", (e) => { -// if (isDraggingVertical) { -// const deltaY = e.clientY - dragStartY; -// -// if (!rafScheduledVertical) { -// rafScheduledVertical = true; -// requestAnimationFrame(() => { -// rafScheduledVertical = false; -// const scrollDelta = deltaY * cachedVerticalScrollRatio; -// bodyContainer.scrollTop = dragStartScrollTop + scrollDelta; -// cachedBodyScrollTop = bodyContainer.scrollTop; -// updateScrollbars(); -// }); -// } -// } else if (isDraggingHorizontal) { -// const deltaX = e.clientX - dragStartX; -// -// if (!rafScheduledHorizontal) { -// rafScheduledHorizontal = true; -// requestAnimationFrame(() => { -// rafScheduledHorizontal = false; -// const scrollDelta = deltaX * cachedHorizontalScrollRatio; -// table.scrollLeft = dragStartScrollLeft + scrollDelta; -// cachedTableScrollLeft = table.scrollLeft; -// updateScrollbars(); -// }); -// } -// } -// }, {signal}); -// -// // Consolidated mouseup listener -// document.addEventListener("mouseup", () => { -// if (isDraggingVertical) { -// isDraggingVertical = false; -// wrapper.removeAttribute("mf-no-tooltip"); -// wrapper.removeAttribute("mf-no-hover"); -// } else if (isDraggingHorizontal) { -// isDraggingHorizontal = false; -// wrapper.removeAttribute("mf-no-tooltip"); -// wrapper.removeAttribute("mf-no-hover"); -// } -// }, {signal}); -// -// // Wheel scrolling - OPTIMIZED with RAF throttling -// let rafScheduledWheel = false; -// let pendingWheelDeltaX = 0; -// let pendingWheelDeltaY = 0; -// let wheelEndTimeout = null; -// -// const handleWheelScrolling = (event) => { -// // Disable hover and tooltip during wheel scroll -// wrapper.setAttribute("mf-no-hover", ""); -// wrapper.setAttribute("mf-no-tooltip", ""); -// -// // Clear previous timeout and re-enable after 150ms of no wheel events -// if (wheelEndTimeout) clearTimeout(wheelEndTimeout); -// wheelEndTimeout = setTimeout(() => { -// wrapper.removeAttribute("mf-no-hover"); -// wrapper.removeAttribute("mf-no-tooltip"); -// }, 150); -// -// // Accumulate wheel deltas -// pendingWheelDeltaX += event.deltaX; -// pendingWheelDeltaY += event.deltaY; -// -// // Schedule update in next animation frame (throttle) -// if (!rafScheduledWheel) { -// rafScheduledWheel = true; -// requestAnimationFrame(() => { -// rafScheduledWheel = false; -// -// // Apply accumulated scroll -// bodyContainer.scrollTop += pendingWheelDeltaY; -// table.scrollLeft += pendingWheelDeltaX; -// -// // Update caches with clamped values (read back from DOM in RAF - OK) -// cachedBodyScrollTop = bodyContainer.scrollTop; -// cachedTableScrollLeft = table.scrollLeft; -// -// // Reset pending deltas -// pendingWheelDeltaX = 0; -// pendingWheelDeltaY = 0; -// -// // Update all scrollbars in a single batched operation -// updateScrollbars(); -// }); -// } -// -// event.preventDefault(); -// }; -// -// wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false, signal}); -// -// // Initialize scrollbars with single batched update -// updateScrollbars(); -// -// // Recompute on window resize with RAF throttling -// let resizeScheduled = false; -// window.addEventListener("resize", () => { -// if (!resizeScheduled) { -// resizeScheduled = true; -// requestAnimationFrame(() => { -// resizeScheduled = false; -// updateScrollbars(); -// }); -// } -// }, {signal}); -// } -// -// function makeDatagridColumnsResizable(datagridId) { -// //console.debug("makeResizable on element " + datagridId); -// -// const tableId = 't_' + datagridId; -// const table = document.getElementById(tableId); -// const resizeHandles = table.querySelectorAll('.dt2-resize-handle'); -// const MIN_WIDTH = 30; // Prevent columns from becoming too narrow -// -// // Attach event listeners using delegation -// resizeHandles.forEach(handle => { -// handle.addEventListener('mousedown', onStartResize); -// handle.addEventListener('touchstart', onStartResize, {passive: false}); -// handle.addEventListener('dblclick', onDoubleClick); // Reset column width -// }); -// -// let resizingState = null; // Maintain resizing state information -// -// function onStartResize(event) { -// event.preventDefault(); // Prevent unintended selections -// -// const isTouch = event.type === 'touchstart'; -// const startX = isTouch ? event.touches[0].pageX : event.pageX; -// const handle = event.target; -// const cell = handle.parentElement; -// const colIndex = cell.getAttribute('data-col'); -// const commandId = handle.dataset.commandId; -// const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`); -// -// // Store initial state -// const startWidth = cell.offsetWidth + 8; -// resizingState = {startX, startWidth, colIndex, commandId, cells}; -// -// // Attach event listeners for resizing -// document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize); -// document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize); -// } -// -// function onResize(event) { -// if (!resizingState) { -// return; -// } -// -// const isTouch = event.type === 'touchmove'; -// const currentX = isTouch ? event.touches[0].pageX : event.pageX; -// const {startX, startWidth, cells} = resizingState; -// -// // Calculate new width and apply constraints -// const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX)); -// cells.forEach(cell => { -// cell.style.width = `${newWidth}px`; -// }); -// } -// -// function onStopResize(event) { -// if (!resizingState) { -// return; -// } -// -// const {colIndex, commandId, cells} = resizingState; -// -// const finalWidth = cells[0].offsetWidth; -// -// // Send width update to server via HTMX -// if (commandId) { -// htmx.ajax('POST', '/myfasthtml/commands', { -// headers: { -// "Content-Type": "application/x-www-form-urlencoded" -// }, -// swap: 'none', -// values: { -// c_id: commandId, -// col_id: colIndex, -// width: finalWidth -// } -// }); -// } -// -// // Clean up -// resizingState = null; -// document.removeEventListener('mousemove', onResize); -// document.removeEventListener('mouseup', onStopResize); -// document.removeEventListener('touchmove', onResize); -// document.removeEventListener('touchend', onStopResize); -// } -// -// function onDoubleClick(event) { -// const handle = event.target; -// const cell = handle.parentElement; -// const colIndex = cell.getAttribute('data-col'); -// const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`); -// -// // Reset column width -// cells.forEach(cell => { -// cell.style.width = ''; // Use CSS default width -// }); -// -// // Emit reset event -// const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}}); -// table.dispatchEvent(resetEvent); -// } -// } -// -// /** -// * Enable column reordering via drag and drop on a DataGrid. -// * Columns can be dragged to new positions with animated transitions. -// * @param {string} gridId - The DataGrid instance ID -// */ -// function makeDatagridColumnsMovable(gridId) { -// const table = document.getElementById(`t_${gridId}`); -// const headerRow = document.getElementById(`th_${gridId}`); -// -// if (!table || !headerRow) { -// console.error(`DataGrid elements not found for ${gridId}`); -// return; -// } -// -// const moveCommandId = headerRow.dataset.moveCommandId; -// const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)'); -// -// let sourceColumn = null; // Column being dragged (original position) -// let lastMoveTarget = null; // Last column we moved to (for persistence) -// let hoverColumn = null; // Current hover target (for delayed move check) -// -// headerCells.forEach(cell => { -// cell.setAttribute('draggable', 'true'); -// -// // Prevent drag when clicking resize handle -// const resizeHandle = cell.querySelector('.dt2-resize-handle'); -// if (resizeHandle) { -// resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false')); -// resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true')); -// } -// -// cell.addEventListener('dragstart', (e) => { -// sourceColumn = cell.getAttribute('data-col'); -// lastMoveTarget = null; -// hoverColumn = null; -// e.dataTransfer.effectAllowed = 'move'; -// e.dataTransfer.setData('text/plain', sourceColumn); -// cell.classList.add('dt2-dragging'); -// }); -// -// cell.addEventListener('dragenter', (e) => { -// e.preventDefault(); -// const targetColumn = cell.getAttribute('data-col'); -// hoverColumn = targetColumn; -// -// if (sourceColumn && sourceColumn !== targetColumn) { -// // Delay to skip columns when dragging fast -// setTimeout(() => { -// if (hoverColumn === targetColumn) { -// moveColumn(table, sourceColumn, targetColumn); -// lastMoveTarget = targetColumn; -// } -// }, 50); -// } -// }); -// -// cell.addEventListener('dragover', (e) => { -// e.preventDefault(); -// e.dataTransfer.dropEffect = 'move'; -// }); -// -// cell.addEventListener('drop', (e) => { -// e.preventDefault(); -// // Persist to server -// if (moveCommandId && sourceColumn && lastMoveTarget) { -// htmx.ajax('POST', '/myfasthtml/commands', { -// headers: {"Content-Type": "application/x-www-form-urlencoded"}, -// swap: 'none', -// values: { -// c_id: moveCommandId, -// source_col_id: sourceColumn, -// target_col_id: lastMoveTarget -// } -// }); -// } -// }); -// -// cell.addEventListener('dragend', () => { -// headerCells.forEach(c => c.classList.remove('dt2-dragging')); -// sourceColumn = null; -// lastMoveTarget = null; -// hoverColumn = null; -// }); -// }); -// } -// -// /** -// * Move a column to a new position with animation. -// * All columns between source and target shift to fill the gap. -// * @param {HTMLElement} table - The table element -// * @param {string} sourceColId - Column ID to move -// * @param {string} targetColId - Column ID to move next to -// */ -// function moveColumn(table, sourceColId, targetColId) { -// const ANIMATION_DURATION = 300; // Must match CSS transition duration -// -// const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`); -// const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`); -// -// if (!sourceHeader || !targetHeader) return; -// if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress -// -// const headerCells = Array.from(sourceHeader.parentNode.children); -// const sourceIdx = headerCells.indexOf(sourceHeader); -// const targetIdx = headerCells.indexOf(targetHeader); -// -// if (sourceIdx === targetIdx) return; -// -// const movingRight = sourceIdx < targetIdx; -// const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`); -// -// // Collect cells that need to shift (between source and target) -// const cellsToShift = []; -// let shiftWidth = 0; -// const [startIdx, endIdx] = movingRight -// ? [sourceIdx + 1, targetIdx] -// : [targetIdx, sourceIdx - 1]; -// -// for (let i = startIdx; i <= endIdx; i++) { -// const colId = headerCells[i].getAttribute('data-col'); -// cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`)); -// shiftWidth += headerCells[i].offsetWidth; -// } -// -// // Calculate animation distances -// const sourceWidth = sourceHeader.offsetWidth; -// const sourceDistance = movingRight ? shiftWidth : -shiftWidth; -// const shiftDistance = movingRight ? -sourceWidth : sourceWidth; -// -// // Animate source column -// sourceCells.forEach(cell => { -// cell.classList.add('dt2-moving'); -// cell.style.transform = `translateX(${sourceDistance}px)`; -// }); -// -// // Animate shifted columns -// cellsToShift.forEach(cell => { -// cell.classList.add('dt2-moving'); -// cell.style.transform = `translateX(${shiftDistance}px)`; -// }); -// -// // After animation: reset transforms and update DOM -// setTimeout(() => { -// [...sourceCells, ...cellsToShift].forEach(cell => { -// cell.classList.remove('dt2-moving'); -// cell.style.transform = ''; -// }); -// -// // Move source column in DOM -// table.querySelectorAll('.dt2-row').forEach(row => { -// const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`); -// const targetCell = row.querySelector(`[data-col="${targetColId}"]`); -// if (sourceCell && targetCell) { -// movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell); -// } -// }); -// }, ANIMATION_DURATION); -// } -// -// /** -// * Initialize DslEditor with CodeMirror 5 -// * -// * Features: -// * - DSL-based autocompletion -// * - Line numbers -// * - Readonly support -// * - Placeholder support -// * - Textarea synchronization -// * - Debounced HTMX server update via updateCommandId -// * -// * Required CodeMirror addons: -// * - addon/hint/show-hint.js -// * - addon/hint/show-hint.css -// * - addon/display/placeholder.js -// * -// * Requires: -// * - htmx loaded globally -// * -// * @param {Object} config -// */ -// function initDslEditor(config) { -// const { -// elementId, -// textareaId, -// lineNumbers, -// autocompletion, -// linting, -// placeholder, -// readonly, -// updateCommandId, -// dslId, -// dsl -// } = config; -// -// const wrapper = document.getElementById(elementId); -// const textarea = document.getElementById(textareaId); -// const editorContainer = document.getElementById(`cm_${elementId}`); -// -// if (!wrapper || !textarea || !editorContainer) { -// console.error(`DslEditor: Missing elements for ${elementId}`); -// return; -// } -// -// if (typeof CodeMirror === "undefined") { -// console.error("DslEditor: CodeMirror 5 not loaded"); -// return; -// } -// -// /* -------------------------------------------------- -// * DSL autocompletion hint (async via server) -// * -------------------------------------------------- */ -// -// // Characters that trigger auto-completion -// const AUTO_TRIGGER_CHARS = [".", "(", '"', " "]; -// -// function dslHint(cm, callback) { -// const cursor = cm.getCursor(); -// const text = cm.getValue(); -// -// // Build URL with query params -// const params = new URLSearchParams({ -// e_id: dslId, -// text: text, -// line: cursor.line, -// ch: cursor.ch -// }); -// -// fetch(`/myfasthtml/completions?${params}`) -// .then(response => response.json()) -// .then(data => { -// if (!data || !data.suggestions || data.suggestions.length === 0) { -// callback(null); -// return; -// } -// -// callback({ -// list: data.suggestions.map(s => ({ -// text: s.label, -// displayText: s.detail ? `${s.label} - ${s.detail}` : s.label -// })), -// from: CodeMirror.Pos(data.from.line, data.from.ch), -// to: CodeMirror.Pos(data.to.line, data.to.ch) -// }); -// }) -// .catch(err => { -// console.error("DslEditor: Completion error", err); -// callback(null); -// }); -// } -// -// // Mark hint function as async for CodeMirror -// dslHint.async = true; -// -// /* -------------------------------------------------- -// * DSL linting (async via server) -// * -------------------------------------------------- */ -// -// function dslLint(text, updateOutput, options, cm) { -// const cursor = cm.getCursor(); -// -// const params = new URLSearchParams({ -// e_id: dslId, -// text: text, -// line: cursor.line, -// ch: cursor.ch -// }); -// -// fetch(`/myfasthtml/validations?${params}`) -// .then(response => response.json()) -// .then(data => { -// if (!data || !data.errors || data.errors.length === 0) { -// updateOutput([]); -// return; -// } -// -// // Convert server errors to CodeMirror lint format -// // Server returns 1-based positions, CodeMirror expects 0-based -// const annotations = data.errors.map(err => ({ -// from: CodeMirror.Pos(err.line - 1, Math.max(0, err.column - 1)), -// to: CodeMirror.Pos(err.line - 1, err.column), -// message: err.message, -// severity: err.severity || "error" -// })); -// -// updateOutput(annotations); -// }) -// .catch(err => { -// console.error("DslEditor: Linting error", err); -// updateOutput([]); -// }); -// } -// -// // Mark lint function as async for CodeMirror -// dslLint.async = true; -// -// /* -------------------------------------------------- -// * Register Simple Mode if available and config provided -// * -------------------------------------------------- */ -// -// let modeName = null; -// -// if (typeof CodeMirror.defineSimpleMode !== "undefined" && dsl && dsl.simpleModeConfig) { -// // Generate unique mode name from DSL name -// modeName = `dsl-${dsl.name.toLowerCase().replace(/\s+/g, '-')}`; -// -// // Register the mode if not already registered -// if (!CodeMirror.modes[modeName]) { -// try { -// CodeMirror.defineSimpleMode(modeName, dsl.simpleModeConfig); -// } catch (err) { -// console.error(`Failed to register Simple Mode for ${dsl.name}:`, err); -// modeName = null; -// } -// } -// } -// -// /* -------------------------------------------------- -// * Create CodeMirror editor -// * -------------------------------------------------- */ -// -// const enableCompletion = autocompletion && dslId; -// // Only enable linting if the lint addon is loaded -// const lintAddonLoaded = typeof CodeMirror.lint !== "undefined" || -// (CodeMirror.defaults && "lint" in CodeMirror.defaults); -// const enableLinting = linting && dslId && lintAddonLoaded; -// -// const editorOptions = { -// value: textarea.value || "", -// mode: modeName || undefined, // Use Simple Mode if available -// theme: "daisy", // Use DaisyUI theme for automatic theme switching -// lineNumbers: !!lineNumbers, -// readOnly: !!readonly, -// placeholder: placeholder || "", -// extraKeys: enableCompletion ? { -// "Ctrl-Space": "autocomplete" -// } : {}, -// hintOptions: enableCompletion ? { -// hint: dslHint, -// completeSingle: false -// } : undefined -// }; -// -// // Add linting options if enabled and addon is available -// if (enableLinting) { -// // Include linenumbers gutter if lineNumbers is enabled -// editorOptions.gutters = lineNumbers -// ? ["CodeMirror-linenumbers", "CodeMirror-lint-markers"] -// : ["CodeMirror-lint-markers"]; -// editorOptions.lint = { -// getAnnotations: dslLint, -// async: true -// }; -// } -// -// const editor = CodeMirror(editorContainer, editorOptions); -// -// /* -------------------------------------------------- -// * Auto-trigger completion on specific characters -// * -------------------------------------------------- */ -// -// if (enableCompletion) { -// editor.on("inputRead", function (cm, change) { -// if (change.origin !== "+input") return; -// -// const lastChar = change.text[change.text.length - 1]; -// const lastCharOfInput = lastChar.slice(-1); -// -// if (AUTO_TRIGGER_CHARS.includes(lastCharOfInput)) { -// cm.showHint({completeSingle: false}); -// } -// }); -// } -// -// /* -------------------------------------------------- -// * Debounced update + HTMX transport -// * -------------------------------------------------- */ -// -// let debounceTimer = null; -// const DEBOUNCE_DELAY = 300; -// -// editor.on("change", function (cm) { -// const value = cm.getValue(); -// textarea.value = value; -// -// if (!updateCommandId) return; -// -// clearTimeout(debounceTimer); -// debounceTimer = setTimeout(() => { -// wrapper.dispatchEvent( -// new CustomEvent("dsl-editor-update", { -// detail: { -// commandId: updateCommandId, -// value: value -// } -// }) -// ); -// }, DEBOUNCE_DELAY); -// }); -// -// /* -------------------------------------------------- -// * HTMX listener (LOCAL to wrapper) -// * -------------------------------------------------- */ -// -// if (updateCommandId && typeof htmx !== "undefined") { -// wrapper.addEventListener("dsl-editor-update", function (e) { -// htmx.ajax("POST", "/myfasthtml/commands", { -// target: wrapper, -// swap: "none", -// values: { -// c_id: e.detail.commandId, -// content: e.detail.value -// } -// }); -// }); -// } -// -// /* -------------------------------------------------- -// * Public API -// * -------------------------------------------------- */ -// -// wrapper._dslEditor = { -// editor: editor, -// getContent: () => editor.getValue(), -// setContent: (content) => editor.setValue(content) -// }; -// -// //console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`); -// } -// -// -// function updateDatagridSelection(datagridId) { -// const selectionManager = document.getElementById(`tsm_${datagridId}`); -// if (!selectionManager) return; -// -// // Clear previous selections -// document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column').forEach((element) => { -// element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column'); -// element.style.userSelect = 'none'; -// }); -// -// // Loop through the children of the selection manager -// Array.from(selectionManager.children).forEach((selection) => { -// const selectionType = selection.getAttribute('selection-type'); -// const elementId = selection.getAttribute('element-id'); -// -// if (selectionType === 'focus') { -// const cellElement = document.getElementById(`${elementId}`); -// if (cellElement) { -// cellElement.classList.add('dt2-selected-focus'); -// cellElement.style.userSelect = 'text'; -// } -// } else if (selectionType === 'cell') { -// const cellElement = document.getElementById(`${elementId}`); -// if (cellElement) { -// cellElement.classList.add('dt2-selected-cell'); -// cellElement.style.userSelect = 'text'; -// } -// } else if (selectionType === 'row') { -// const rowElement = document.getElementById(`${elementId}`); -// if (rowElement) { -// rowElement.classList.add('dt2-selected-row'); -// } -// } else if (selectionType === 'column') { -// // Select all elements in the specified column -// document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => { -// columnElement.classList.add('dt2-selected-column'); -// }); -// } -// }); -// } \ No newline at end of file diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 17c3d00..9cc68c3 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -31,11 +31,11 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.optimized_ft import OptimizedDiv -from myfasthtml.core.utils import make_safe_id +from myfasthtml.core.utils import make_safe_id, merge_classes from myfasthtml.icons.carbon import row, column, grid from myfasthtml.icons.fluent import checkbox_unchecked16_regular -from myfasthtml.icons.fluent_p1 import settings16_regular -from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular +from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular +from myfasthtml.icons.fluent_p3 import text_edit_style20_regular # OPTIMIZATION: Pre-compiled regex to detect HTML special characters _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']') @@ -592,12 +592,12 @@ class DataGrid(MultipleInstance): return NotStr(f'{row_index}') # Get format rules and apply formatting - css_string = None + style = None formatted_value = None rules = self._get_format_rules(col_pos, row_index, col_def) if rules: row_data = self._state.ns_row_data[row_index] if row_index < len(self._state.ns_row_data) else None - css_string, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) + style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) # Use formatted value or convert to string value_str = formatted_value if formatted_value is not None else str(value) @@ -606,11 +606,18 @@ class DataGrid(MultipleInstance): if _HTML_SPECIAL_CHARS_REGEX.search(value_str): value_str = html.escape(value_str) + if style: + cls = style.cls + css_string = style.css + else: + cls = None + css_string = "" + # Number or Text type if column_type == ColumnType.Number: - return mk_highlighted_text(value_str, "dt2-cell-content-number", css_string) + return mk_highlighted_text(value_str, merge_classes("dt2-cell-content-number", cls), css_string) else: - return mk_highlighted_text(value_str, "dt2-cell-content-text", css_string) + return mk_highlighted_text(value_str, merge_classes("dt2-cell-content-text", cls), css_string) def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): """ @@ -816,10 +823,10 @@ class DataGrid(MultipleInstance): Div(self._datagrid_filter, Div( self._selection_mode_selector, - mk.icon(settings16_regular, + mk.icon(column_edit20_regular, command=self.commands.toggle_columns_manager(), tooltip="Show column manager"), - mk.icon(settings16_regular, + mk.icon(text_edit_style20_regular, command=self.commands.toggle_formatting_editor(), tooltip="Show formatting editor"), cls="flex"), diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 415db85..fec03bb 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -47,15 +47,16 @@ class Command: # In this situation, # either there is no parameter (so one single instance of the command is enough) # or the parameter is a kwargs (so the parameters are provided when the command is called) - if (key is None - and owner is not None - and args is None # args is not provided - ): - key = f"{owner.get_full_id()}-{name}" - - key = key.replace("#{args}", _compute_from_args()) - key = key.replace("#{id}", owner.get_full_id()) - key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}") + if key is None: + if owner is not None and args is None: # args is not provided + key = f"{owner.get_full_id()}-{name}" + else: + key = f"{name}-{_compute_from_args()}" + else: + key = key.replace("#{args}", _compute_from_args()) + if owner is not None: + key = key.replace("#{id}", owner.get_full_id()) + key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}") return key @@ -78,24 +79,17 @@ class Command: self._bindings = [] self._ft = None self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} - self._key = key - - # special management when kwargs are provided - # In this situation, - # either there is no parameter (so one single instance of the command is enough) - # or the parameter is a kwargs (so the parameters are provided when the command is called) - if (self._key is None - and self.owner is not None - and args is None # args is not provided - ): - self._key = f"{owner.get_full_id()}-{name}" + self._key = self.process_key(key, self.name, self.owner, self.default_args, self.default_kwargs) # register the command if auto_register: - if self._key in CommandsManager.commands_by_key: - self.id = CommandsManager.commands_by_key[self._key].id + if self._key is not None: + if self._key in CommandsManager.commands_by_key: + self.id = CommandsManager.commands_by_key[self._key].id + else: + CommandsManager.register(self) else: - CommandsManager.register(self) + logger.warning(f"Command {self.name} has no key, it will not be registered.") def get_key(self): return self._key diff --git a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py index 9295bea..4457905 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py +++ b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py @@ -105,13 +105,13 @@ class FormattingCompletionEngine(BaseCompletionEngine): case Context.CELL_ROW: return self._get_row_index_suggestions() - + case Context.TABLE_NAME: return self._get_table_name_suggestion() - + case Context.TABLES_SCOPE: return [Suggestion(":", "Define global rules for all tables", "syntax")] - + # ================================================================= # Rule-level contexts # ================================================================= @@ -236,13 +236,13 @@ class FormattingCompletionEngine(BaseCompletionEngine): except Exception: pass return [] - + def _get_table_name_suggestion(self) -> list[Suggestion]: """Get table name suggestion (current table only).""" if self.table_name: return [Suggestion(f'"{self.table_name}"', f"Current table: {self.table_name}", "table")] return [] - + def _get_style_preset_suggestions(self) -> list[Suggestion]: """Get style preset suggestions (without quotes).""" suggestions = [] @@ -337,7 +337,7 @@ class FormattingCompletionEngine(BaseCompletionEngine): try: # Use table_name from scope, or empty string as fallback - table_name = scope.table_name or "" + table_name = scope.table_name or self.table_name or "" values = self.provider.list_column_values(table_name, scope.column_name) suggestions = [] for value in values: diff --git a/src/myfasthtml/core/formatting/engine.py b/src/myfasthtml/core/formatting/engine.py index 95e805c..02a471c 100644 --- a/src/myfasthtml/core/formatting/engine.py +++ b/src/myfasthtml/core/formatting/engine.py @@ -3,228 +3,226 @@ from typing import Any, Callable from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator from myfasthtml.core.formatting.dataclasses import FormatRule from myfasthtml.core.formatting.formatter_resolver import FormatterResolver -from myfasthtml.core.formatting.style_resolver import StyleResolver +from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer class FormattingEngine: + """ + Main facade for the formatting system. + + Combines: + - ConditionEvaluator: evaluates conditions + - StyleResolver: resolves styles to CSS + - FormatterResolver: formats values for display + - Conflict resolution: handles multiple matching rules + + Usage: + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)), + FormatRule(formatter=NumberFormatter(preset="EUR")), + ] + css, formatted = engine.apply_format(rules, cell_value=-5.0) + """ + + def __init__( + self, + style_presets: dict = None, + formatter_presets: dict = None, + lookup_resolver: Callable[[str, str, str], dict] = None + ): """ - Main facade for the formatting system. + Initialize the FormattingEngine. - Combines: - - ConditionEvaluator: evaluates conditions - - StyleResolver: resolves styles to CSS - - FormatterResolver: formats values for display - - Conflict resolution: handles multiple matching rules - - Usage: - engine = FormattingEngine() - rules = [ - FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)), - FormatRule(formatter=NumberFormatter(preset="EUR")), - ] - css, formatted = engine.apply_format(rules, cell_value=-5.0) + Args: + style_presets: Custom style presets. If None, uses defaults. + formatter_presets: Custom formatter presets. If None, uses defaults. + lookup_resolver: Function for resolving enum datagrid sources. """ + self._condition_evaluator = ConditionEvaluator() + self._style_resolver = StyleResolver(style_presets) + self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver) + + def apply_format( + self, + rules: list[FormatRule], + cell_value: Any, + row_data: dict = None + ) -> tuple[StyleContainer | None, str | None]: + """ + Apply format rules to a cell value. - def __init__( - self, - style_presets: dict = None, - formatter_presets: dict = None, - lookup_resolver: Callable[[str, str, str], dict] = None - ): - """ - Initialize the FormattingEngine. + Args: + rules: List of FormatRule to evaluate + cell_value: The cell value to format + row_data: Dict of {col_id: value} for column references - Args: - style_presets: Custom style presets. If None, uses defaults. - formatter_presets: Custom formatter presets. If None, uses defaults. - lookup_resolver: Function for resolving enum datagrid sources. - """ - self._condition_evaluator = ConditionEvaluator() - self._style_resolver = StyleResolver(style_presets) - self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver) + Returns: + Tuple of (css_string, formatted_value): + - css_string: CSS inline style string, or None if no style + - formatted_value: Formatted string, or None if no formatter + """ + if not rules: + return None, None + + # Find all matching rules + matching_rules = self._get_matching_rules(rules, cell_value, row_data) + + if not matching_rules: + return None, None + + # Resolve style and formatter independently + # This allows combining style from one rule and formatter from another + winning_style = self._resolve_style(matching_rules) + winning_formatter = self._resolve_formatter(matching_rules) + + # Apply style + style = None + if winning_style: + style = self._style_resolver.to_style_container(winning_style) + + # Apply formatter + formatted_value = None + if winning_formatter: + formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value) + + return style, formatted_value + + def _get_matching_rules( + self, + rules: list[FormatRule], + cell_value: Any, + row_data: dict = None + ) -> list[FormatRule]: + """ + Get all rules that match the current cell. - def apply_format( - self, - rules: list[FormatRule], - cell_value: Any, - row_data: dict = None - ) -> tuple[str | None, str | None]: - """ - Apply format rules to a cell value. + A rule matches if: + - It has no condition (unconditional) + - Its condition evaluates to True + """ + matching = [] + + for rule in rules: + if rule.condition is None: + # Unconditional rule always matches + matching.append(rule) + elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data): + # Conditional rule matches + matching.append(rule) + + return matching + + def _resolve_style(self, matching_rules: list[FormatRule]): + """ + Resolve style conflicts when multiple rules match. - Args: - rules: List of FormatRule to evaluate - cell_value: The cell value to format - row_data: Dict of {col_id: value} for column references + Resolution logic: + 1. Filter to rules that have a style + 2. Specificity = 1 if rule has condition, 0 otherwise + 3. Higher specificity wins + 4. At equal specificity, last rule wins - Returns: - Tuple of (css_string, formatted_value): - - css_string: CSS inline style string, or None if no style - - formatted_value: Formatted string, or None if no formatter - """ - if not rules: - return None, None + Args: + matching_rules: List of rules that matched - # Find all matching rules - matching_rules = self._get_matching_rules(rules, cell_value, row_data) + Returns: + The winning Style, or None if no rules have style + """ + # Filter to rules with style + style_rules = [rule for rule in matching_rules if rule.style is not None] + + if not style_rules: + return None + + if len(style_rules) == 1: + return style_rules[0].style + + # Calculate specificity for each rule + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in style_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1].style + + def _resolve_formatter(self, matching_rules: list[FormatRule]): + """ + Resolve formatter conflicts when multiple rules match. - if not matching_rules: - return None, None + Resolution logic: + 1. Filter to rules that have a formatter + 2. Specificity = 1 if rule has condition, 0 otherwise + 3. Higher specificity wins + 4. At equal specificity, last rule wins - # Resolve style and formatter independently - # This allows combining style from one rule and formatter from another - winning_style = self._resolve_style(matching_rules) - winning_formatter = self._resolve_formatter(matching_rules) + Args: + matching_rules: List of rules that matched - # Apply style - css_string = None - if winning_style: - css_string = self._style_resolver.to_css_string(winning_style) - if css_string == "": - css_string = None + Returns: + The winning Formatter, or None if no rules have formatter + """ + # Filter to rules with formatter + formatter_rules = [rule for rule in matching_rules if rule.formatter is not None] + + if not formatter_rules: + return None + + if len(formatter_rules) == 1: + return formatter_rules[0].formatter + + # Calculate specificity for each rule + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in formatter_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1].formatter + + def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None: + """ + Resolve conflicts when multiple rules match. - # Apply formatter - formatted_value = None - if winning_formatter: - formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value) + DEPRECATED: This method is kept for backward compatibility but is no longer used. + Use _resolve_style() and _resolve_formatter() instead. - return css_string, formatted_value + Resolution logic: + 1. Specificity = 1 if rule has condition, 0 otherwise + 2. Higher specificity wins + 3. At equal specificity, last rule wins entirely (no fusion) - def _get_matching_rules( - self, - rules: list[FormatRule], - cell_value: Any, - row_data: dict = None - ) -> list[FormatRule]: - """ - Get all rules that match the current cell. + Args: + matching_rules: List of rules that matched - A rule matches if: - - It has no condition (unconditional) - - Its condition evaluates to True - """ - matching = [] - - for rule in rules: - if rule.condition is None: - # Unconditional rule always matches - matching.append(rule) - elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data): - # Conditional rule matches - matching.append(rule) - - return matching - - def _resolve_style(self, matching_rules: list[FormatRule]): - """ - Resolve style conflicts when multiple rules match. - - Resolution logic: - 1. Filter to rules that have a style - 2. Specificity = 1 if rule has condition, 0 otherwise - 3. Higher specificity wins - 4. At equal specificity, last rule wins - - Args: - matching_rules: List of rules that matched - - Returns: - The winning Style, or None if no rules have style - """ - # Filter to rules with style - style_rules = [rule for rule in matching_rules if rule.style is not None] - - if not style_rules: - return None - - if len(style_rules) == 1: - return style_rules[0].style - - # Calculate specificity for each rule - def get_specificity(rule: FormatRule) -> int: - return 1 if rule.condition is not None else 0 - - # Find the maximum specificity - max_specificity = max(get_specificity(rule) for rule in style_rules) - - # Filter to rules with max specificity - top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity] - - # Last rule wins among equal specificity - return top_rules[-1].style - - def _resolve_formatter(self, matching_rules: list[FormatRule]): - """ - Resolve formatter conflicts when multiple rules match. - - Resolution logic: - 1. Filter to rules that have a formatter - 2. Specificity = 1 if rule has condition, 0 otherwise - 3. Higher specificity wins - 4. At equal specificity, last rule wins - - Args: - matching_rules: List of rules that matched - - Returns: - The winning Formatter, or None if no rules have formatter - """ - # Filter to rules with formatter - formatter_rules = [rule for rule in matching_rules if rule.formatter is not None] - - if not formatter_rules: - return None - - if len(formatter_rules) == 1: - return formatter_rules[0].formatter - - # Calculate specificity for each rule - def get_specificity(rule: FormatRule) -> int: - return 1 if rule.condition is not None else 0 - - # Find the maximum specificity - max_specificity = max(get_specificity(rule) for rule in formatter_rules) - - # Filter to rules with max specificity - top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity] - - # Last rule wins among equal specificity - return top_rules[-1].formatter - - def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None: - """ - Resolve conflicts when multiple rules match. - - DEPRECATED: This method is kept for backward compatibility but is no longer used. - Use _resolve_style() and _resolve_formatter() instead. - - Resolution logic: - 1. Specificity = 1 if rule has condition, 0 otherwise - 2. Higher specificity wins - 3. At equal specificity, last rule wins entirely (no fusion) - - Args: - matching_rules: List of rules that matched - - Returns: - The winning FormatRule, or None if no rules - """ - if not matching_rules: - return None - - if len(matching_rules) == 1: - return matching_rules[0] - - # Calculate specificity for each rule - # Specificity = 1 if has condition, 0 otherwise - def get_specificity(rule: FormatRule) -> int: - return 1 if rule.condition is not None else 0 - - # Find the maximum specificity - max_specificity = max(get_specificity(rule) for rule in matching_rules) - - # Filter to rules with max specificity - top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity] - - # Last rule wins among equal specificity - return top_rules[-1] + Returns: + The winning FormatRule, or None if no rules + """ + if not matching_rules: + return None + + if len(matching_rules) == 1: + return matching_rules[0] + + # Calculate specificity for each rule + # Specificity = 1 if has condition, 0 otherwise + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in matching_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1] diff --git a/src/myfasthtml/core/formatting/presets.py b/src/myfasthtml/core/formatting/presets.py index d3b96d4..7cad3cd 100644 --- a/src/myfasthtml/core/formatting/presets.py +++ b/src/myfasthtml/core/formatting/presets.py @@ -3,40 +3,31 @@ DEFAULT_STYLE_PRESETS = { "primary": { - "background-color": "var(--color-primary)", - "color": "var(--color-primary-content)", + "__class__": "mf-formatting-primary", }, "secondary": { - "background-color": "var(--color-secondary)", - "color": "var(--color-secondary-content)", + "__class__": "mf-formatting-secondary", }, "accent": { - "background-color": "var(--color-accent)", - "color": "var(--color-accent-content)", + "__class__": "mf-formatting-accent", }, "neutral": { - "background-color": "var(--color-neutral)", - "color": "var(--color-neutral-content)", + "__class__": "mf-formatting-neutral", }, "info": { - "background-color": "var(--color-info)", - "color": "var(--color-info-content)", + "__class__": "mf-formatting-info", }, "success": { - "background-color": "var(--color-success)", - "color": "var(--color-success-content)", + "__class__": "mf-formatting-success", }, "warning": { - "background-color": "var(--color-warning)", - "color": "var(--color-warning-content)", + "__class__": "mf-formatting-warning", }, "error": { - "background-color": "var(--color-error)", - "color": "var(--color-error-content)", + "__class__": "mf-formatting-error", }, } - # === Formatter Presets === DEFAULT_FORMATTER_PRESETS = { diff --git a/src/myfasthtml/core/formatting/style_resolver.py b/src/myfasthtml/core/formatting/style_resolver.py index 10d1979..6e36a08 100644 --- a/src/myfasthtml/core/formatting/style_resolver.py +++ b/src/myfasthtml/core/formatting/style_resolver.py @@ -1,7 +1,8 @@ +from dataclasses import dataclass + from myfasthtml.core.formatting.dataclasses import Style from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS - # Mapping from Python attribute names to CSS property names PROPERTY_NAME_MAP = { "background_color": "background-color", @@ -13,63 +14,90 @@ PROPERTY_NAME_MAP = { } +@dataclass +class StyleContainer: + cls: str | None = None + css: str = None + + class StyleResolver: - """Resolves styles by applying presets and explicit properties.""" + """Resolves styles by applying presets and explicit properties.""" + + def __init__(self, style_presets: dict = None): + """ + Initialize the StyleResolver. - def __init__(self, style_presets: dict = None): - """ - Initialize the StyleResolver. + Args: + style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS. + """ + self.style_presets = style_presets or DEFAULT_STYLE_PRESETS + + def resolve(self, style: Style) -> dict: + """ + Resolve a Style to CSS properties dict. - Args: - style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS. - """ - self.style_presets = style_presets or DEFAULT_STYLE_PRESETS + Logic: + 1. If preset is defined, load preset properties + 2. Override with explicit properties (non-None values) + 3. Convert Python names to CSS names - def resolve(self, style: Style) -> dict: - """ - Resolve a Style to CSS properties dict. + Args: + style: The Style object to resolve - Logic: - 1. If preset is defined, load preset properties - 2. Override with explicit properties (non-None values) - 3. Convert Python names to CSS names + Returns: + Dict of CSS properties, e.g. {"background-color": "red", "color": "white"} + """ + if style is None: + return {} + + result = {} + + # Apply preset first + if style.preset and style.preset in self.style_presets: + preset_props = self.style_presets[style.preset] + for css_name, value in preset_props.items(): + result[css_name] = value + + # Override with explicit properties + for py_name, css_name in PROPERTY_NAME_MAP.items(): + value = getattr(style, py_name, None) + if value is not None: + result[css_name] = value + + return result + + def to_css_string(self, style: Style) -> str: + """ + Resolve a Style to a CSS inline string. - Args: - style: The Style object to resolve + Args: + style: The Style object to resolve - Returns: - Dict of CSS properties, e.g. {"background-color": "red", "color": "white"} - """ - if style is None: - return {} + Returns: + CSS string, e.g. "background-color: red; color: white;" + """ + props = self.resolve(style) + if not props: + return "" + + props.pop("__class__", "") + return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";" + + def to_style_container(self, style: Style) -> StyleContainer: + """ + Resolve a Style to a class that contains the class name and the CSS inline string. - result = {} + Args: + style: The Style object to resolve - # Apply preset first - if style.preset and style.preset in self.style_presets: - preset_props = self.style_presets[style.preset] - for css_name, value in preset_props.items(): - result[css_name] = value - - # Override with explicit properties - for py_name, css_name in PROPERTY_NAME_MAP.items(): - value = getattr(style, py_name, None) - if value is not None: - result[css_name] = value - - return result - - def to_css_string(self, style: Style) -> str: - """ - Resolve a Style to a CSS inline string. - - Args: - style: The Style object to resolve - - Returns: - CSS string, e.g. "background-color: red; color: white;" - """ - props = self.resolve(style) - if not props: - return "" - return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";" + Returns: + CSS string, e.g. "background-color: red; color: white;" and the class name + """ + props = self.resolve(style) + if not props: + return StyleContainer(None, "") + + cls = props.pop("__class__", None) + css = "; ".join(f"{key}: {value}" for key, value in props.items()) + ";" + + return StyleContainer(cls, css) diff --git a/tests/core/formatting/test_engine.py b/tests/core/formatting/test_engine.py index 81128cd..96771c8 100644 --- a/tests/core/formatting/test_engine.py +++ b/tests/core/formatting/test_engine.py @@ -8,6 +8,7 @@ from myfasthtml.core.formatting.dataclasses import ( FormatRule, ) from myfasthtml.core.formatting.engine import FormattingEngine +from myfasthtml.core.formatting.style_resolver import StyleContainer class TestApplyFormat: @@ -18,9 +19,9 @@ class TestApplyFormat: css, formatted = engine.apply_format(rules, cell_value=42) - assert css is not None - assert "background-color: red" in css - assert "color: white" in css + assert isinstance(css, StyleContainer) + assert "background-color: red" in css.css + assert "color: white" in css.css assert formatted is None def test_apply_format_with_formatter_only(self): @@ -45,8 +46,8 @@ class TestApplyFormat: css, formatted = engine.apply_format(rules, cell_value=42.567) - assert css is not None - assert "color: green" in css + assert isinstance(css, StyleContainer) + assert "color: green" in css.css assert formatted == "42.57" def test_apply_format_condition_met(self): @@ -61,8 +62,8 @@ class TestApplyFormat: css, formatted = engine.apply_format(rules, cell_value=-5) - assert css is not None - assert "color: red" in css + assert isinstance(css, StyleContainer) + assert "color: red" in css.css def test_apply_format_condition_not_met(self): """Conditional rule does not apply when condition is not met.""" @@ -97,8 +98,8 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value="anything") - assert css is not None - assert "color: gray" in css + assert isinstance(css, StyleContainer) + assert "color: gray" in css.css def test_multiple_unconditional_rules_last_wins(self): """Among unconditional rules, last one wins.""" @@ -111,9 +112,9 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=42) - assert "color: green" in css - assert "color: red" not in css - assert "color: blue" not in css + assert "color: green" in css.css + assert "color: red" not in css.css + assert "color: blue" not in css.css def test_conditional_beats_unconditional(self): """Conditional rule (higher specificity) beats unconditional.""" @@ -128,8 +129,8 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=-5) - assert "color: red" in css - assert "color: gray" not in css + assert "color: red" in css.css + assert "color: gray" not in css.css def test_conditional_not_met_falls_back_to_unconditional(self): """When conditional doesn't match, unconditional applies.""" @@ -144,7 +145,7 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=5) # positive, condition not met - assert "color: gray" in css + assert "color: gray" in css.css def test_multiple_conditional_last_wins(self): """Among conditional rules with same specificity, last wins.""" @@ -162,8 +163,8 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=5) - assert "color: blue" in css - assert "color: red" not in css + assert "color: blue" in css.css + assert "color: red" not in css.css def test_spec_example_value_minus_5(self): """ @@ -190,7 +191,7 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=-5) - assert "color: black" in css + assert "color: black" in css.css def test_spec_example_value_minus_3(self): """ @@ -213,7 +214,7 @@ class TestConflictResolution: css, _ = engine.apply_format(rules, cell_value=-3) - assert "color: red" in css + assert "color: red" in css.css def test_style_and_formatter_fusion(self): """ @@ -241,8 +242,8 @@ class TestConflictResolution: # Case 1: Condition met (value > budget) css, formatted = engine.apply_format(rules, cell_value=150, row_data=row_data) - assert css is not None - assert "var(--color-secondary)" in css # Style from Rule 2 + assert isinstance(css, StyleContainer) + assert "var(--color-secondary)" in css.css # Style from Rule 2 assert formatted == "150.00 €" # Formatter from Rule 1 # Case 2: Condition not met (value <= budget) @@ -281,7 +282,7 @@ class TestConflictResolution: css, formatted = engine.apply_format(rules, cell_value=-5.67) - assert "var(--color-error)" in css # Rule 3 wins for style + assert "var(--color-error)" in css.css # Rule 3 wins for style assert formatted == "-6 €" # Rule 4 wins for formatter (precision=0) @@ -299,7 +300,7 @@ class TestWithRowData: css, _ = engine.apply_format(rules, cell_value=150, row_data=row_data) - assert "color: red" in css + assert "color: red" in css.css def test_condition_with_col_parameter(self): """Row-level condition using col parameter.""" @@ -314,8 +315,8 @@ class TestWithRowData: css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data) - assert css is not None - assert "background-color" in css + assert isinstance(css, StyleContainer) + assert "background-color" in css.css class TestPresets: @@ -326,7 +327,7 @@ class TestPresets: css, _ = engine.apply_format(rules, cell_value=42) - assert "var(--color-success)" in css + assert "var(--color-success)" in css.css def test_formatter_preset(self): """Formatter preset is resolved correctly.""" @@ -347,5 +348,5 @@ class TestPresets: css, _ = engine.apply_format(rules, cell_value=42) - assert "background-color: purple" in css - assert "color: yellow" in css + assert "background-color: purple" in css.css + assert "color: yellow" in css.css diff --git a/tests/core/formatting/test_style_resolver.py b/tests/core/formatting/test_style_resolver.py index 8b57f2d..23fc11a 100644 --- a/tests/core/formatting/test_style_resolver.py +++ b/tests/core/formatting/test_style_resolver.py @@ -1,7 +1,7 @@ import pytest from myfasthtml.core.formatting.dataclasses import Style -from myfasthtml.core.formatting.style_resolver import StyleResolver +from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer class TestResolve: @@ -141,3 +141,108 @@ class TestToCssString: result = resolver.to_css_string(style) assert result == "color: blue;" + + +class TestToStyleContainer: + @pytest.mark.parametrize("style_input,expected_cls,expected_css_contains", [ + # CSS properties only + ( + Style(background_color="red", color="white"), + None, + ["background-color: red", "color: white"] + ), + # Class only via preset + ( + Style(preset="success"), + None, # Default presets don't have __class__ + ["background-color: var(--color-success)", "color: var(--color-success-content)"] + ), + # Empty style + ( + Style(), + None, + [] + ), + # None style + ( + None, + None, + [] + ), + ]) + def test_i_can_resolve_to_style_container(self, style_input, expected_cls, expected_css_contains): + """Test to_style_container() with various style inputs.""" + resolver = StyleResolver() + result = resolver.to_style_container(style_input) + + assert isinstance(result, StyleContainer) + assert result.cls == expected_cls + + if expected_css_contains: + for css_part in expected_css_contains: + assert css_part in result.css + else: + assert result.css == "" + + def test_i_can_resolve_preset_with_class_to_container(self): + """Preset with __class__ key is extracted to cls attribute.""" + custom_presets = { + "badge": { + "__class__": "badge badge-primary", + "background-color": "blue", + "color": "white" + } + } + resolver = StyleResolver(style_presets=custom_presets) + style = Style(preset="badge") + result = resolver.to_style_container(style) + + assert result.cls == "badge badge-primary" + assert "background-color: blue" in result.css + assert "color: white" in result.css + assert "__class__" not in result.css + + def test_i_can_override_preset_class_with_explicit_properties(self): + """Explicit properties override preset but __class__ is preserved.""" + custom_presets = { + "badge": { + "__class__": "badge badge-primary", + "background-color": "blue", + "color": "white" + } + } + resolver = StyleResolver(style_presets=custom_presets) + style = Style(preset="badge", color="black") + result = resolver.to_style_container(style) + + assert result.cls == "badge badge-primary" + assert "background-color: blue" in result.css + assert "color: black" in result.css # Overridden + assert "color: white" not in result.css + + def test_i_can_resolve_multiple_css_properties_to_container(self): + """Multiple CSS properties are correctly formatted in container.""" + resolver = StyleResolver() + style = Style( + background_color="#ff0000", + color="#ffffff", + font_weight="bold", + font_style="italic" + ) + result = resolver.to_style_container(style) + + assert result.cls is None + assert "background-color: #ff0000" in result.css + assert "color: #ffffff" in result.css + assert "font-weight: bold" in result.css + assert "font-style: italic" in result.css + assert result.css.endswith(";") + + def test_i_can_resolve_empty_container_when_no_properties(self): + """Empty style returns container with None cls and empty css.""" + resolver = StyleResolver() + result = resolver.to_style_container(Style()) + + assert isinstance(result, StyleContainer) + assert result.cls is None + assert result.css == ""