Fixed command id collision. Added class support in style preset

This commit is contained in:
2026-02-08 19:50:10 +01:00
parent 3ec994d6df
commit d44e0a0c01
14 changed files with 623 additions and 3677 deletions

View File

@@ -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 ## Layer 1: Formatting Engine
@@ -441,7 +477,7 @@ def apply_format(
rules: list[FormatRule], rules: list[FormatRule],
cell_value: Any, cell_value: Any,
row_data: dict = None row_data: dict = None
) -> tuple[str | None, str | None]: ) -> tuple[StyleContainer | None, str | None]:
""" """
Apply format rules to a cell value. Apply format rules to a cell value.
@@ -451,8 +487,8 @@ def apply_format(
row_data: Dict of {col_id: value} for column references row_data: Dict of {col_id: value} for column references
Returns: Returns:
Tuple of (css_string, formatted_value): Tuple of (style_container, formatted_value):
- css_string: CSS inline style string, or None - style_container: StyleContainer with cls and css attributes, or None
- formatted_value: Formatted string, or None - formatted_value: Formatted string, or None
""" """
``` ```
@@ -478,9 +514,17 @@ rules = [
] ]
# Apply to cell # Apply to cell
css, formatted = engine.apply_format(rules, -1234.56) style, formatted = engine.apply_format(rules, -1234.56)
# css = "background-color: var(--color-error); color: var(--color-error-content);" # style = StyleContainer(
# cls=None,
# css="background-color: var(--color-error); color: var(--color-error-content);"
# )
# formatted = "-1 234,56 €" # formatted = "-1 234,56 €"
# Access CSS string
if style:
css_string = style.css
css_classes = style.cls
``` ```
### Sub-components ### Sub-components
@@ -511,11 +555,47 @@ Converts `Style` objects to CSS strings:
resolver = StyleResolver() resolver = StyleResolver()
style = Style(preset="error", font_weight="bold") style = Style(preset="error", font_weight="bold")
# Get CSS properties dict
css_dict = resolver.resolve(style) css_dict = resolver.resolve(style)
# {"background-color": "var(--color-error)", "color": "var(--color-error-content)", "font-weight": "bold"} # {"background-color": "var(--color-error)", "color": "var(--color-error-content)", "font-weight": "bold"}
# Get CSS inline string
css_string = resolver.to_css_string(style) css_string = resolver.to_css_string(style)
# "background-color: var(--color-error); color: var(--color-error-content); font-weight: bold;" # "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 #### FormatterResolver
@@ -1217,9 +1297,12 @@ Used by `DataGridFormattingEditor` to configure the CodeMirror editor.
7. DataGrid renders cells 7. DataGrid renders cells
- mk_body_cell_content() applies formatting - mk_body_cell_content() applies formatting
- FormattingEngine.apply_format(rules, cell_value, row_data) - 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) manager = DataGridsManager.get_instance(session)
# Style preset with CSS properties only
manager.add_style_preset("corporate", { manager.add_style_preset("corporate", {
"background-color": "#003366", "background-color": "#003366",
"color": "#FFFFFF", "color": "#FFFFFF",
"font-weight": "bold" "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 #### Add Custom Formatter Presets

View File

@@ -941,4 +941,4 @@ The Panel component uses JavaScript for manual resizing:
- Sends width updates to server via HTMX - Sends width updates to server via HTMX
- Constrains width between 150px and 500px - Constrains width between 150px and 500px
**File:** `src/myfasthtml/assets/myfasthtml.js` **File:** `src/myfasthtml/assets/core/myfasthtml.js`

View File

@@ -207,3 +207,47 @@
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; 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);
}

View File

@@ -375,6 +375,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
} }
// Make AJAX call with htmx // Make AJAX call with htmx
console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions);
htmx.ajax(method, url, htmxOptions); htmx.ajax(method, url, htmxOptions);
} }

View File

