Added "table" and "tables" in the DSL

This commit is contained in:
2026-02-07 22:48:51 +01:00
parent 08c8c00e28
commit 6160e91665
18 changed files with 717 additions and 54 deletions

View File

@@ -78,6 +78,7 @@ class DatagridState(DbObject):
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
self.cell_formats: dict = {}
self.table_format: list = []
self.ne_df = None
self.ns_fast_access = None
@@ -274,7 +275,7 @@ class DataGrid(MultipleInstance):
def _get_filtered_df(self):
if self._df is None:
return DataFrame()
return None
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
@@ -396,6 +397,8 @@ class DataGrid(MultipleInstance):
1. Cell-level: self._state.cell_formats[cell_id]
2. Row-level: row_state.format (if row has specific state)
3. Column-level: col_def.format
4. Table-level: self._state.table_format
5. Tables-level (global): manager.all_tables_formats
Args:
col_pos: Column position index
@@ -419,7 +422,11 @@ class DataGrid(MultipleInstance):
if col_def.format:
return col_def.format
return None
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
@@ -629,6 +636,9 @@ class DataGrid(MultipleInstance):
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
"""
df = self._get_filtered_df()
if df is None:
return []
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._state.ns_total_rows > end:

View File

@@ -2,7 +2,8 @@ import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
logger = logging.getLogger("DataGridFormattingEditor")
@@ -62,11 +63,13 @@ class DataGridFormattingEditor(DslEditor):
columns_rules = defaultdict(list) # key = column name
rows_rules = defaultdict(list) # key = row index
cells_rules = defaultdict(list) # key = cell_id
table_rules = [] # rules for this table
tables_rules = [] # global rules for all tables
for scoped_rule in scoped_rules:
scope = scoped_rule.scope
rule = scoped_rule.rule
if isinstance(scope, ColumnScope):
columns_rules[scope.column].append(rule)
elif isinstance(scope, RowScope):
@@ -75,6 +78,14 @@ class DataGridFormattingEditor(DslEditor):
cell_id = self._get_cell_id(scope)
if cell_id:
cells_rules[cell_id].append(rule)
elif isinstance(scope, TableScope):
# Validate table name matches current grid
if scope.table == self._parent._settings.name:
table_rules.append(rule)
else:
logger.warning(f"Table name '{scope.table}' does not match grid name '{self._parent._settings.name}', skipping rules")
elif isinstance(scope, TablesScope):
tables_rules.append(rule)
# Step 3: Copy state for atomic update
state = self._parent.get_state().copy()
@@ -85,6 +96,7 @@ class DataGridFormattingEditor(DslEditor):
for row in state.rows:
row.format = None
state.cell_formats.clear()
state.table_format = []
# Step 5: Apply grouped rules on the copy
for column_name, rules in columns_rules.items():
@@ -103,10 +115,21 @@ class DataGridFormattingEditor(DslEditor):
for cell_id, rules in cells_rules.items():
state.cell_formats[cell_id] = rules
# Apply table-level rules
if table_rules:
state.table_format = table_rules
# Apply global tables-level rules to manager
if tables_rules:
from myfasthtml.controls.DataGridsManager import DataGridsManager
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
if manager:
manager.all_tables_formats = tables_rules
# Step 6: Update state atomically
self._parent.get_state().update(state)
# Step 7: Refresh the DataGrid
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells")
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells, table: {len(table_rules)}, tables: {len(tables_rules)}")
return self._parent.render_partial("body")

View File

@@ -84,12 +84,13 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._state = DataGridsState(self)
self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
self.all_tables_formats: list = []
def upload_from_source(self):
file_upload = FileUpload(self)

View File

@@ -99,7 +99,7 @@ class DslEditor(MultipleInstance):
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
self._state = DslEditorState(self, name=self.conf.name, save_state=save_state)
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")

View File

@@ -23,7 +23,7 @@ Example:
"""
from .parser import get_parser
from .transformer import DSLTransformer
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from .exceptions import DSLError, DSLSyntaxError, DSLValidationError
@@ -61,6 +61,8 @@ __all__ = [
"ColumnScope",
"RowScope",
"CellScope",
"TableScope",
"TablesScope",
"ScopedRule",
# Exceptions
"DSLError",

View File

@@ -105,7 +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
# =================================================================
@@ -230,7 +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 = []

View File

@@ -25,12 +25,14 @@ class Context(Enum):
NONE = auto()
# Scope-level contexts
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell, table, tables
COLUMN_NAME = auto() # After "column ": column names
ROW_INDEX = auto() # After "row ": row indices
CELL_START = auto() # After "cell ": (
CELL_COLUMN = auto() # After "cell (": column names
CELL_ROW = auto() # After "cell (col, ": row indices
TABLE_NAME = auto() # After "table ": table name
TABLES_SCOPE = auto() # After "tables": colon
# Rule-level contexts
RULE_START = auto() # Start of indented line: style(, format(, format.
@@ -76,10 +78,10 @@ class DetectedScope:
Represents the detected scope from scanning previous lines.
Attributes:
scope_type: "column", "row", "cell", or None
scope_type: "column", "row", "cell", "table", "tables", or None
column_name: Column name (for column and cell scopes)
row_index: Row index (for row and cell scopes)
table_name: DataGrid name (if determinable)
table_name: Table name (for table scope) or DataGrid name
"""
scope_type: str | None = None
@@ -92,7 +94,7 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning backwards from the cursor line.
Looks for the most recent scope declaration (column/row/cell)
Looks for the most recent scope declaration (column/row/cell/table/tables)
that is not indented.
Args:
@@ -138,6 +140,17 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
return DetectedScope(
scope_type="cell", column_name=column_name, row_index=row_index
)
# Check for table scope
match = re.match(r'^table\s+"([^"]+)"\s*:', line)
if match:
table_name = match.group(1)
return DetectedScope(scope_type="table", table_name=table_name)
# Check for tables scope
match = re.match(r"^tables\s*:", line)
if match:
return DetectedScope(scope_type="tables")
return DetectedScope()
@@ -192,6 +205,14 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context
if re.match(r'^cell\s+\(\s*(?:"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*$', line_to_cursor):
return Context.CELL_ROW
# After "table "
if re.match(r"^table\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
return Context.TABLE_NAME
# After "tables"
if re.match(r"^tables\s*$", line_to_cursor):
return Context.TABLES_SCOPE
# Start of line or partial keyword
return Context.SCOPE_KEYWORD

View File

@@ -120,6 +120,8 @@ SCOPE_KEYWORDS: list[Suggestion] = [
Suggestion("column", "Define column scope", "keyword"),
Suggestion("row", "Define row scope", "keyword"),
Suggestion("cell", "Define cell scope", "keyword"),
Suggestion("table", "Define table scope", "keyword"),
Suggestion("tables", "Define global scope for all tables", "keyword"),
]
# =============================================================================

View File

@@ -16,10 +16,14 @@ GRAMMAR = r"""
scope_header: column_scope
| row_scope
| cell_scope
| table_scope
| tables_scope
column_scope: "column" column_name
row_scope: "row" INTEGER
cell_scope: "cell" cell_ref
table_scope: "table" QUOTED_STRING
tables_scope: "tables"
column_name: NAME -> name
| QUOTED_STRING -> quoted_name

View File

@@ -64,12 +64,18 @@ class DSLParser:
lines = text.split("\n")
lines = ["" if line.strip().startswith("#") else line for line in lines]
text = "\n".join(lines)
# Strip leading whitespace/newlines and ensure text ends with newline
text = text.strip()
if text and not text.endswith("\n"):
# Handle empty text (return empty tree that will transform to empty list)
if not text:
from lark import Tree
return Tree('start', [])
if not text.endswith("\n"):
text += "\n"
try:
return self._parser.parse(text)
except UnexpectedInput as e:

View File

@@ -32,6 +32,18 @@ class CellScope:
cell_id: str = None
@dataclass
class TableScope:
"""Scope targeting a specific table by name."""
table: str
@dataclass
class TablesScope:
"""Scope targeting all tables (global)."""
pass
@dataclass
class ScopedRule:
"""
@@ -40,8 +52,8 @@ class ScopedRule:
The DSL parser returns a list of ScopedRule objects.
Attributes:
scope: Where the rule applies (ColumnScope, RowScope, or CellScope)
scope: Where the rule applies (ColumnScope, RowScope, CellScope, TableScope, or TablesScope)
rule: The FormatRule (condition + style + formatter)
"""
scope: ColumnScope | RowScope | CellScope
scope: ColumnScope | RowScope | CellScope | TableScope | TablesScope
rule: FormatRule

View File

@@ -6,7 +6,7 @@ Converts lark AST into FormatRule and ScopedRule objects.
from lark import Transformer
from .exceptions import DSLValidationError
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from ..dataclasses import (
Condition,
Style,
@@ -67,7 +67,14 @@ class DSLTransformer(Transformer):
def cell_id(self, items):
cell_id = str(items[0])
return CellScope(cell_id=cell_id)
def table_scope(self, items):
table_name = self._unquote(items[0])
return TableScope(table=table_name)
def tables_scope(self, items):
return TablesScope()
def name(self, items):
return str(items[0])

View File

@@ -202,12 +202,17 @@ class InstancesManager:
return default
@staticmethod
def get_by_type(session: dict, cls: type):
def get_by_type(session: dict, cls: type, default=NO_DEFAULT_VALUE):
session_id = InstancesManager.get_session_id(session)
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
try:
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
except AssertionError:
if default is NO_DEFAULT_VALUE:
raise
return default
@staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple):