Added "table" and "tables" in the DSL
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user