@@ -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;*/
/*}*/

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,11 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv 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.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular 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, column_edit20_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular from myfasthtml.icons.fluent_p3 import text_edit_style20_regular
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters # OPTIMIZATION: Pre-compiled regex to detect HTML special characters
_HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']') _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
@@ -592,12 +592,12 @@ class DataGrid(MultipleInstance):
return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>') return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>')
# Get format rules and apply formatting # Get format rules and apply formatting
css_string = None style = None
formatted_value = None formatted_value = None
rules = self._get_format_rules(col_pos, row_index, col_def) rules = self._get_format_rules(col_pos, row_index, col_def)
if rules: if rules:
row_data = self._state.ns_row_data[row_index] if row_index < len(self._state.ns_row_data) else None 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 # Use formatted value or convert to string
value_str = formatted_value if formatted_value is not None else str(value) 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): if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
value_str = html.escape(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 # Number or Text type
if column_type == ColumnType.Number: 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: 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): 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._datagrid_filter,
Div( Div(
self._selection_mode_selector, self._selection_mode_selector,
mk.icon(settings16_regular, mk.icon(column_edit20_regular,
command=self.commands.toggle_columns_manager(), command=self.commands.toggle_columns_manager(),
tooltip="Show column manager"), tooltip="Show column manager"),
mk.icon(settings16_regular, mk.icon(text_edit_style20_regular,
command=self.commands.toggle_formatting_editor(), command=self.commands.toggle_formatting_editor(),
tooltip="Show formatting editor"), tooltip="Show formatting editor"),
cls="flex"), cls="flex"),

View File

@@ -47,15 +47,16 @@ class Command:
# In this situation, # In this situation,
# either there is no parameter (so one single instance of the command is enough) # 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) # or the parameter is a kwargs (so the parameters are provided when the command is called)
if (key is None if key is None:
and owner is not None if owner is not None and args is None: # args is not provided
and args is None # args is not provided key = f"{owner.get_full_id()}-{name}"
): else:
key = f"{owner.get_full_id()}-{name}" key = f"{name}-{_compute_from_args()}"
else:
key = key.replace("#{args}", _compute_from_args()) key = key.replace("#{args}", _compute_from_args())
key = key.replace("#{id}", owner.get_full_id()) if owner is not None:
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_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()}")
return key return key
@@ -78,24 +79,17 @@ class Command:
self._bindings = [] self._bindings = []
self._ft = None self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self._key = key self._key = self.process_key(key, self.name, self.owner, self.default_args, self.default_kwargs)
# 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}"
# register the command # register the command
if auto_register: if auto_register:
if self._key in CommandsManager.commands_by_key: if self._key is not None:
self.id = CommandsManager.commands_by_key[self._key].id if self._key in CommandsManager.commands_by_key:
self.id = CommandsManager.commands_by_key[self._key].id
else:
CommandsManager.register(self)
else: else:
CommandsManager.register(self) logger.warning(f"Command {self.name} has no key, it will not be registered.")
def get_key(self): def get_key(self):
return self._key return self._key

View File

@@ -337,7 +337,7 @@ class FormattingCompletionEngine(BaseCompletionEngine):
try: try:
# Use table_name from scope, or empty string as fallback # 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) values = self.provider.list_column_values(table_name, scope.column_name)
suggestions = [] suggestions = []
for value in values: for value in values:

View File

