FreeCAD / src /Mod /BIM /ArchReport.py
AbdulElahGwaith's picture
Upload folder using huggingface_hub
985c397 verified
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# Copyright (c) 2025 The FreeCAD Project
import FreeCAD
import os
import json
if FreeCAD.GuiUp:
from PySide import QtCore, QtWidgets, QtGui
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCADGui
from draftutils.translate import translate
# Create an alias for the Slot decorator for use within the GUI-only classes.
Slot = QtCore.Slot
else:
def translate(ctxt, txt):
return txt
def QT_TRANSLATE_NOOP(ctxt, txt):
return txt
# In headless mode, create a dummy decorator named 'Slot'. This allows the
# Python interpreter to parse the @Slot syntax in GUI-only classes without
# raising a NameError because QtCore is not imported.
def Slot(*args, **kwargs):
def decorator(func):
return func
return decorator
import ArchSql
from ArchSql import ReportStatement
if FreeCAD.GuiUp:
ICON_STATUS_OK = FreeCADGui.getIcon(":/icons/edit_OK.svg")
ICON_STATUS_WARN = FreeCADGui.getIcon(":/icons/Warning.svg")
ICON_STATUS_ERROR = FreeCADGui.getIcon(":/icons/delete.svg")
ICON_STATUS_INCOMPLETE = FreeCADGui.getIcon(":/icons/button_invalid.svg")
ICON_EDIT = FreeCADGui.getIcon(":/icons/edit-edit.svg")
ICON_ADD = FreeCADGui.getIcon(":/icons/list-add.svg")
ICON_REMOVE = FreeCADGui.getIcon(":/icons/list-remove.svg")
ICON_DUPLICATE = FreeCADGui.getIcon(":/icons/edit-copy.svg")
def _get_preset_paths(preset_type):
"""
Gets the file paths for bundled (system) and user preset directories.
Parameters
----------
preset_type : str
The type of preset, either 'query' or 'report'.
Returns
-------
tuple
A tuple containing (system_preset_dir, user_preset_dir).
"""
if preset_type == "query":
subdir = "QueryPresets"
elif preset_type == "report":
subdir = "ReportPresets"
else:
return None, None
# Path to the bundled presets installed with FreeCAD
system_path = os.path.join(
FreeCAD.getResourceDir(), "Mod", "BIM", "Presets", "ArchReport", subdir
)
# Path to the user's custom presets in their AppData directory
user_path = os.path.join(FreeCAD.getUserAppDataDir(), "BIM", "Presets", "ArchReport", subdir)
return system_path, user_path
def _get_presets(preset_type):
"""
Loads all bundled and user presets from the filesystem.
This function scans the mirrored system and user directories, loading each
valid .json file. It is resilient to errors in user-created files.
Parameters
----------
preset_type : str
The type of preset to load, either 'query' or 'report'.
Returns
-------
dict
A dictionary mapping the preset's filename (its stable ID) to a
dictionary containing its display name, data, and origin.
Example:
{
"room-schedule.json": {"name": "Room Schedule", "data": {...}, "is_user": False},
"c2f5b1a0...json": {"name": "My Custom Report", "data": {...}, "is_user": True}
}
"""
system_dir, user_dir = _get_preset_paths(preset_type)
presets = {}
def scan_directory(directory, is_user_preset):
if not os.path.isdir(directory):
return
for filename in os.listdir(directory):
if not filename.endswith(".json"):
continue
file_path = os.path.join(directory, filename)
try:
with open(file_path, "r", encoding="utf8") as f:
data = json.load(f)
if "name" not in data:
# Graceful handling: use filename as fallback, log a warning
display_name = os.path.splitext(filename)[0]
FreeCAD.Console.PrintWarning(
f"BIM Report: Preset file '{file_path}' is missing a 'name' key. Using filename as fallback.\n"
)
else:
display_name = data["name"]
# Apply translation only to bundled system presets
if not is_user_preset:
display_name = translate("Arch", display_name)
presets[filename] = {"name": display_name, "data": data, "is_user": is_user_preset}
except json.JSONDecodeError:
# Graceful handling: skip malformed file, log a detailed error
FreeCAD.Console.PrintError(
f"BIM Report: Could not parse preset file at '{file_path}'. It may contain a syntax error.\n"
)
except Exception as e:
FreeCAD.Console.PrintError(
f"BIM Report: An unexpected error occurred while loading preset '{file_path}': {e}\n"
)
# Scan system presets first, then user presets. User presets will not
# overwrite system presets as their filenames (UUIDs) are unique.
scan_directory(system_dir, is_user_preset=False)
scan_directory(user_dir, is_user_preset=True)
return presets
def _save_preset(preset_type, name, data):
"""
Saves a preset to a new, individual .json file with a UUID-based filename.
This function handles name collision checks and ensures the user's preset
is saved in their personal AppData directory.
Parameters
----------
preset_type : str
The type of preset, either 'query' or 'report'.
name : str
The desired human-readable display name for the preset.
data : dict
The dictionary of preset data to be saved as JSON.
"""
import uuid
_, user_path = _get_preset_paths(preset_type)
if not user_path:
return
os.makedirs(user_path, exist_ok=True)
# --- Name Collision Handling ---
existing_presets = _get_presets(preset_type)
existing_display_names = {p["name"] for p in existing_presets.values() if p["is_user"]}
final_name = name
counter = 1
while final_name in existing_display_names:
final_name = f"{name} ({counter:03d})"
counter += 1
# The display name is stored inside the JSON content
data_to_save = data.copy()
data_to_save["name"] = final_name
# The filename is a stable, unique identifier
filename = f"{uuid.uuid4()}.json"
file_path = os.path.join(user_path, filename)
try:
with open(file_path, "w", encoding="utf8") as f:
json.dump(data_to_save, f, indent=2)
FreeCAD.Console.PrintMessage(
f"BIM Report: Preset '{final_name}' saved successfully to '{file_path}'.\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"BIM Report: Could not save preset to '{file_path}': {e}\n")
def _rename_preset(preset_type, filename, new_name):
"""Renames a user preset by updating the 'name' key in its JSON file."""
_, user_path = _get_preset_paths(preset_type)
file_path = os.path.join(user_path, filename)
if not os.path.exists(file_path):
FreeCAD.Console.PrintError(
f"BIM Report: Cannot rename preset. File not found: {file_path}\n"
)
return
try:
with open(file_path, "r", encoding="utf8") as f:
data = json.load(f)
data["name"] = new_name
with open(file_path, "w", encoding="utf8") as f:
json.dump(data, f, indent=2)
except Exception as e:
FreeCAD.Console.PrintError(f"BIM Report: Failed to rename preset file '{file_path}': {e}\n")
def _delete_preset(preset_type, filename):
"""Deletes a user preset file from disk."""
_, user_path = _get_preset_paths(preset_type)
file_path = os.path.join(user_path, filename)
if not os.path.exists(file_path):
FreeCAD.Console.PrintError(
f"BIM Report: Cannot delete preset. File not found: {file_path}\n"
)
return
try:
os.remove(file_path)
except Exception as e:
FreeCAD.Console.PrintError(f"BIM Report: Failed to delete preset file '{file_path}': {e}\n")
if FreeCAD.GuiUp:
class SqlQueryEditor(QtWidgets.QPlainTextEdit):
"""
A custom QPlainTextEdit that provides autocompletion features.
This class integrates QCompleter and handles key events to provide
content-based sizing for the popup and a better user experience,
such as accepting completions with the Tab key.
"""
def __init__(self, parent=None):
super().__init__(parent)
self._completer = None
self.setMouseTracking(True) # Required to receive mouseMoveEvents
self.api_docs = {}
self.clauses = set()
self.functions = {}
def set_api_documentation(self, api_docs: dict):
"""Receives the API documentation from the panel and caches it."""
self.api_docs = api_docs
self.clauses = set(api_docs.get("clauses", []))
# Create a flat lookup dictionary for fast access
for category, func_list in api_docs.get("functions", {}).items():
for func_data in func_list:
self.functions[func_data["name"]] = {
"category": category,
"signature": func_data["signature"],
"description": func_data["description"],
}
def mouseMoveEvent(self, event: QtGui.QMouseEvent):
"""Overrides the mouse move event to show tooltips."""
cursor = self.cursorForPosition(event.pos())
cursor.select(QtGui.QTextCursor.WordUnderCursor)
word = cursor.selectedText().upper()
tooltip_text = self._get_tooltip_for_word(word)
if tooltip_text:
QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, self)
else:
QtWidgets.QToolTip.hideText()
super().mouseMoveEvent(event)
def _get_tooltip_for_word(self, word: str) -> str:
"""Builds the HTML-formatted tooltip string for a given word."""
if not word:
return ""
# Check if the word is a function
if word in self.functions:
func_data = self.functions[word]
# Format a rich HTML tooltip for functions
return (
f"<p style='white-space:nowrap'><code><b>{func_data['signature']}</b></code><br>"
f"<i>{func_data['category']}</i><br>"
f"{func_data['description']}</p>"
)
# Check if the word is a clause
if word in self.clauses:
# Format a simple, translatable tooltip for clauses
# The string itself is marked for translation here.
return f"<i>{translate('Arch', 'SQL Clause')}</i>"
return ""
def setCompleter(self, completer):
if self._completer:
self._completer.activated.disconnect(self.insertCompletion)
self._completer = completer
if not self._completer:
return
self._completer.setWidget(self)
self._completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion)
self._completer.activated.connect(self.insertCompletion)
def completer(self):
return self._completer
def insertCompletion(self, completion):
if self._completer.widget() is not self:
return
tc = self.textCursor()
tc.select(QtGui.QTextCursor.WordUnderCursor)
tc.insertText(completion)
self.setTextCursor(tc)
def textUnderCursor(self):
tc = self.textCursor()
tc.select(QtGui.QTextCursor.WordUnderCursor)
return tc.selectedText()
def keyPressEvent(self, event):
# Pass key events to the completer first if its popup is visible.
if self._completer and self._completer.popup().isVisible():
if event.key() in (
QtCore.Qt.Key_Enter,
QtCore.Qt.Key_Return,
QtCore.Qt.Key_Escape,
QtCore.Qt.Key_Tab,
QtCore.Qt.Key_Backtab,
):
event.ignore()
return
# Let the parent handle the key press to ensure normal typing works.
super().keyPressEvent(event)
# --- Autocompletion Trigger Logic ---
# A Ctrl+Space shortcut can also be used to trigger completion.
is_shortcut = (
event.modifiers() & QtCore.Qt.ControlModifier and event.key() == QtCore.Qt.Key_Space
)
completion_prefix = self.textUnderCursor()
# Don't show completer for very short prefixes unless forced by shortcut.
if not is_shortcut and len(completion_prefix) < 2:
self._completer.popup().hide()
return
# Show the completer if the prefix has changed.
if completion_prefix != self._completer.completionPrefix():
self._completer.setCompletionPrefix(completion_prefix)
# Select the first item by default for a better UX.
self._completer.popup().setCurrentIndex(
self._completer.completionModel().index(0, 0)
)
# --- Sizing and Positioning Logic (The critical fix) ---
cursor_rect = self.cursorRect()
# Calculate the required width based on the content of the popup.
popup_width = (
self._completer.popup().sizeHintForColumn(0)
+ self._completer.popup().verticalScrollBar().sizeHint().width()
)
cursor_rect.setWidth(popup_width)
# Show the completer.
self._completer.complete(cursor_rect)
class _ArchReportDocObserver:
"""Document observer that triggers report execution on recompute."""
def __init__(self, doc, report):
self.doc = doc
self.report = report
def slotRecomputedDocument(self, doc):
if doc != self.doc:
return
self.report.Proxy.execute(self.report)
class _ArchReport:
def __init__(self, obj):
self.setProperties(obj)
# Keep a reference to the host object so helper methods can persist data
self.obj = obj
obj.Proxy = self
self.Type = "ArchReport"
self.spreadsheet = None
self.docObserver = None
self.spreadsheet_current_row = 1 # Internal state for multi-statement reports
# This list holds the "live" ReportStatement objects for runtime use (UI, execute)
self.live_statements = []
# On creation, immediately hydrate the live list from the persistent property
self.hydrate_live_statements(obj)
# If no persisted statements were present, create one default statement
# so the UI shows a starter entry (matching previous behavior).
if not self.live_statements:
default_stmt = ReportStatement(description=translate("Arch", "New Statement"))
self.live_statements.append(default_stmt)
# Persist the default starter statement so future loads see it
try:
self.commit_statements()
except Exception:
# Be resilient during early initialization when document context
# may not be fully available; ignore commit failure.
pass
def onDocumentRestored(self, obj):
"""Called after the object properties are restored from a file."""
# Rebuild the live list of objects from the newly loaded persistent data
self.obj = obj
self.hydrate_live_statements(obj)
self.setProperties(obj) # This will ensure observer is re-attached
def hydrate_live_statements(self, obj):
"""(Re)builds the live list of objects from the stored list of dicts."""
self.live_statements = []
if hasattr(obj, "Statements") and obj.Statements:
for s_data in obj.Statements:
statement = ReportStatement()
statement.loads(s_data) # Use existing loads method
self.live_statements.append(statement)
def commit_statements(self):
"""
Persists the live statements to the document object.
This method serializes the list of live ReportStatement objects
(self.live_statements) into a list of dictionaries and saves it
to the persistent obj.Statements property. This is the official
programmatic way to commit changes.
"""
self.obj.Statements = [s.dumps() for s in self.live_statements]
def setProperties(self, obj):
# Ensure the `Statements` property exists (list of ReportStatement objects)
if not "Statements" in obj.PropertiesList:
obj.addProperty(
"App::PropertyPythonObject",
"Statements",
"Report",
QT_TRANSLATE_NOOP(
"App::Property",
"The list of SQL statements to execute (managed by the Task Panel)",
),
locked=True,
)
obj.Statements = [] # Initialize with an empty list
if not "Target" in obj.PropertiesList:
obj.addProperty(
"App::PropertyLink",
"Target",
"Report",
QT_TRANSLATE_NOOP("App::Property", "The spreadsheet for the results"),
)
if not "AutoUpdate" in obj.PropertiesList:
obj.addProperty(
"App::PropertyBool",
"AutoUpdate",
"Report",
QT_TRANSLATE_NOOP(
"App::Property", "If True, update report when document recomputes"
),
)
obj.AutoUpdate = True
self.onChanged(obj, "AutoUpdate")
# Make the Statements property read-only in the GUI to guide users to the TaskPanel.
# Mode 1: Read-Only. It does not affect scripting access.
if FreeCAD.GuiUp:
obj.setEditorMode("Statements", 1)
def setReportPropertySpreadsheet(self, sp, obj):
"""Associate a spreadsheet with a report.
Ensures the spreadsheet has a non-dependent string property
``ReportName`` with the report's object name, and sets the
report's ``Target`` link to the spreadsheet for future writes.
Parameters
- sp: the Spreadsheet::Sheet object to associate
- obj: the report object (proxy owner)
"""
if not hasattr(sp, "ReportName"):
sp.addProperty(
"App::PropertyString",
"ReportName",
"Report",
QT_TRANSLATE_NOOP(
"App::Property", "The name of the BIM Report that uses this spreadsheet"
),
)
sp.ReportName = obj.Name
obj.Target = sp
def getSpreadSheet(self, obj, force=False):
"""Find or (optionally) create the spreadsheet associated with a report.
The association is persisted via the sheet's ``ReportName`` string.
Parameters
- obj: the report object
- force: if True, create a new spreadsheet when none is found
"""
sp = getattr(self, "spreadsheet", None)
if sp and getattr(sp, "ReportName", None) == obj.Name:
return sp
for o in FreeCAD.ActiveDocument.Objects:
if o.TypeId == "Spreadsheet::Sheet" and getattr(o, "ReportName", None) == obj.Name:
self.spreadsheet = o
return self.spreadsheet
if force:
sheet = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", "ReportResult")
self.setReportPropertySpreadsheet(sheet, obj)
self.spreadsheet = sheet
return self.spreadsheet
else:
return None
def onChanged(self, obj, prop):
if prop == "AutoUpdate":
if obj.AutoUpdate:
if getattr(self, "docObserver", None) is None:
self.docObserver = _ArchReportDocObserver(FreeCAD.ActiveDocument, obj)
FreeCAD.addDocumentObserver(self.docObserver)
else:
if getattr(self, "docObserver", None) is not None:
FreeCAD.removeDocumentObserver(self.docObserver)
self.docObserver = None
if prop == "Statements":
# If the persistent data is changed externally (e.g., by a script),
# re-hydrate the live list to ensure consistency.
self.hydrate_live_statements(obj)
def __getstate__(self):
"""Returns minimal internal state of the proxy for serialization."""
# The main 'Statements' data is persisted on the obj property, not here.
return {
"Type": self.Type,
}
def __setstate__(self, state):
"""Restores minimal internal state of the proxy from serialized data."""
self.Type = state.get("Type", "ArchReport")
self.spreadsheet = None
self.docObserver = None
def _write_cell(self, spreadsheet, cell_address, value):
"""Intelligently writes a value to a spreadsheet cell based on its type."""
# Handle FreeCAD Quantity objects by extracting their raw numerical value.
if isinstance(value, FreeCAD.Units.Quantity):
spreadsheet.set(cell_address, str(value.Value))
elif isinstance(value, (int, float)):
# Write other numbers directly without quotes for calculations.
spreadsheet.set(cell_address, str(value))
elif value is None:
# Write an empty literal string for None.
spreadsheet.set(cell_address, "''")
else:
# Write all other types (e.g., strings) as literal strings.
spreadsheet.set(cell_address, f"'{value}")
def setSpreadsheetData(
self,
obj,
headers,
data_rows,
start_row,
use_description_as_header=False,
description_text="",
include_column_names=True,
add_empty_row_after=False,
print_results_in_bold=False,
force=False,
):
"""Write headers and rows into the report's spreadsheet, starting from a specific row."""
sp = obj.Target # Always use obj.Target directly as it's the explicit link
if not sp: # ensure spreadsheet exists, this is an error condition
FreeCAD.Console.PrintError(
f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
)
return start_row # Return current row unchanged
# Determine the effective starting row for this block of data
current_row = start_row
# --- "Analyst-First" Header Generation ---
# Pre-scan the first data row to find the common unit for each column.
unit_map = {} # e.g., {1: 'mm', 2: 'mm'}
if data_rows:
for i, cell_value in enumerate(data_rows[0]):
if isinstance(cell_value, FreeCAD.Units.Quantity):
# TODO: Replace this with a direct API call when available. The C++ Base::Unit
# class has a `getString()` method that returns the simple unit symbol (e.g.,
# "mm^2"), but it is not exposed to the Python API. The most reliable workaround
# is to stringify the entire Quantity (e.g., "1500.0 mm") and parse the unit
# from that string.
quantity_str = str(cell_value)
parts = quantity_str.split(" ", 1)
if len(parts) > 1:
unit_map[i] = parts[1]
# Create the final headers, appending units where found.
final_headers = []
for i, header_text in enumerate(headers):
if i in unit_map:
final_headers.append(f"{header_text} ({unit_map[i]})")
else:
final_headers.append(header_text)
# Add header for this statement if requested
if use_description_as_header and description_text.strip():
# Merging the header across columns (A to last data column)
last_col_char = chr(ord("A") + len(final_headers) - 1) if final_headers else "A"
sp.set(f"A{current_row}", f"'{description_text}")
sp.mergeCells(f"A{current_row}:{last_col_char}{current_row}")
sp.setStyle(f"A{current_row}", "bold", "add")
current_row += 1 # Advance row for data or column names
# Write column names if requested
if include_column_names and final_headers:
for col_idx, header_text in enumerate(final_headers):
sp.set(f"{chr(ord('A') + col_idx)}{current_row}", f"'{header_text}")
sp.setStyle(
f'A{current_row}:{chr(ord("A") + len(final_headers) - 1)}{current_row}',
"bold",
"add",
)
current_row += 1 # Advance row for data
# Write data rows
for row_data in data_rows:
for col_idx, cell_value in enumerate(row_data):
cell_address = f"{chr(ord('A') + col_idx)}{current_row}"
self._write_cell(sp, cell_address, cell_value)
if print_results_in_bold:
sp.setStyle(cell_address, "bold", "add")
current_row += 1 # Advance row for next data row
# Add empty row if specified
if add_empty_row_after:
current_row += 1 # Just increment row, leave it blank
return current_row # Return the next available row
def execute(self, obj):
"""Executes all statements and writes the results to the target spreadsheet."""
if not self.live_statements:
return
sp = self.getSpreadSheet(obj, force=True)
if not sp:
FreeCAD.Console.PrintError(
f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
)
return
# clear all the content of the spreadsheet
used_range = sp.getUsedRange()
if used_range:
sp.clear(f"{used_range[0]}:{used_range[1]}")
else:
FreeCAD.Console.PrintError(
f"Report '{getattr(obj, 'Label', '')}': Invalid cell address found, clearing spreadsheet.\n"
)
sp.clearAll()
# Reset the row counter for a new report build.
self.spreadsheet_current_row = 1
# The execute_pipeline function is a generator that yields the results
# of each standalone statement or the final result of a pipeline chain.
for statement, headers, results_data in ArchSql.execute_pipeline(self.live_statements):
# For each yielded result block, write it to the spreadsheet.
# The setSpreadsheetData helper already handles all the formatting.
self.spreadsheet_current_row = self.setSpreadsheetData(
obj,
headers,
results_data,
start_row=self.spreadsheet_current_row,
use_description_as_header=statement.use_description_as_header,
description_text=statement.description,
include_column_names=statement.include_column_names,
add_empty_row_after=statement.add_empty_row_after,
print_results_in_bold=statement.print_results_in_bold,
)
sp.recompute()
sp.purgeTouched()
def __repr__(self):
"""Provides an unambiguous representation for developers."""
return f"<BIM Report Label='{self.obj.Label}' Statements={len(self.live_statements)}>"
def __str__(self):
"""
Provides a detailed, human-readable string representation of the report,
including the full SQL query for each statement.
"""
num_statements = len(self.live_statements)
header = f"BIM Report: '{self.obj.Label}' ({num_statements} statements)"
lines = [header]
if not self.live_statements:
return header
for i, stmt in enumerate(self.live_statements, 1):
lines.append("") # Add a blank line for spacing
# Build the flag string for the statement header
flags = []
if stmt.is_pipelined:
flags.append("Pipelined")
if stmt.use_description_as_header:
flags.append("Header")
flag_str = f" ({', '.join(flags)})" if flags else ""
# Add the statement header
lines.append(f"=== Statement [{i}]: {stmt.description}{flag_str} ===")
# Add the formatted SQL query
if stmt.query_string.strip():
query_lines = stmt.query_string.strip().split("\n")
for line in query_lines:
lines.append(f" {line}")
else:
lines.append(" (No query defined)")
return "\n".join(lines)
class ViewProviderReport:
"""The ViewProvider for the ArchReport object."""
def __init__(self, vobj):
vobj.Proxy = self
self.vobj = vobj
def getIcon(self):
return ":/icons/Arch_Schedule.svg"
def doubleClicked(self, vobj):
return self.setEdit(vobj, 0)
def setEdit(self, vobj, mode):
if mode == 0:
if FreeCAD.GuiUp:
panel = ReportTaskPanel(vobj.Object)
try:
FreeCADGui.Control.showDialog(panel)
except RuntimeError as e:
# Avoid raising into the caller (e.g., double click handler)
FreeCAD.Console.PrintError(f"Could not open Report editor: {e}\n")
return False
return True
return False
def attach(self, vobj):
"""Called by the C++ loader when the view provider is rehydrated."""
self.vobj = vobj # Ensure self.vobj is set for consistent access
def claimChildren(self):
"""
Makes the Target spreadsheet appear as a child in the Tree view,
by relying on the proxy's getSpreadSheet method for robust lookup.
"""
obj = self.vobj.Object
spreadsheet = obj.Proxy.getSpreadSheet(obj)
return [spreadsheet] if spreadsheet else []
def dumps(self):
return None
def loads(self, state):
return None
class ReportTaskPanel:
"""Multi-statement task panel for editing a Report.
Exposes `self.form` as a QWidget so it works with FreeCADGui.Control.showDialog(panel).
Implements accept() and reject() to save or discard changes.
"""
# A static blocklist of common, non-queryable properties to exclude
# from the autocompletion list to reduce noise.
PROPERTY_BLOCKLIST = {
"ExpressionEngine",
"Label2",
"Proxy",
"ShapeColor",
"Visibility",
"LineColor",
"LineWidth",
"PointColor",
"PointSize",
}
def __init__(self, report_obj):
# Create two top-level widgets so FreeCAD will wrap each into a TaskBox.
# Box 1 (overview) contains the statements table and management buttons.
# Box 2 (editor) contains the query editor and options.
self.obj = report_obj
self.current_edited_statement_index = -1 # To track which statement is in editor
self.is_dirty = False # To track uncommitted changes
# Overview widget (TaskBox 1)
self.overview_widget = QtWidgets.QWidget()
self.overview_widget.setWindowTitle(translate("Arch", "Report Statements"))
self.statements_overview_widget = self.overview_widget # preserve older name
self.statements_overview_layout = QtWidgets.QVBoxLayout(self.statements_overview_widget)
# Table for statements: Description | Header | Cols | Status
self.table_statements = QtWidgets.QTableWidget()
self.table_statements.setColumnCount(5) # Description, Pipe, Header, Cols, Status
self.table_statements.setHorizontalHeaderLabels(
[
translate("Arch", "Description"),
translate("Arch", "Pipe"),
translate("Arch", "Header"),
translate("Arch", "Cols"),
translate("Arch", "Status"),
]
)
# Add informative tooltips to the headers
self.table_statements.horizontalHeaderItem(2).setToolTip(
translate("Arch", "A user-defined description for this statement.")
)
self.table_statements.horizontalHeaderItem(1).setToolTip(
translate(
"Arch",
"If checked, this statement will use the results of the previous statement as its data source.",
)
)
self.table_statements.horizontalHeaderItem(2).setToolTip(
translate(
"Arch",
"If checked, the Description will be used as a section header in the report.",
)
)
self.table_statements.horizontalHeaderItem(3).setToolTip(
translate(
"Arch",
"If checked, the column names (e.g., 'Label', 'Area') will be included in the report.",
)
)
self.table_statements.horizontalHeaderItem(4).setToolTip(
translate("Arch", "Indicates the status of the SQL query.")
)
# Description stretches, others sized to contents
self.table_statements.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.Stretch
) # Description
self.table_statements.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeToContents
) # Pipe
self.table_statements.horizontalHeader().setSectionResizeMode(
2, QtWidgets.QHeaderView.ResizeToContents
)
self.table_statements.horizontalHeader().setSectionResizeMode(
3, QtWidgets.QHeaderView.ResizeToContents
)
self.table_statements.horizontalHeader().setSectionResizeMode(
4, QtWidgets.QHeaderView.ResizeToContents
)
self.table_statements.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table_statements.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.table_statements.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
self.table_statements.setDragDropOverwriteMode(False)
# Allow in-place editing of the description with F2, but disable the
# default double-click editing so we can repurpose it.
self.table_statements.setEditTriggers(QtWidgets.QAbstractItemView.EditKeyPressed)
self.table_statements.verticalHeader().sectionMoved.connect(self._on_row_moved)
self.statements_overview_layout.addWidget(self.table_statements)
# Template controls for full reports
self.template_layout = QtWidgets.QHBoxLayout()
self.template_dropdown = NoScrollHijackComboBox()
self.template_dropdown.setToolTip(
translate("Arch", "Load a full report template, replacing all current statements.")
)
# Enable per-item tooltips in the dropdown view
self.template_dropdown.view().setToolTip("")
self.btn_manage_templates = QtWidgets.QPushButton(translate("Arch", "Manage..."))
self.btn_manage_templates.setToolTip(
translate("Arch", "Rename, delete, or edit saved report templates.")
)
self.btn_save_template = QtWidgets.QPushButton(translate("Arch", "Save as Template..."))
self.btn_save_template.setToolTip(
translate("Arch", "Save the current set of statements as a new report template.")
)
self.btn_save_template.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
self.template_layout.addWidget(self.template_dropdown)
self.template_layout.addWidget(self.btn_manage_templates)
self.template_layout.addWidget(self.btn_save_template)
template_label = QtWidgets.QLabel(translate("Arch", "Report Templates:"))
self.statements_overview_layout.addWidget(template_label)
self.statements_overview_layout.addLayout(self.template_layout)
# Statement Management Buttons
self.statement_buttons_layout = QtWidgets.QHBoxLayout()
self.btn_add_statement = QtWidgets.QPushButton(ICON_ADD, translate("Arch", "Add Statement"))
self.btn_add_statement.setToolTip(
translate("Arch", "Add a new blank statement to the report.")
)
self.btn_remove_statement = QtWidgets.QPushButton(
ICON_REMOVE, translate("Arch", "Remove Selected")
)
self.btn_remove_statement.setToolTip(
translate("Arch", "Remove the selected statement from the report.")
)
self.btn_duplicate_statement = QtWidgets.QPushButton(
ICON_DUPLICATE, translate("Arch", "Duplicate Selected")
)
self.btn_duplicate_statement.setToolTip(
translate("Arch", "Create a copy of the selected statement.")
)
self.btn_edit_selected = QtWidgets.QPushButton(
ICON_EDIT, translate("Arch", "Edit Selected")
)
self.btn_edit_selected.setToolTip(
translate("Arch", "Load the selected statement into the editor below.")
)
self.statement_buttons_layout.addWidget(self.btn_add_statement)
self.statement_buttons_layout.addWidget(self.btn_remove_statement)
self.statement_buttons_layout.addWidget(self.btn_duplicate_statement)
self.statement_buttons_layout.addStretch()
self.statement_buttons_layout.addWidget(self.btn_edit_selected)
self.statements_overview_layout.addLayout(self.statement_buttons_layout)
# Editor widget (TaskBox 2) -- starts collapsed until a statement is selected
self.editor_widget = QtWidgets.QWidget()
self.editor_widget.setWindowTitle(translate("Arch", "Statement Editor"))
# Keep compatibility name used elsewhere
self.editor_box = self.editor_widget
self.editor_layout = QtWidgets.QVBoxLayout(self.editor_box)
# --- Form Layout for Aligned Inputs ---
self.form_layout = QtWidgets.QFormLayout()
self.form_layout.setContentsMargins(0, 0, 0, 0) # Use the main layout's margins
# Description Row
self.description_edit = QtWidgets.QLineEdit()
self.form_layout.addRow(translate("Arch", "Description:"), self.description_edit)
# Preset Controls Row (widgets are placed in a QHBoxLayout for the second column)
self.preset_controls_layout = QtWidgets.QHBoxLayout()
self.query_preset_dropdown = NoScrollHijackComboBox()
self.query_preset_dropdown.setToolTip(
translate("Arch", "Load a saved query preset into the editor.")
)
# Enable per-item tooltips in the dropdown view
self.query_preset_dropdown.view().setToolTip("")
self.btn_manage_queries = QtWidgets.QPushButton(translate("Arch", "Manage..."))
self.btn_manage_queries.setToolTip(
translate("Arch", "Rename, delete, or edit your saved query presets.")
)
self.btn_save_query_preset = QtWidgets.QPushButton(translate("Arch", "Save..."))
self.btn_save_query_preset.setToolTip(
translate("Arch", "Save the current query as a new preset.")
)
self.preset_controls_layout.addWidget(self.query_preset_dropdown)
self.preset_controls_layout.addWidget(self.btn_manage_queries)
self.preset_controls_layout.addWidget(self.btn_save_query_preset)
self.form_layout.addRow(translate("Arch", "Query Presets:"), self.preset_controls_layout)
self.editor_layout.addLayout(self.form_layout)
# SQL Query editor
self.sql_label = QtWidgets.QLabel(translate("Arch", "SQL Query:"))
self.sql_query_edit = SqlQueryEditor()
self.sql_query_status_label = QtWidgets.QLabel(translate("Arch", "Ready"))
self.sql_query_status_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
# Enable word wrapping to prevent long error messages from expanding the panel.
self.sql_query_status_label.setWordWrap(True)
# Set a dynamic minimum height of 2 lines to prevent layout shifting
# when the label's content changes from 1 to 2 lines.
font_metrics = QtGui.QFontMetrics(self.sql_query_status_label.font())
two_lines_height = 2.5 * font_metrics.height()
self.sql_query_status_label.setMinimumHeight(two_lines_height)
# --- Attach Syntax Highlighter ---
self.sql_highlighter = SqlSyntaxHighlighter(self.sql_query_edit.document())
# --- Setup Autocompletion ---
self.completer = QtWidgets.QCompleter(self.sql_query_edit)
self.completion_model = self._build_completion_model()
self.completer.setModel(self.completion_model)
self.completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
# We use a custom keyPressEvent in SqlQueryEditor to handle Tab/Enter
self.sql_query_edit.setCompleter(self.completer)
self.editor_layout.addWidget(self.sql_label)
self.editor_layout.addWidget(self.sql_query_edit)
self.editor_layout.addWidget(self.sql_query_status_label)
# --- Debugging Actions (Show Preview, Help) ---
self.debugging_actions_layout = QtWidgets.QHBoxLayout()
self.btn_toggle_preview = QtWidgets.QPushButton(translate("Arch", "Show Preview"))
self.btn_toggle_preview.setIcon(FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg"))
self.btn_toggle_preview.setToolTip(
translate("Arch", "Show a preview pane to test the current query in isolation.")
)
self.btn_toggle_preview.setCheckable(True) # Make it a toggle button
self.btn_show_cheatsheet = QtWidgets.QPushButton(translate("Arch", "SQL Cheatsheet"))
self.btn_show_cheatsheet.setIcon(FreeCADGui.getIcon(":/icons/help-browser.svg"))
self.btn_show_cheatsheet.setToolTip(
translate("Arch", "Show a cheatsheet of the supported SQL syntax.")
)
self.editor_layout.addLayout(self.debugging_actions_layout)
self.debugging_actions_layout.addStretch() # Add stretch first for right-alignment
self.debugging_actions_layout.addWidget(self.btn_show_cheatsheet)
self.debugging_actions_layout.addWidget(self.btn_toggle_preview)
# --- Self-Contained Preview Pane ---
self.preview_pane = QtWidgets.QWidget()
preview_pane_layout = QtWidgets.QVBoxLayout(self.preview_pane)
preview_pane_layout.setContentsMargins(0, 5, 0, 0) # Add a small top margin
preview_toolbar_layout = QtWidgets.QHBoxLayout()
self.btn_refresh_preview = QtWidgets.QPushButton(translate("Arch", "Refresh"))
self.btn_refresh_preview.setIcon(FreeCADGui.getIcon(":/icons/view-refresh.svg"))
self.btn_refresh_preview.setToolTip(
translate("Arch", "Re-run the query and update the preview table.")
)
preview_toolbar_layout.addWidget(
QtWidgets.QLabel(translate("Arch", "<b>Query Results Preview</b>"))
)
preview_toolbar_layout.addStretch()
preview_toolbar_layout.addWidget(self.btn_refresh_preview)
self.table_preview_results = QtWidgets.QTableWidget()
self.table_preview_results.setMinimumHeight(150)
self.table_preview_results.setEditTriggers(
QtWidgets.QAbstractItemView.NoEditTriggers
) # Make read-only
self.table_preview_results.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
preview_pane_layout.addLayout(preview_toolbar_layout)
preview_pane_layout.addWidget(self.table_preview_results)
self.editor_layout.addWidget(self.preview_pane)
# Display Options GroupBox
self.display_options_group = QtWidgets.QGroupBox(translate("Arch", "Display Options"))
self.display_options_layout = QtWidgets.QVBoxLayout(self.display_options_group)
self.chk_is_pipelined = QtWidgets.QCheckBox(translate("Arch", "Use as Pipeline Step"))
self.chk_is_pipelined.setToolTip(
translate(
"Arch",
"When checked, this statement will use the results of the previous statement as its data source.",
)
)
self.chk_use_description_as_header = QtWidgets.QCheckBox(
translate("Arch", "Use Description as Section Header")
)
self.chk_use_description_as_header.setToolTip(
translate(
"Arch",
"When checked, the statement's description will be written as a merged header row before its results.",
)
)
self.chk_include_column_names = QtWidgets.QCheckBox(
translate("Arch", "Include Column Names as Headers")
)
self.chk_include_column_names.setToolTip(
translate(
"Arch",
"Include the column headers (Label, IfcType, ...) in the spreadsheet output.",
)
)
self.chk_add_empty_row_after = QtWidgets.QCheckBox(translate("Arch", "Add Empty Row After"))
self.chk_add_empty_row_after.setToolTip(
translate("Arch", "Insert one empty row after this statement's results.")
)
self.chk_print_results_in_bold = QtWidgets.QCheckBox(
translate("Arch", "Print Results in Bold")
)
self.chk_print_results_in_bold.setToolTip(
translate("Arch", "Render the result cells in bold font for emphasis.")
)
self.display_options_layout.addWidget(self.chk_is_pipelined)
self.display_options_layout.addWidget(self.chk_use_description_as_header)
self.display_options_layout.addWidget(self.chk_include_column_names)
self.display_options_layout.addWidget(self.chk_add_empty_row_after)
self.display_options_layout.addWidget(self.chk_print_results_in_bold)
self.editor_layout.addWidget(self.display_options_group)
# --- Commit Actions (Apply, Discard) ---
self.commit_actions_layout = QtWidgets.QHBoxLayout()
self.chk_save_and_next = QtWidgets.QCheckBox(translate("Arch", "Save and Next"))
self.chk_save_and_next.setToolTip(
translate(
"Arch",
"If checked, clicking 'Save' will automatically load the next statement for editing.",
)
)
self.btn_save = QtWidgets.QPushButton(translate("Arch", "Save"))
self.btn_save.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
self.btn_save.setToolTip(
translate("Arch", "Save changes to this statement and close the statement editor.")
)
self.btn_discard = QtWidgets.QPushButton(translate("Arch", "Discard"))
self.btn_discard.setIcon(FreeCADGui.getIcon(":/icons/delete.svg"))
self.btn_discard.setToolTip(
translate("Arch", "Discard all changes made in the statement editor.")
)
self.commit_actions_layout.addStretch()
self.commit_actions_layout.addWidget(self.chk_save_and_next)
self.commit_actions_layout.addWidget(self.btn_discard)
self.commit_actions_layout.addWidget(self.btn_save)
self.editor_layout.addLayout(self.commit_actions_layout)
# Expose form as a list of the two top-level widgets so FreeCAD creates
# two built-in TaskBox sections. The overview goes first, editor second.
self.form = [self.overview_widget, self.editor_widget]
# --- Connections ---
# Use explicit slots instead of lambda wrappers so Qt's meta-object
# system can see the call targets and avoid creating anonymous functions.
self.btn_add_statement.clicked.connect(self._on_add_statement_clicked)
self.btn_remove_statement.clicked.connect(self._on_remove_selected_statement_clicked)
self.btn_duplicate_statement.clicked.connect(self._on_duplicate_selected_statement_clicked) # type: ignore
self.btn_edit_selected.clicked.connect(self._on_edit_selected_clicked)
self.table_statements.itemSelectionChanged.connect(self._on_table_selection_changed)
self.table_statements.itemDoubleClicked.connect(self._on_item_double_clicked)
self.template_dropdown.activated.connect(self._on_load_report_template)
# Keep table edits in sync with the runtime statements
self.table_statements.itemChanged.connect(self._on_table_item_changed)
self.btn_save_template.clicked.connect(self._on_save_report_template)
# Enable and connect the preset management buttons
self.btn_manage_templates.setEnabled(True)
self.btn_manage_queries.setEnabled(True)
self.btn_manage_templates.clicked.connect(lambda: self._on_manage_presets("report"))
self.btn_manage_queries.clicked.connect(lambda: self._on_manage_presets("query"))
# Connect all editor fields to a generic handler to manage the dirty state.
self.description_edit.textChanged.connect(self._on_editor_field_changed)
self.sql_query_edit.textChanged.connect(self._on_editor_sql_changed)
for checkbox in self.display_options_group.findChildren(QtWidgets.QCheckBox):
checkbox.stateChanged.connect(self._on_editor_field_changed)
self.query_preset_dropdown.activated.connect(self._on_load_query_preset)
self.chk_is_pipelined.stateChanged.connect(self._on_editor_sql_changed)
self.btn_save_query_preset.clicked.connect(self._on_save_query_preset)
# Preview and Commit connections
self.btn_toggle_preview.toggled.connect(self._on_preview_toggled)
self.btn_refresh_preview.clicked.connect(self._run_and_display_preview)
self.btn_save.clicked.connect(self.on_save_clicked)
self.btn_discard.clicked.connect(self.on_discard_clicked)
self.btn_show_cheatsheet.clicked.connect(self._show_cheatsheet_dialog)
# Validation Timer for live SQL preview
# Timer doesn't need a specific QWidget parent here; use no parent.
self.validation_timer = QtCore.QTimer()
self.validation_timer.setSingleShot(True)
self.validation_timer.timeout.connect(self._run_live_validation_for_editor)
# Store icons for dynamic button changes
self.icon_show_preview = FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg")
self.icon_hide_preview = FreeCADGui.getIcon(":/icons/Invisible.svg")
# Initial UI setup
self._load_and_populate_presets()
self._populate_table_from_statements()
# Pass the documentation data to the editor for its tooltips
api_docs = ArchSql.getSqlApiDocumentation()
self.sql_query_edit.set_api_documentation(api_docs)
self.editor_widget.setVisible(False) # Start with editor hidden
self._update_ui_for_mode("overview") # Set initial button states
def _load_and_populate_presets(self):
"""Loads all presets and populates the UI dropdowns, including tooltips."""
def _populate_combobox(combobox, preset_type, placeholder_text):
"""Internal helper to load presets and populate a QComboBox."""
# Load the raw preset data from the backend
presets = _get_presets(preset_type)
# Prepare the UI widget
combobox.clear()
# The placeholder_text is already translated by the caller
combobox.addItem(placeholder_text)
model = combobox.model()
sorted_presets = sorted(presets.items(), key=lambda item: item[1]["name"])
# Populate the combobox with the sorted presets
for filename, preset in sorted_presets:
# Add the item with its display name and stable filename (as userData)
combobox.addItem(preset["name"], userData=filename)
# Get the index of the item that was just added
index = combobox.count() - 1
# Access the description from the nested "data" dictionary.
description = preset["data"].get("description", "").strip()
if description:
item = model.item(index)
if item:
item.setToolTip(description)
return presets
# Use the helper function to populate both dropdowns,
# ensuring the placeholder strings are translatable.
self.query_presets = _populate_combobox(
self.query_preset_dropdown, "query", translate("Arch", "--- Select a Query Preset ---")
)
self.report_templates = _populate_combobox(
self.template_dropdown, "report", translate("Arch", "--- Load a Report Template ---")
)
def _on_manage_presets(self, mode):
"""
Launches the ManagePresetsDialog and refreshes the dropdowns
when the dialog is closed.
"""
dialog = ManagePresetsDialog(mode, parent=self.form[0])
dialog.exec_()
# Refresh the dropdowns to reflect any changes made
self._load_and_populate_presets()
@Slot("QTableWidgetItem")
def _on_item_double_clicked(self, item):
"""Handles a double-click on an item in the statements table."""
if item:
# A double-click is a shortcut for editing the full statement.
self._start_edit_session(row_index=item.row())
# --- Statement Management (Buttons and Table Interaction) ---
def _populate_table_from_statements(self):
# Avoid emitting itemChanged while we repopulate programmatically
self.table_statements.blockSignals(True)
self.table_statements.setRowCount(0) # Clear existing rows
# The UI always interacts with the live list of objects from the proxy
for row_idx, statement in enumerate(self.obj.Proxy.live_statements):
self.table_statements.insertRow(row_idx)
# Description (editable text)
desc_item = QtWidgets.QTableWidgetItem(statement.description)
desc_item.setFlags(
desc_item.flags()
| QtCore.Qt.ItemIsEditable
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsEnabled
)
desc_item.setToolTip(translate("Arch", "Double-click to edit description in place."))
self.table_statements.setItem(row_idx, 0, desc_item)
# Pipe checkbox
pipe_item = QtWidgets.QTableWidgetItem()
pipe_item.setFlags(
QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
pipe_item.setCheckState(
QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
)
if row_idx == 0:
pipe_item.setFlags(
pipe_item.flags() & ~QtCore.Qt.ItemIsEnabled
) # Disable for first row
pipe_item.setToolTip(translate("Arch", "The first statement cannot be pipelined."))
else:
pipe_item.setToolTip(
translate(
"Arch", "Toggle whether to use the previous statement's results as input."
)
)
self.table_statements.setItem(row_idx, 1, pipe_item)
# Header checkbox
header_item = QtWidgets.QTableWidgetItem()
header_item.setFlags(
QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
header_item.setCheckState(
QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
)
header_item.setToolTip(
translate(
"Arch",
"Toggle whether to use this statement's Description as a section header.",
)
)
self.table_statements.setItem(row_idx, 2, header_item)
# Cols checkbox (Include Column Names)
cols_item = QtWidgets.QTableWidgetItem()
cols_item.setFlags(
QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
cols_item.setCheckState(
QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
)
cols_item.setToolTip(
translate(
"Arch", "Toggle whether to include this statement's column names in the report."
)
)
self.table_statements.setItem(row_idx, 3, cols_item)
# Status Item (Icon + Tooltip) - read-only
status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
status_item = QtWidgets.QTableWidgetItem()
status_item.setIcon(status_icon)
status_item.setToolTip(status_tooltip)
# Display the object count next to the icon for valid queries.
if statement._validation_status in ("OK", "0_RESULTS"):
status_item.setText(str(statement._validation_count))
# Align the text to the right for better visual separation.
status_item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
status_item.setFlags(status_item.flags() & ~QtCore.Qt.ItemIsEditable) # Make read-only
self.table_statements.setItem(row_idx, 4, status_item)
# After populating all rows, trigger a validation for all statements.
# This ensures the counts and statuses are up-to-date when the panel opens.
for statement in self.obj.Proxy.live_statements:
statement.validate_and_update_status()
self._update_table_row_status(
self.obj.Proxy.live_statements.index(statement), statement
)
# Re-enable signals after population so user edits are handled
self.table_statements.blockSignals(False)
# --- Explicit Qt Slot Wrappers ---
@Slot()
def _on_add_statement_clicked(self):
"""Slot wrapper for the Add button (clicked)."""
# Default behavior: create a new statement but do not open editor.
self._add_statement(start_editing=False)
@Slot()
def _on_remove_selected_statement_clicked(self):
"""Slot wrapper for the Remove button (clicked)."""
self._remove_selected_statement()
@Slot()
def _on_duplicate_selected_statement_clicked(self):
"""Slot wrapper for the Duplicate button (clicked)."""
self._duplicate_selected_statement()
@Slot()
def _on_edit_selected_clicked(self):
"""Slot wrapper for the Edit Selected button (clicked)."""
# Delegate to _start_edit_session() which will find the selection if no
# explicit row_index is given.
self._start_edit_session()
def _on_table_item_changed(self, item):
"""Synchronize direct table edits (description and checkboxes) back into the runtime statement."""
row = item.row()
col = item.column()
if row < 0 or row >= len(self.obj.Proxy.live_statements):
return
stmt = self.obj.Proxy.live_statements[row]
if col == 0: # Description
new_text = item.text()
if stmt.description != new_text:
stmt.description = new_text
self._set_dirty(True)
elif col == 1: # Pipe checkbox
is_checked = item.checkState() == QtCore.Qt.Checked
if stmt.is_pipelined != is_checked:
stmt.is_pipelined = is_checked
self._set_dirty(True)
# Re-validate the editor if its context has changed
if self.current_edited_statement_index != -1:
self._run_live_validation_for_editor()
elif col == 2: # Header checkbox
is_checked = item.checkState() == QtCore.Qt.Checked
if stmt.use_description_as_header != is_checked:
stmt.use_description_as_header = is_checked
self._set_dirty(True)
elif col == 3: # Cols checkbox
is_checked = item.checkState() == QtCore.Qt.Checked
if stmt.include_column_names != is_checked:
stmt.include_column_names = is_checked
self._set_dirty(True)
def _on_row_moved(self, logical_index, old_visual_index, new_visual_index):
"""Handles the reordering of statements via drag-and-drop."""
# The visual index is what the user sees. The logical index is tied to the original sort.
# When a row is moved, we need to map the visual change back to our data model.
# Pop the item from its original position in the data model.
moving_statement = self.obj.Proxy.live_statements.pop(old_visual_index)
# Insert it into its new position.
self.obj.Proxy.live_statements.insert(new_visual_index, moving_statement)
self._set_dirty(True)
# After reordering the data, we must repopulate the table to ensure
# everything is visually correct and consistent, especially the disabled
# "Pipe" checkbox on the new first row.
self._populate_table_from_statements()
# Restore the selection to the row that was just moved.
self.table_statements.selectRow(new_visual_index)
def _add_statement(self, start_editing=False):
"""Creates a new statement, adds it to the report, and optionally starts editing it."""
# Create the new statement object and add it to the live list.
new_statement = ReportStatement(
description=translate(
"Arch", f"New Statement {len(self.obj.Proxy.live_statements) + 1}"
)
)
self.obj.Proxy.live_statements.append(new_statement)
# Refresh the entire overview table to show the new row.
self._populate_table_from_statements()
# Validate the new (empty) statement to populate its status.
new_statement.validate_and_update_status()
new_row_index = len(self.obj.Proxy.live_statements) - 1
if start_editing:
self._start_edit_session(row_index=new_row_index)
else:
self.table_statements.selectRow(new_row_index)
self._set_dirty(True)
def _remove_selected_statement(self):
selected_rows = self.table_statements.selectionModel().selectedRows()
if not selected_rows:
return
row_to_remove = selected_rows[0].row()
description_to_remove = self.table_statements.item(row_to_remove, 0).text()
if (
QtWidgets.QMessageBox.question(
None,
translate("Arch", "Remove Statement"),
translate(
"Arch", f"Are you sure you want to remove statement '{description_to_remove}'?"
),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
)
== QtWidgets.QMessageBox.Yes
):
self.obj.Proxy.live_statements.pop(row_to_remove)
self._set_dirty(True)
self._populate_table_from_statements()
self._end_edit_session() # Close editor and reset selection
def _duplicate_selected_statement(self):
"""Duplicates the selected statement without opening the editor."""
selected_rows = self.table_statements.selectionModel().selectedRows()
if not selected_rows:
return
row_to_duplicate = selected_rows[0].row()
original = self.obj.Proxy.live_statements[row_to_duplicate]
duplicated = ReportStatement()
duplicated.loads(original.dumps())
duplicated.description = translate("Arch", f"Copy of {original.description}")
self.obj.Proxy.live_statements.insert(row_to_duplicate + 1, duplicated)
self._set_dirty(True)
self._populate_table_from_statements()
duplicated.validate_and_update_status()
# New behavior: Just select the newly created row. Do NOT open the editor.
self.table_statements.selectRow(row_to_duplicate + 1)
def _select_statement_in_table(self, row_idx):
# Select the row visually and trigger the new edit session
self.table_statements.selectRow(row_idx)
# This method should ONLY select, not start an edit session.
# --- Editor (Box 2) Management ---
def _load_statement_to_editor(self, statement: ReportStatement):
# Disable/enable the pipeline checkbox based on row index
is_first_statement = self.current_edited_statement_index == 0
self.chk_is_pipelined.setEnabled(not is_first_statement)
if is_first_statement:
# Ensure the first statement can never be pipelined
statement.is_pipelined = False
self.description_edit.setText(statement.description)
self.sql_query_edit.setPlainText(statement.query_string)
self.chk_is_pipelined.setChecked(statement.is_pipelined)
self.chk_use_description_as_header.setChecked(statement.use_description_as_header)
self.chk_include_column_names.setChecked(statement.include_column_names)
self.chk_add_empty_row_after.setChecked(statement.add_empty_row_after)
self.chk_print_results_in_bold.setChecked(statement.print_results_in_bold)
# We must re-run validation here because the context may have changed
self._run_live_validation_for_editor()
def _save_current_editor_state_to_statement(self):
if self.current_edited_statement_index != -1 and self.current_edited_statement_index < len(
self.obj.Proxy.live_statements
):
statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
statement.description = self.description_edit.text()
statement.query_string = self.sql_query_edit.toPlainText()
statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
statement.include_column_names = self.chk_include_column_names.isChecked()
statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()
statement.validate_and_update_status() # Update status in the statement object
self._update_table_row_status(
self.current_edited_statement_index, statement
) # Refresh table status
def _on_editor_sql_changed(self):
"""Handles text changes in the SQL editor, triggering validation."""
self._on_editor_field_changed() # Mark as dirty
# Immediately switch to a neutral "Typing..." state to provide
# instant feedback and hide any previous validation messages.
self.sql_query_status_label.setText(translate("Arch", "<i>Typing...</i>"))
self.sql_query_status_label.setStyleSheet("color: gray;")
# Start (or restart) the timer for the full validation.
self.validation_timer.start(500)
def _on_editor_field_changed(self, *args):
"""A generic slot that handles any change in an editor field to mark it as dirty.
This method is connected to multiple signal signatures (textChanged -> str,
stateChanged -> int). Leaving it undecorated (or accepting *args) keeps it
flexible so Qt can call it with varying argument lists.
"""
self._set_dirty(True)
@Slot(int)
def _on_load_query_preset(self, index):
"""Handles the selection of a query preset from the dropdown."""
if index == 0: # Ignore the placeholder item
return
filename = self.query_preset_dropdown.itemData(index)
preset_data = self.query_presets.get(filename, {}).get("data")
if not preset_data:
FreeCAD.Console.PrintError(
f"BIM Report: Could not load data for query preset with filename '{filename}'.\n"
)
self.query_preset_dropdown.setCurrentIndex(0)
return
# Confirm before overwriting existing text
if self.sql_query_edit.toPlainText().strip():
reply = QtWidgets.QMessageBox.question(
None,
translate("Arch", "Overwrite Query?"),
translate(
"Arch",
"Loading a preset will overwrite the current text in the query editor. Continue?",
),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No,
)
if reply == QtWidgets.QMessageBox.No:
self.query_preset_dropdown.setCurrentIndex(0) # Reset dropdown
return
if "query" in preset_data:
self.sql_query_edit.setPlainText(preset_data["query"])
# Reset dropdown to act as a one-shot action button
self.query_preset_dropdown.setCurrentIndex(0)
@Slot()
def _on_save_query_preset(self):
"""Saves the current query text as a new user preset."""
current_query = self.sql_query_edit.toPlainText().strip()
if not current_query:
QtWidgets.QMessageBox.warning(
None,
translate("Arch", "Empty Query"),
translate("Arch", "Cannot save an empty query as a preset."),
)
return
preset_name, ok = QtWidgets.QInputDialog.getText(
None, translate("Arch", "Save Query Preset"), translate("Arch", "Preset Name:")
)
if ok and preset_name:
# The data payload does not include the 'name' key; _save_preset adds it.
preset_data = {"description": "User-defined query preset.", "query": current_query}
_save_preset("query", preset_name, preset_data)
self._load_and_populate_presets() # Refresh the dropdown with the new preset
@Slot(int)
def _on_load_report_template(self, index):
"""Handles the selection of a full report template from the dropdown."""
if index == 0:
return
filename = self.template_dropdown.itemData(index)
template_data = self.report_templates.get(filename, {}).get("data")
if not template_data:
FreeCAD.Console.PrintError(
f"BIM Report: Could not load data for template with filename '{filename}'.\n"
)
self.template_dropdown.setCurrentIndex(0)
return
if self.obj.Proxy.live_statements:
reply = QtWidgets.QMessageBox.question(
None,
translate("Arch", "Overwrite Report?"),
translate(
"Arch",
"Loading a template will replace all current statements in this report. Continue?",
),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No,
)
if reply == QtWidgets.QMessageBox.No:
self.template_dropdown.setCurrentIndex(0)
return
if "statements" in template_data:
# Rebuild the live list from the template data
self.obj.Proxy.live_statements = []
for s_data in template_data["statements"]:
statement = ReportStatement()
statement.loads(s_data)
self.obj.Proxy.live_statements.append(statement)
self._populate_table_from_statements()
# Terminate any active editing session, as loading a template invalidates it. This
# correctly resets the entire UI state.
self._end_edit_session()
self._set_dirty(True)
self.template_dropdown.setCurrentIndex(0)
@Slot()
def _on_save_report_template(self):
"""Saves the current set of statements as a new report template."""
if not self.obj.Proxy.live_statements:
QtWidgets.QMessageBox.warning(
None,
translate("Arch", "Empty Report"),
translate("Arch", "Cannot save an empty report as a template."),
)
return
template_name, ok = QtWidgets.QInputDialog.getText(
None, translate("Arch", "Save Report Template"), translate("Arch", "Template Name:")
)
if ok and template_name:
# The data payload does not include the 'name' key.
template_data = {
"description": "User-defined report template.",
"statements": [s.dumps() for s in self.obj.Proxy.live_statements],
}
_save_preset("report", template_name, template_data)
self._load_and_populate_presets() # Refresh the template dropdown
def _run_live_validation_for_editor(self):
"""
Runs live validation for the query in the editor, providing
contextual feedback if the statement is part of a pipeline.
This method does NOT modify the underlying statement object.
"""
if self.current_edited_statement_index == -1:
return
current_query = self.sql_query_edit.toPlainText()
is_pipelined = self.chk_is_pipelined.isChecked()
# Create a temporary, in-memory statement object for validation.
# This prevents mutation of the real data model.
temp_statement = ReportStatement()
source_objects = None
input_count_str = ""
if is_pipelined and self.current_edited_statement_index > 0:
preceding_statements = self.obj.Proxy.live_statements[
: self.current_edited_statement_index
]
source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)
input_count = len(source_objects)
input_count_str = translate("Arch", f" (from {input_count} in pipeline)")
count, error = ArchSql.count(current_query, source_objects=source_objects)
# --- Update the UI display using the validation results ---
if not error and count > 0:
temp_statement._validation_status = "OK"
temp_statement._validation_message = f"{translate('Arch', 'Found')} {count} {translate('Arch', 'objects')}{input_count_str}."
elif not error and count == 0:
temp_statement._validation_status = "0_RESULTS"
# The message for 0 results is more of a warning than a success.
temp_statement._validation_message = (
f"{translate('Arch', 'Query is valid but found 0 objects')}{input_count_str}."
)
elif error == "INCOMPLETE":
temp_statement._validation_status = "INCOMPLETE"
temp_statement._validation_message = translate("Arch", "Query is incomplete")
else: # An actual error occurred
temp_statement._validation_status = "ERROR"
temp_statement._validation_message = f"{error}{input_count_str}"
self._update_editor_status_display(temp_statement)
def _update_editor_status_display(self, statement: ReportStatement):
# Update the status label (below SQL editor) in Box 2
# The "Typing..." state is now handled instantly by _on_editor_sql_changed.
# This method only handles the final states (Incomplete, Error, 0, OK).
if statement._validation_status == "INCOMPLETE":
self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
self.sql_query_status_label.setStyleSheet("color: orange;")
elif statement._validation_status == "ERROR":
self.sql_query_status_label.setText(f"❌ {statement._validation_message}")
self.sql_query_status_label.setStyleSheet("color: red;")
elif statement._validation_status == "0_RESULTS":
self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
self.sql_query_status_label.setStyleSheet("color: orange;")
else: # OK or Ready
self.sql_query_status_label.setText(f"✅ {statement._validation_message}")
self.sql_query_status_label.setStyleSheet("color: green;")
# The preview button should only be enabled if the query is valid and
# can be executed (even if it returns 0 results).
is_executable = statement._validation_status in ("OK", "0_RESULTS")
self.btn_toggle_preview.setEnabled(is_executable)
def _update_table_row_status(self, row_idx, statement: ReportStatement):
"""Updates the status icon/tooltip and other data in the QTableWidget for a given row."""
if row_idx < 0 or row_idx >= self.table_statements.rowCount():
return
# Correct Column Mapping:
# 0: Description
# 1: Pipe
# 2: Header
# 3: Cols
# 4: Status
# Update all cells in the row to be in sync with the statement object.
# This is safer than assuming which property might have changed.
self.table_statements.item(row_idx, 0).setText(statement.description)
self.table_statements.item(row_idx, 1).setCheckState(
QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
)
self.table_statements.item(row_idx, 2).setCheckState(
QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
)
self.table_statements.item(row_idx, 3).setCheckState(
QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
)
status_item = self.table_statements.item(row_idx, 4)
if status_item:
status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
status_item.setIcon(status_icon)
status_item.setToolTip(status_tooltip)
# Update the text as well
if statement._validation_status in ("OK", "0_RESULTS"):
status_item.setText(str(statement._validation_count))
else:
status_item.setText("") # Clear the text for error/incomplete states
def _get_status_icon_and_tooltip(self, statement: ReportStatement):
# Helper to get appropriate icon and tooltip for table status column
status = statement._validation_status
message = statement._validation_message
if status == "OK":
return ICON_STATUS_OK, message
elif status == "0_RESULTS":
return ICON_STATUS_WARN, message
elif status == "ERROR":
return ICON_STATUS_ERROR, message
elif status == "INCOMPLETE":
return ICON_STATUS_INCOMPLETE, translate("Arch", "Query incomplete or typing...")
return QtGui.QIcon(), translate("Arch", "Ready") # Default/initial state
def _set_dirty(self, dirty_state):
"""Updates the UI to show if there are uncommitted changes."""
if self.is_dirty == dirty_state:
return
self.is_dirty = dirty_state
title = translate("Arch", "Report Statements")
if self.is_dirty:
title += " *"
self.overview_widget.setWindowTitle(title)
def _show_cheatsheet_dialog(self):
"""Gets the API documentation and displays it in a dialog."""
api_data = ArchSql.getSqlApiDocumentation()
dialog = CheatsheetDialog(api_data, parent=self.editor_widget)
dialog.exec_()
def _build_completion_model(self):
"""
Builds the master list of words for the autocompleter.
This method gets raw data from the SQL engine and then applies all
UI-specific formatting, such as combining keywords into phrases and
adding trailing spaces for a better user experience.
"""
# 1. Get the set of keywords that should NOT get a trailing space.
no_space_keywords = ArchSql.getSqlKeywords(kind="no_space")
# 2. Get the raw list of all individual keywords.
raw_keywords = set(ArchSql.getSqlKeywords())
# 3. Define UI-specific phrases and their components.
smart_clauses = {"GROUP BY ": ("GROUP", "BY"), "ORDER BY ": ("ORDER", "BY")}
# 4. Build the final set of completion words.
all_words = set()
# Add the smart phrases directly.
all_words.update(smart_clauses.keys())
# Get the individual components of the smart phrases to avoid adding them twice.
words_to_skip = {word for components in smart_clauses.values() for word in components}
for word in raw_keywords:
if word in words_to_skip:
continue
if word in no_space_keywords:
all_words.add(word) # Add without a space
else:
all_words.add(word + " ") # Add with a space by default
# 5. Add all unique property names from the document (without spaces).
if FreeCAD.ActiveDocument:
property_names = set()
for obj in FreeCAD.ActiveDocument.Objects:
for prop_name in obj.PropertiesList:
if prop_name not in self.PROPERTY_BLOCKLIST:
property_names.add(prop_name)
all_words.update(property_names)
# 6. Return a sorted model for the completer.
return QtCore.QStringListModel(sorted(list(all_words)))
def _update_ui_for_mode(self, mode):
"""Centralizes enabling/disabling of UI controls based on the current mode."""
if mode == "editing":
# In edit mode, disable overview actions to prevent conflicts
self.btn_add_statement.setEnabled(False)
self.btn_remove_statement.setEnabled(False)
self.btn_duplicate_statement.setEnabled(False)
self.btn_edit_selected.setEnabled(False)
self.template_dropdown.setEnabled(False)
self.btn_save_template.setEnabled(False)
self.table_statements.setEnabled(False)
else: # "overview" mode
# In overview mode, re-enable controls
self.btn_add_statement.setEnabled(True)
self.btn_remove_statement.setEnabled(True)
self.btn_duplicate_statement.setEnabled(True)
self.template_dropdown.setEnabled(True)
self.btn_save_template.setEnabled(True)
self.table_statements.setEnabled(True)
# The "Edit" button state depends on whether a row is selected
self._on_table_selection_changed()
def _on_table_selection_changed(self):
"""Slot for selection changes in the overview table."""
# This method's only job is to enable the "Edit" button if a row is selected.
has_selection = bool(self.table_statements.selectionModel().selectedRows())
self.btn_edit_selected.setEnabled(has_selection)
def _start_edit_session(self, row_index=None):
"""Loads a statement into the editor and displays it."""
if row_index is None:
selected_rows = self.table_statements.selectionModel().selectedRows()
if not selected_rows:
return
row_index = selected_rows[0].row()
# Explicitly hide the preview pane and reset the toggle when starting a new session.
self.preview_pane.setVisible(False)
self.btn_toggle_preview.setChecked(False)
self.current_edited_statement_index = row_index
statement = self.obj.Proxy.live_statements[row_index]
# Load data into the editor
self._load_statement_to_editor(statement)
# Show editor and set focus
self.editor_widget.setVisible(True)
self.sql_query_edit.setFocus()
self._update_ui_for_mode("editing")
# Initially disable the preview button until the first validation confirms
# that the query is executable.
self.btn_toggle_preview.setEnabled(False)
def _end_edit_session(self):
"""Hides the editor and restores the overview state."""
self.editor_widget.setVisible(False)
self.preview_pane.setVisible(False) # Also hide preview if it was open
self.btn_toggle_preview.setChecked(False) # Ensure toggle is reset
self.current_edited_statement_index = -1
self._update_ui_for_mode("overview")
self.table_statements.setFocus()
def _commit_changes(self):
"""Saves the data from the editor back to the live statement object."""
if self.current_edited_statement_index == -1:
return
statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
statement.description = self.description_edit.text()
statement.query_string = self.sql_query_edit.toPlainText()
statement.is_pipelined = self.chk_is_pipelined.isChecked()
statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
statement.include_column_names = self.chk_include_column_names.isChecked()
statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()
statement.validate_and_update_status()
self._update_table_row_status(self.current_edited_statement_index, statement)
self._set_dirty(True)
def on_save_clicked(self):
"""Saves changes and either closes the editor or adds a new statement."""
# First, always commit the changes from the current edit session.
self._commit_changes()
if self.chk_save_and_next.isChecked():
# If the checkbox is checked, the "Next" action is to add a new
# blank statement. The _add_statement helper already handles
# creating the statement and opening it in the editor.
self._add_statement(start_editing=True)
else:
# The default action is to simply close the editor.
self._end_edit_session()
def on_discard_clicked(self):
"""Discards changes and closes the editor."""
self._end_edit_session()
@Slot(bool)
def _on_preview_toggled(self, checked):
"""Shows or hides the preview pane and updates the toggle button's appearance."""
if checked:
self.btn_toggle_preview.setText(translate("Arch", "Hide Preview"))
self.btn_toggle_preview.setIcon(self.icon_hide_preview)
self.preview_pane.setVisible(True)
self.btn_refresh_preview.setVisible(True)
self._run_and_display_preview()
else:
self.btn_toggle_preview.setText(translate("Arch", "Show Preview"))
self.btn_toggle_preview.setIcon(self.icon_show_preview)
self.preview_pane.setVisible(False)
self.btn_refresh_preview.setVisible(False)
def _run_and_display_preview(self):
"""Executes the query in the editor and populates the preview table, respecting the pipeline context."""
query = self.sql_query_edit.toPlainText().strip()
is_pipelined = self.chk_is_pipelined.isChecked()
if not self.preview_pane.isVisible():
return
if not query:
self.table_preview_results.clear()
self.table_preview_results.setRowCount(0)
self.table_preview_results.setColumnCount(0)
return
source_objects = None
if is_pipelined and self.current_edited_statement_index > 0:
preceding_statements = self.obj.Proxy.live_statements[
: self.current_edited_statement_index
]
source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)
try:
# Run the preview with the correct context.
headers, data_rows, _ = ArchSql._run_query(
query, mode="full_data", source_objects=source_objects
)
self.table_preview_results.clear()
self.table_preview_results.setColumnCount(len(headers))
self.table_preview_results.setHorizontalHeaderLabels(headers)
self.table_preview_results.setRowCount(len(data_rows))
for row_idx, row_data in enumerate(data_rows):
for col_idx, cell_value in enumerate(row_data):
item = QtWidgets.QTableWidgetItem(str(cell_value))
self.table_preview_results.setItem(row_idx, col_idx, item)
self.table_preview_results.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Interactive
)
except (ArchSql.SqlEngineError, ArchSql.BimSqlSyntaxError) as e:
# Error handling remains the same
self.table_preview_results.clear()
self.table_preview_results.setRowCount(1)
self.table_preview_results.setColumnCount(1)
self.table_preview_results.setHorizontalHeaderLabels(["Query Error"])
error_item = QtWidgets.QTableWidgetItem(f"❌ {str(e)}")
error_item.setForeground(QtGui.QColor("red"))
self.table_preview_results.setItem(0, 0, error_item)
self.table_preview_results.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.Stretch
)
# --- Dialog Acceptance / Rejection ---
def accept(self):
"""Saves changes from UI to Report object and triggers recompute."""
# First, check if there is an active, unsaved edit session.
if self.current_edited_statement_index != -1:
reply = QtWidgets.QMessageBox.question(
None,
translate("Arch", "Unsaved Changes"),
translate(
"Arch",
"You have unsaved changes in the statement editor. Do you want to save them before closing?",
),
QtWidgets.QMessageBox.Save
| QtWidgets.QMessageBox.Discard
| QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Save,
)
if reply == QtWidgets.QMessageBox.Save:
self._commit_changes()
elif reply == QtWidgets.QMessageBox.Cancel:
return # Abort the close operation entirely.
# If Discard, do nothing and proceed with closing.
# This is the "commit" step: persist the live statements to the document object.
self.obj.Proxy.commit_statements()
# Trigger a recompute to run the report and mark the document as modified.
# This will now run the final, correct pipeline.
FreeCAD.ActiveDocument.recompute()
# Quality of life: open the target spreadsheet to show the results.
spreadsheet = self.obj.Target
if spreadsheet:
FreeCADGui.ActiveDocument.setEdit(spreadsheet.Name, 0)
# Close the task panel.
try:
FreeCADGui.Control.closeDialog()
except Exception as e:
FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")
self._set_dirty(False)
def reject(self):
"""Closes dialog without saving changes to the Report object."""
# Revert changes by not writing to self.obj.Statements
# Discard live changes by re-hydrating from the persisted property
self.obj.Proxy.hydrate_live_statements(self.obj)
self._set_dirty(False)
# Close the task panel when GUI is available
try:
FreeCADGui.Control.closeDialog()
except Exception as e:
# This is a defensive catch. If closing the dialog fails for any reason
# (e.g., it was already closed), we log the error but do not crash.
FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")
if FreeCAD.GuiUp:
from PySide.QtGui import QDesktopServices
from PySide.QtCore import QUrl
class ManagePresetsDialog(QtWidgets.QDialog):
"""A dialog for managing user-created presets (rename, delete, edit source)."""
def __init__(self, mode, parent=None):
super().__init__(parent)
self.mode = mode # 'query' or 'report'
self.setWindowTitle(translate("Arch", f"Manage {mode.capitalize()} Presets"))
self.setMinimumSize(500, 400)
# --- UI Layout ---
self.layout = QtWidgets.QVBoxLayout(self)
self.preset_list = QtWidgets.QListWidget()
self.layout.addWidget(self.preset_list)
self.buttons_layout = QtWidgets.QHBoxLayout()
self.btn_rename = QtWidgets.QPushButton(translate("Arch", "Rename..."))
self.btn_delete = QtWidgets.QPushButton(translate("Arch", "Delete"))
self.btn_edit_source = QtWidgets.QPushButton(translate("Arch", "Edit Source..."))
self.btn_close = QtWidgets.QPushButton(translate("Arch", "Close"))
self.buttons_layout.addWidget(self.btn_rename)
self.buttons_layout.addWidget(self.btn_delete)
self.buttons_layout.addStretch()
self.buttons_layout.addWidget(self.btn_edit_source)
self.layout.addLayout(self.buttons_layout)
self.layout.addWidget(self.btn_close)
# --- Connections ---
self.btn_close.clicked.connect(self.accept)
self.preset_list.itemSelectionChanged.connect(self._on_selection_changed)
self.btn_rename.clicked.connect(self._on_rename)
self.btn_delete.clicked.connect(self._on_delete)
self.btn_edit_source.clicked.connect(self._on_edit_source)
# --- Initial State ---
self._populate_list()
self._on_selection_changed() # Set initial button states
def _populate_list(self):
"""Fills the list widget with system and user presets."""
self.preset_list.clear()
self.presets = _get_presets(self.mode)
# Sort by display name for consistent UI order
sorted_presets = sorted(self.presets.items(), key=lambda item: item[1]["name"])
for filename, preset_data in sorted_presets:
item = QtWidgets.QListWidgetItem()
display_text = preset_data["name"]
if preset_data["is_user"]:
item.setText(f"{display_text} (User)")
else:
item.setText(display_text)
# Make system presets visually distinct and non-selectable for modification
item.setForeground(QtGui.QColor("gray"))
flags = item.flags()
flags &= ~QtCore.Qt.ItemIsSelectable
item.setFlags(flags)
# Store the stable filename as data in the item
item.setData(QtCore.Qt.UserRole, filename)
self.preset_list.addItem(item)
def _on_selection_changed(self):
"""Enables/disables buttons based on the current selection."""
selected_items = self.preset_list.selectedItems()
is_user_preset_selected = False
if selected_items:
filename = selected_items[0].data(QtCore.Qt.UserRole)
if self.presets[filename]["is_user"]:
is_user_preset_selected = True
self.btn_rename.setEnabled(is_user_preset_selected)
self.btn_delete.setEnabled(is_user_preset_selected)
self.btn_edit_source.setEnabled(is_user_preset_selected)
# --- Add Tooltips for Disabled State (Refinement #2) ---
tooltip = translate("Arch", "This action is only available for user-created presets.")
self.btn_rename.setToolTip("" if is_user_preset_selected else tooltip)
self.btn_delete.setToolTip("" if is_user_preset_selected else tooltip)
self.btn_edit_source.setToolTip("" if is_user_preset_selected else tooltip)
def _on_rename(self):
"""Handles the rename action."""
item = self.preset_list.selectedItems()[0]
filename = item.data(QtCore.Qt.UserRole)
current_name = self.presets[filename]["name"]
# --- Live Name Collision Check (Refinement #2) ---
existing_names = {p["name"] for f, p in self.presets.items() if f != filename}
new_name, ok = QtWidgets.QInputDialog.getText(
self,
translate("Arch", "Rename Preset"),
translate("Arch", "New name:"),
text=current_name,
)
if ok and new_name and new_name != current_name:
if new_name in existing_names:
QtWidgets.QMessageBox.warning(
self,
translate("Arch", "Name Conflict"),
translate(
"Arch",
"A preset with this name already exists. Please choose a different name.",
),
)
return
_rename_preset(self.mode, filename, new_name)
self._populate_list() # Refresh the list
def _on_delete(self):
"""Handles the delete action."""
item = self.preset_list.selectedItems()[0]
filename = item.data(QtCore.Qt.UserRole)
name = self.presets[filename]["name"]
reply = QtWidgets.QMessageBox.question(
self,
translate("Arch", "Delete Preset"),
translate(
"Arch", f"Are you sure you want to permanently delete the preset '{name}'?"
),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No,
)
if reply == QtWidgets.QMessageBox.Yes:
_delete_preset(self.mode, filename)
self._populate_list()
def _on_edit_source(self):
"""Opens the preset's JSON file in an external editor."""
item = self.preset_list.selectedItems()[0]
filename = item.data(QtCore.Qt.UserRole)
_, user_path = _get_preset_paths(self.mode)
file_path = os.path.join(user_path, filename)
if not os.path.exists(file_path):
QtWidgets.QMessageBox.critical(
self,
translate("Arch", "File Not Found"),
translate("Arch", f"Could not find the preset file at:\n{file_path}"),
)
return
# --- Use QDesktopServices for robust, cross-platform opening (Refinement #3) ---
url = QUrl.fromLocalFile(file_path)
if not QDesktopServices.openUrl(url):
QtWidgets.QMessageBox.warning(
self,
translate("Arch", "Could Not Open File"),
translate(
"Arch",
"FreeCAD could not open the file. Please check if you have a default text editor configured in your operating system.",
),
)
class NoScrollHijackComboBox(QtWidgets.QComboBox):
"""
A custom QComboBox that only processes wheel events when its popup view is visible.
This prevents it from "hijacking" the scroll wheel from a parent QScrollArea.
"""
def wheelEvent(self, event):
if self.view().isVisible():
# If the widget has focus, perform the default scrolling action.
super().wheelEvent(event)
else:
# If the popup is not visible, ignore the event. This allows
# the event to propagate to the parent widget (the scroll area).
event.ignore()
class SqlSyntaxHighlighter(QtGui.QSyntaxHighlighter):
"""
Custom QSyntaxHighlighter for SQL syntax.
"""
def __init__(self, parent_text_document):
super().__init__(parent_text_document)
# --- Define Formatting Rules ---
keyword_format = QtGui.QTextCharFormat()
keyword_format.setForeground(QtGui.QColor("#0070C0")) # Dark Blue
keyword_format.setFontWeight(QtGui.QFont.Bold)
function_format = QtGui.QTextCharFormat()
function_format.setForeground(QtGui.QColor("#800080")) # Purple
function_format.setFontItalic(True)
string_format = QtGui.QTextCharFormat()
string_format.setForeground(QtGui.QColor("#A31515")) # Dark Red
comment_format = QtGui.QTextCharFormat()
comment_format.setForeground(QtGui.QColor("#008000")) # Green
comment_format.setFontItalic(True)
# --- Build Rules List ---
self.highlighting_rules = []
if hasattr(QtCore.QRegularExpression, "PatternOption"):
# This is the PySide6/Qt6 structure
CaseInsensitiveOption = (
QtCore.QRegularExpression.PatternOption.CaseInsensitiveOption
)
else:
# This is the PySide2/Qt5 structure
CaseInsensitiveOption = QtCore.QRegularExpression.CaseInsensitiveOption
# Keywords (case-insensitive regex)
# Get the list of keywords from the SQL engine.
for word in ArchSql.getSqlKeywords():
pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
rule = {"pattern": pattern, "format": keyword_format}
self.highlighting_rules.append(rule)
# Aggregate Functions (case-insensitive regex)
functions = ["COUNT", "SUM", "MIN", "MAX"]
for word in functions:
pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
rule = {"pattern": pattern, "format": function_format}
self.highlighting_rules.append(rule)
# String Literals (single quotes)
# This regex captures everything between single quotes, allowing for escaped quotes
string_pattern = QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'")
self.highlighting_rules.append({"pattern": string_pattern, "format": string_format})
# Also support double-quoted string literals (some SQL dialects use double quotes)
double_string_pattern = QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"')
self.highlighting_rules.append(
{"pattern": double_string_pattern, "format": string_format}
)
# Single-line comments (starting with -- or #)
comment_single_line_pattern = QtCore.QRegularExpression(r"--[^\n]*|\#[^\n]*")
self.highlighting_rules.append(
{"pattern": comment_single_line_pattern, "format": comment_format}
)
# Multi-line comments (/* ... */) - requires special handling in highlightBlock
self.multi_line_comment_start_pattern = QtCore.QRegularExpression(r"/\*")
self.multi_line_comment_end_pattern = QtCore.QRegularExpression(r"\*/")
self.multi_line_comment_format = comment_format
def highlightBlock(self, text):
"""
Applies highlighting rules to the given text block.
This method is called automatically by Qt for each visible text block.
"""
# --- Part 1: Handle single-line rules ---
# Iterate over all the rules defined in the constructor
for rule in self.highlighting_rules:
pattern = rule["pattern"]
format = rule["format"]
# Get an iterator for all matches
iterator = pattern.globalMatch(text)
while iterator.hasNext():
match = iterator.next()
# Apply the format to the matched text
self.setFormat(match.capturedStart(), match.capturedLength(), format)
# --- Part 2: Handle multi-line comments (which span blocks) ---
self.setCurrentBlockState(0)
startIndex = 0
# Check if the previous block was an unclosed multi-line comment
if self.previousBlockState() != 1:
# It wasn't, so find the start of a new comment in the current line
match = self.multi_line_comment_start_pattern.match(text)
startIndex = match.capturedStart() if match.hasMatch() else -1
else:
# The previous block was an unclosed comment, so this block starts inside a comment
startIndex = 0
while startIndex >= 0:
# Find the end of the comment
end_match = self.multi_line_comment_end_pattern.match(text, startIndex)
commentLength = 0
if not end_match.hasMatch():
# The comment doesn't end in this line, so it spans the rest of the block
self.setCurrentBlockState(1)
commentLength = len(text) - startIndex
else:
# The comment ends in this line
commentLength = end_match.capturedEnd() - startIndex
self.setFormat(startIndex, commentLength, self.multi_line_comment_format)
# Look for the next multi-line comment in the same line
next_start_index = startIndex + commentLength
next_match = self.multi_line_comment_start_pattern.match(text, next_start_index)
startIndex = next_match.capturedStart() if next_match.hasMatch() else -1
class CheatsheetDialog(QtWidgets.QDialog):
"""A simple dialog to display the HTML cheatsheet."""
def __init__(self, api_data, parent=None):
super().__init__(parent)
self.setWindowTitle(translate("Arch", "BIM SQL Cheatsheet"))
self.setMinimumSize(800, 600)
layout = QtWidgets.QVBoxLayout(self)
html = self._format_as_html(api_data)
text_edit = QtWidgets.QTextEdit()
text_edit.setReadOnly(True)
text_edit.setHtml(html)
layout.addWidget(text_edit)
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
self.setLayout(layout)
def _format_as_html(self, api_data: dict) -> str:
"""
Takes the structured data from the API and builds the final HTML string.
All presentation logic and translatable strings are contained here.
"""
html = f"<h1>{translate('Arch', 'BIM SQL Cheatsheet')}</h1>"
html += f"<h2>{translate('Arch', 'Clauses')}</h2>"
html += f"<code>{', '.join(sorted(api_data.get('clauses', [])))}</code>"
html += f"<h2>{translate('Arch', 'Key Functions')}</h2>"
# Sort categories for a consistent display order
for category_name in sorted(api_data.get("functions", {}).keys()):
functions = api_data["functions"][category_name]
html += f"<b>{category_name}:</b><ul>"
# Sort functions within a category alphabetically
for func_data in sorted(functions, key=lambda x: x["name"]):
# Add a bottom margin to the list item for clear visual separation.
html += f"<li style='margin-bottom: 10px;'><code>{func_data['signature']}</code><br>{func_data['description']}" # Add the example snippet if it exists
if func_data.get("snippet"):
snippet_html = func_data["snippet"].replace("\n", "<br>")
# No <br> before the snippet. Added styling to make the snippet stand out.
html += f"<pre style='margin-top: 4px; padding: 5px; background-color: #f0f0f0; border: 1px solid #ccc;'><code>{snippet_html}</code></pre></li>"
else:
html += "</li>"
html += "</ul>"
return html
else:
# In headless mode, we don't need the GUI classes.
pass