@@ -3,228 +3,226 @@ from typing import Any, Callable
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
from myfasthtml.core.formatting.dataclasses import FormatRule from myfasthtml.core.formatting.dataclasses import FormatRule
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver 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: 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: Args:
- ConditionEvaluator: evaluates conditions style_presets: Custom style presets. If None, uses defaults.
- StyleResolver: resolves styles to CSS formatter_presets: Custom formatter presets. If None, uses defaults.
- FormatterResolver: formats values for display lookup_resolver: Function for resolving enum datagrid sources.
- 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)
""" """
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
def __init__( def apply_format(
self, self,
style_presets: dict = None, rules: list[FormatRule],
formatter_presets: dict = None, cell_value: Any,
lookup_resolver: Callable[[str, str, str], dict] = None row_data: dict = None
): ) -> tuple[StyleContainer | None, str | None]:
""" """
Initialize the FormattingEngine. Apply format rules to a cell value.
Args: Args:
style_presets: Custom style presets. If None, uses defaults. rules: List of FormatRule to evaluate
formatter_presets: Custom formatter presets. If None, uses defaults. cell_value: The cell value to format
lookup_resolver: Function for resolving enum datagrid sources. row_data: Dict of {col_id: value} for column references
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
def apply_format( Returns:
self, Tuple of (css_string, formatted_value):
rules: list[FormatRule], - css_string: CSS inline style string, or None if no style
cell_value: Any, - formatted_value: Formatted string, or None if no formatter
row_data: dict = None """
) -> tuple[str | None, str | None]: if not rules:
""" return None, None
Apply format rules to a cell value.
Args: # Find all matching rules
rules: List of FormatRule to evaluate matching_rules = self._get_matching_rules(rules, cell_value, row_data)
cell_value: The cell value to format
row_data: Dict of {col_id: value} for column references
Returns: if not matching_rules:
Tuple of (css_string, formatted_value): return None, None
- 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 # Resolve style and formatter independently
matching_rules = self._get_matching_rules(rules, cell_value, row_data) # 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)
if not matching_rules: # Apply style
return None, None style = None
if winning_style:
style = self._style_resolver.to_style_container(winning_style)
# Resolve style and formatter independently # Apply formatter
# This allows combining style from one rule and formatter from another formatted_value = None
winning_style = self._resolve_style(matching_rules) if winning_formatter:
winning_formatter = self._resolve_formatter(matching_rules) formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value)
# Apply style return style, formatted_value
css_string = None
if winning_style:
css_string = self._style_resolver.to_css_string(winning_style)
if css_string == "":
css_string = None
# Apply formatter def _get_matching_rules(
formatted_value = None self,
if winning_formatter: rules: list[FormatRule],
formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value) cell_value: Any,
row_data: dict = None
) -> list[FormatRule]:
"""
Get all rules that match the current cell.
return css_string, formatted_value A rule matches if:
- It has no condition (unconditional)
- Its condition evaluates to True
"""
matching = []
def _get_matching_rules( for rule in rules:
self, if rule.condition is None:
rules: list[FormatRule], # Unconditional rule always matches
cell_value: Any, matching.append(rule)
row_data: dict = None elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
) -> list[FormatRule]: # Conditional rule matches
""" matching.append(rule)
Get all rules that match the current cell.
A rule matches if: return matching
- It has no condition (unconditional)
- Its condition evaluates to True
"""
matching = []
for rule in rules: def _resolve_style(self, matching_rules: list[FormatRule]):
if rule.condition is None: """
# Unconditional rule always matches Resolve style conflicts when multiple rules match.
matching.append(rule)
elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
# Conditional rule matches
matching.append(rule)
return matching 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
def _resolve_style(self, matching_rules: list[FormatRule]): Args:
""" matching_rules: List of rules that matched
Resolve style conflicts when multiple rules match.
Resolution logic: Returns:
1. Filter to rules that have a style The winning Style, or None if no rules have style
2. Specificity = 1 if rule has condition, 0 otherwise """
3. Higher specificity wins # Filter to rules with style
4. At equal specificity, last rule wins style_rules = [rule for rule in matching_rules if rule.style is not None]
Args: if not style_rules:
matching_rules: List of rules that matched return None
Returns: if len(style_rules) == 1:
The winning Style, or None if no rules have style return style_rules[0].style
"""
# Filter to rules with style
style_rules = [rule for rule in matching_rules if rule.style is not None]
if not style_rules: # Calculate specificity for each rule
return None def get_specificity(rule: FormatRule) -> int:
return 1 if rule.condition is not None else 0
if len(style_rules) == 1: # Find the maximum specificity
return style_rules[0].style max_specificity = max(get_specificity(rule) for rule in style_rules)
# Calculate specificity for each rule # Filter to rules with max specificity
def get_specificity(rule: FormatRule) -> int: top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity]
return 1 if rule.condition is not None else 0
# Find the maximum specificity # Last rule wins among equal specificity
max_specificity = max(get_specificity(rule) for rule in style_rules) return top_rules[-1].style
# Filter to rules with max specificity def _resolve_formatter(self, matching_rules: list[FormatRule]):
top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity] """
Resolve formatter conflicts when multiple rules match.
# Last rule wins among equal specificity Resolution logic:
return top_rules[-1].style 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
def _resolve_formatter(self, matching_rules: list[FormatRule]): Args:
""" matching_rules: List of rules that matched
Resolve formatter conflicts when multiple rules match.
Resolution logic: Returns:
1. Filter to rules that have a formatter The winning Formatter, or None if no rules have formatter
2. Specificity = 1 if rule has condition, 0 otherwise """
3. Higher specificity wins # Filter to rules with formatter
4. At equal specificity, last rule wins formatter_rules = [rule for rule in matching_rules if rule.formatter is not None]
Args: if not formatter_rules:
matching_rules: List of rules that matched return None
Returns: if len(formatter_rules) == 1:
The winning Formatter, or None if no rules have formatter return formatter_rules[0].formatter
"""
# Filter to rules with formatter
formatter_rules = [rule for rule in matching_rules if rule.formatter is not None]
if not formatter_rules: # Calculate specificity for each rule
return None def get_specificity(rule: FormatRule) -> int:
return 1 if rule.condition is not None else 0
if len(formatter_rules) == 1: # Find the maximum specificity
return formatter_rules[0].formatter max_specificity = max(get_specificity(rule) for rule in formatter_rules)
# Calculate specificity for each rule # Filter to rules with max specificity
def get_specificity(rule: FormatRule) -> int: top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity]
return 1 if rule.condition is not None else 0
# Find the maximum specificity # Last rule wins among equal specificity
max_specificity = max(get_specificity(rule) for rule in formatter_rules) return top_rules[-1].formatter
# Filter to rules with max specificity def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity] """
Resolve conflicts when multiple rules match.
# Last rule wins among equal specificity DEPRECATED: This method is kept for backward compatibility but is no longer used.
return top_rules[-1].formatter Use _resolve_style() and _resolve_formatter() instead.
def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None: Resolution logic:
""" 1. Specificity = 1 if rule has condition, 0 otherwise
Resolve conflicts when multiple rules match. 2. Higher specificity wins
3. At equal specificity, last rule wins entirely (no fusion)
DEPRECATED: This method is kept for backward compatibility but is no longer used. Args:
Use _resolve_style() and _resolve_formatter() instead. matching_rules: List of rules that matched
Resolution logic: Returns:
1. Specificity = 1 if rule has condition, 0 otherwise The winning FormatRule, or None if no rules
2. Higher specificity wins """
3. At equal specificity, last rule wins entirely (no fusion) if not matching_rules:
return None
Args: if len(matching_rules) == 1:
matching_rules: List of rules that matched return matching_rules[0]
Returns: # Calculate specificity for each rule
The winning FormatRule, or None if no rules # Specificity = 1 if has condition, 0 otherwise
""" def get_specificity(rule: FormatRule) -> int:
if not matching_rules: return 1 if rule.condition is not None else 0
return None
if len(matching_rules) == 1: # Find the maximum specificity
return matching_rules[0] max_specificity = max(get_specificity(rule) for rule in matching_rules)
# Calculate specificity for each rule # Filter to rules with max specificity
# Specificity = 1 if has condition, 0 otherwise top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity]
def get_specificity(rule: FormatRule) -> int:
return 1 if rule.condition is not None else 0
# Find the maximum specificity # Last rule wins among equal specificity
max_specificity = max(get_specificity(rule) for rule in matching_rules) return top_rules[-1]
# 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]

View File

@@ -3,40 +3,31 @@
DEFAULT_STYLE_PRESETS = { DEFAULT_STYLE_PRESETS = {
"primary": { "primary": {
"background-color": "var(--color-primary)", "__class__": "mf-formatting-primary",
"color": "var(--color-primary-content)",
}, },
"secondary": { "secondary": {
"background-color": "var(--color-secondary)", "__class__": "mf-formatting-secondary",
"color": "var(--color-secondary-content)",
}, },
"accent": { "accent": {
"background-color": "var(--color-accent)", "__class__": "mf-formatting-accent",
"color": "var(--color-accent-content)",
}, },
"neutral": { "neutral": {
"background-color": "var(--color-neutral)", "__class__": "mf-formatting-neutral",
"color": "var(--color-neutral-content)",
}, },
"info": { "info": {
"background-color": "var(--color-info)", "__class__": "mf-formatting-info",
"color": "var(--color-info-content)",
}, },
"success": { "success": {
"background-color": "var(--color-success)", "__class__": "mf-formatting-success",
"color": "var(--color-success-content)",
}, },
"warning": { "warning": {
"background-color": "var(--color-warning)", "__class__": "mf-formatting-warning",
"color": "var(--color-warning-content)",
}, },
"error": { "error": {
"background-color": "var(--color-error)", "__class__": "mf-formatting-error",
"color": "var(--color-error-content)",
}, },
} }
# === Formatter Presets === # === Formatter Presets ===
DEFAULT_FORMATTER_PRESETS = { DEFAULT_FORMATTER_PRESETS = {

View File

@@ -1,7 +1,8 @@
from dataclasses import dataclass
from myfasthtml.core.formatting.dataclasses import Style from myfasthtml.core.formatting.dataclasses import Style
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS
# Mapping from Python attribute names to CSS property names # Mapping from Python attribute names to CSS property names
PROPERTY_NAME_MAP = { PROPERTY_NAME_MAP = {
"background_color": "background-color", "background_color": "background-color",
@@ -13,63 +14,90 @@ PROPERTY_NAME_MAP = {
} }
@dataclass
class StyleContainer:
cls: str | None = None
css: str = None
class StyleResolver: 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): def __init__(self, style_presets: dict = None):
""" """
Initialize the StyleResolver. Initialize the StyleResolver.
Args: Args:
style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS. style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS.
""" """
self.style_presets = style_presets or DEFAULT_STYLE_PRESETS self.style_presets = style_presets or DEFAULT_STYLE_PRESETS
def resolve(self, style: Style) -> dict: def resolve(self, style: Style) -> dict:
""" """
Resolve a Style to CSS properties dict. Resolve a Style to CSS properties dict.
Logic: Logic:
1. If preset is defined, load preset properties 1. If preset is defined, load preset properties
2. Override with explicit properties (non-None values) 2. Override with explicit properties (non-None values)
3. Convert Python names to CSS names 3. Convert Python names to CSS names
Args: Args:
style: The Style object to resolve style: The Style object to resolve
Returns: Returns:
Dict of CSS properties, e.g. {"background-color": "red", "color": "white"} Dict of CSS properties, e.g. {"background-color": "red", "color": "white"}
""" """
if style is None: if style is None:
return {} return {}
result = {} result = {}
# Apply preset first # Apply preset first
if style.preset and style.preset in self.style_presets: if style.preset and style.preset in self.style_presets:
preset_props = self.style_presets[style.preset] preset_props = self.style_presets[style.preset]
for css_name, value in preset_props.items(): for css_name, value in preset_props.items():
result[css_name] = value result[css_name] = value
# Override with explicit properties # Override with explicit properties
for py_name, css_name in PROPERTY_NAME_MAP.items(): for py_name, css_name in PROPERTY_NAME_MAP.items():
value = getattr(style, py_name, None) value = getattr(style, py_name, None)
if value is not None: if value is not None:
result[css_name] = value result[css_name] = value
return result return result
def to_css_string(self, style: Style) -> str: def to_css_string(self, style: Style) -> str:
""" """
Resolve a Style to a CSS inline string. Resolve a Style to a CSS inline string.
Args: Args:
style: The Style object to resolve style: The Style object to resolve
Returns: Returns:
CSS string, e.g. "background-color: red; color: white;" CSS string, e.g. "background-color: red; color: white;"
""" """
props = self.resolve(style) props = self.resolve(style)
if not props: if not props:
return "" return ""
return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
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.
Args:
style: The Style object to resolve
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)

View File

@@ -8,6 +8,7 @@ from myfasthtml.core.formatting.dataclasses import (
FormatRule, FormatRule,
) )
from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.formatting.style_resolver import StyleContainer
class TestApplyFormat: class TestApplyFormat:
@@ -18,9 +19,9 @@ class TestApplyFormat:
css, formatted = engine.apply_format(rules, cell_value=42) css, formatted = engine.apply_format(rules, cell_value=42)
assert css is not None assert isinstance(css, StyleContainer)
assert "background-color: red" in css assert "background-color: red" in css.css
assert "color: white" in css assert "color: white" in css.css
assert formatted is None assert formatted is None
def test_apply_format_with_formatter_only(self): def test_apply_format_with_formatter_only(self):
@@ -45,8 +46,8 @@ class TestApplyFormat:
css, formatted = engine.apply_format(rules, cell_value=42.567) css, formatted = engine.apply_format(rules, cell_value=42.567)
assert css is not None assert isinstance(css, StyleContainer)
assert "color: green" in css assert "color: green" in css.css
assert formatted == "42.57" assert formatted == "42.57"
def test_apply_format_condition_met(self): def test_apply_format_condition_met(self):
@@ -61,8 +62,8 @@ class TestApplyFormat:
css, formatted = engine.apply_format(rules, cell_value=-5) css, formatted = engine.apply_format(rules, cell_value=-5)
assert css is not None assert isinstance(css, StyleContainer)
assert "color: red" in css assert "color: red" in css.css
def test_apply_format_condition_not_met(self): def test_apply_format_condition_not_met(self):
"""Conditional rule does not apply when condition is not met.""" """Conditional rule does not apply when condition is not met."""
@@ -97,8 +98,8 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value="anything") css, _ = engine.apply_format(rules, cell_value="anything")
assert css is not None assert isinstance(css, StyleContainer)
assert "color: gray" in css assert "color: gray" in css.css
def test_multiple_unconditional_rules_last_wins(self): def test_multiple_unconditional_rules_last_wins(self):
"""Among unconditional rules, last one wins.""" """Among unconditional rules, last one wins."""
@@ -111,9 +112,9 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value=42) css, _ = engine.apply_format(rules, cell_value=42)
assert "color: green" in css assert "color: green" in css.css
assert "color: red" not in css assert "color: red" not in css.css
assert "color: blue" not in css assert "color: blue" not in css.css
def test_conditional_beats_unconditional(self): def test_conditional_beats_unconditional(self):
"""Conditional rule (higher specificity) beats unconditional.""" """Conditional rule (higher specificity) beats unconditional."""
@@ -128,8 +129,8 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value=-5) css, _ = engine.apply_format(rules, cell_value=-5)
assert "color: red" in css assert "color: red" in css.css
assert "color: gray" not in css assert "color: gray" not in css.css
def test_conditional_not_met_falls_back_to_unconditional(self): def test_conditional_not_met_falls_back_to_unconditional(self):
"""When conditional doesn't match, unconditional applies.""" """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 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): def test_multiple_conditional_last_wins(self):
"""Among conditional rules with same specificity, last wins.""" """Among conditional rules with same specificity, last wins."""
@@ -162,8 +163,8 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value=5) css, _ = engine.apply_format(rules, cell_value=5)
assert "color: blue" in css assert "color: blue" in css.css
assert "color: red" not in css assert "color: red" not in css.css
def test_spec_example_value_minus_5(self): def test_spec_example_value_minus_5(self):
""" """
@@ -190,7 +191,7 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value=-5) 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): def test_spec_example_value_minus_3(self):
""" """
@@ -213,7 +214,7 @@ class TestConflictResolution:
css, _ = engine.apply_format(rules, cell_value=-3) 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): def test_style_and_formatter_fusion(self):
""" """
@@ -241,8 +242,8 @@ class TestConflictResolution:
# Case 1: Condition met (value > budget) # Case 1: Condition met (value > budget)
css, formatted = engine.apply_format(rules, cell_value=150, row_data=row_data) css, formatted = engine.apply_format(rules, cell_value=150, row_data=row_data)
assert css is not None assert isinstance(css, StyleContainer)
assert "var(--color-secondary)" in css # Style from Rule 2 assert "var(--color-secondary)" in css.css # Style from Rule 2
assert formatted == "150.00 €" # Formatter from Rule 1 assert formatted == "150.00 €" # Formatter from Rule 1
# Case 2: Condition not met (value <= budget) # Case 2: Condition not met (value <= budget)
@@ -281,7 +282,7 @@ class TestConflictResolution:
css, formatted = engine.apply_format(rules, cell_value=-5.67) 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) 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) 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): def test_condition_with_col_parameter(self):
"""Row-level condition using col parameter.""" """Row-level condition using col parameter."""
@@ -314,8 +315,8 @@ class TestWithRowData:
css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data) css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data)
assert css is not None assert isinstance(css, StyleContainer)
assert "background-color" in css assert "background-color" in css.css
class TestPresets: class TestPresets:
@@ -326,7 +327,7 @@ class TestPresets:
css, _ = engine.apply_format(rules, cell_value=42) 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): def test_formatter_preset(self):
"""Formatter preset is resolved correctly.""" """Formatter preset is resolved correctly."""
@@ -347,5 +348,5 @@ class TestPresets:
css, _ = engine.apply_format(rules, cell_value=42) css, _ = engine.apply_format(rules, cell_value=42)
assert "background-color: purple" in css assert "background-color: purple" in css.css
assert "color: yellow" in css assert "color: yellow" in css.css

View File

@@ -1,7 +1,7 @@
import pytest import pytest
from myfasthtml.core.formatting.dataclasses import Style 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: class TestResolve:
@@ -141,3 +141,108 @@ class TestToCssString:
result = resolver.to_css_string(style) result = resolver.to_css_string(style)
assert result == "color: blue;" 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 == ""