You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

480 lines
17 KiB

#!/usr/bin/env python3
"""
Command Table Widget - Reusable
================================
Auto-detecting command table with CRUD operations.
Features:
- Auto-detect columns from database
- Search + filter
- Add/Edit/Delete commands
- Gray out when disconnected
- Double-click to execute
- Works for both UART and I2C
Author: Kynsight
Version: 1.0.0
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QLineEdit, QLabel, QComboBox, QHeaderView,
QAbstractItemView, QDialog, QFormLayout, QDialogButtonBox,
QMessageBox, QTextEdit
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QColor
class CommandDialog(QDialog):
"""Dialog for adding/editing commands."""
def __init__(self, command_type, all_columns, values=None, parent=None):
super().__init__(parent)
self.command_type = command_type
self.all_columns = all_columns
self.fields = {}
self.setWindowTitle("Add Command" if values is None else "Edit Command")
self.setModal(True)
layout = QVBoxLayout()
self.setLayout(layout)
# Form
form_layout = QFormLayout()
for col in all_columns:
if col == 'command_id':
continue # Skip ID
# Create appropriate widget
if col == 'description':
widget = QTextEdit()
widget.setMaximumHeight(80)
if values and col in values:
widget.setPlainText(str(values[col]))
else:
widget = QLineEdit()
if values and col in values:
widget.setText(str(values[col]))
self.fields[col] = widget
form_layout.addRow(col.replace('_', ' ').title() + ":", widget)
layout.addLayout(form_layout)
# Buttons
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def get_values(self):
"""Get values from form."""
values = {}
for col, widget in self.fields.items():
if isinstance(widget, QTextEdit):
values[col] = widget.toPlainText()
else:
values[col] = widget.text()
return values
class CommandTableWidget(QWidget):
"""
Reusable command table with auto-detection.
Signals:
command_selected: (command_id, command_data)
command_double_clicked: (command_id, command_data)
"""
command_selected = pyqtSignal(int, dict) # command_id, all column data
command_double_clicked = pyqtSignal(int, dict)
def __init__(self, db_connection, command_type='uart', visible_columns=None, parent=None):
"""
Args:
db_connection: SQLite connection
command_type: 'uart' or 'i2c'
visible_columns: List of columns to display (None = all)
"""
super().__init__(parent)
self.conn = db_connection
self.command_type = command_type
self.table_name = f"{command_type}_commands"
self.all_commands = []
self.all_columns = [] # All columns from DB
self.visible_columns = visible_columns # Columns to show
self.filter_column = None # For category/operation filter
self._init_ui()
self._detect_columns()
self._load_commands()
def _init_ui(self):
"""Initialize UI."""
layout = QVBoxLayout()
self.setLayout(layout)
# Search and filter
filter_layout = QHBoxLayout()
self.txt_search = QLineEdit()
self.txt_search.setPlaceholderText("Search commands...")
self.txt_search.textChanged.connect(self._filter_table)
filter_layout.addWidget(self.txt_search, 2)
self.combo_filter = QComboBox()
self.combo_filter.currentTextChanged.connect(self._filter_table)
filter_layout.addWidget(self.combo_filter, 1)
layout.addLayout(filter_layout)
# Table
self.table = QTableWidget()
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setAlternatingRowColors(True)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.itemSelectionChanged.connect(self._on_selection_changed)
self.table.cellDoubleClicked.connect(self._on_double_click)
layout.addWidget(self.table)
# CRUD buttons
btn_layout = QHBoxLayout()
self.btn_add = QPushButton("+ Add")
self.btn_add.clicked.connect(self._add_command)
btn_layout.addWidget(self.btn_add)
self.btn_edit = QPushButton("✎ Edit")
self.btn_edit.setEnabled(False)
self.btn_edit.clicked.connect(self._edit_command)
btn_layout.addWidget(self.btn_edit)
self.btn_delete = QPushButton("🗑 Delete")
self.btn_delete.setEnabled(False)
self.btn_delete.clicked.connect(self._delete_command)
btn_layout.addWidget(self.btn_delete)
btn_layout.addStretch()
self.lbl_count = QLabel()
btn_layout.addWidget(self.lbl_count)
layout.addLayout(btn_layout)
def _detect_columns(self):
"""Auto-detect columns from database."""
cursor = self.conn.execute(f"PRAGMA table_info({self.table_name})")
self.all_columns = [row[1] for row in cursor.fetchall()]
# If no visible_columns specified, show all
if self.visible_columns is None:
self.visible_columns = self.all_columns
# Setup table columns (only visible ones)
self.table.setColumnCount(len(self.visible_columns))
self.table.setHorizontalHeaderLabels([
col.replace('_', ' ').title() for col in self.visible_columns
])
# Make ALL columns user-resizable (Interactive mode)
header = self.table.horizontalHeader()
for i in range(len(self.visible_columns)):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive)
# Detect filter column (category for UART, operation for I2C)
if 'category' in self.all_columns:
self.filter_column = 'category'
self.combo_filter.addItem("All Categories")
elif 'operation' in self.all_columns:
self.filter_column = 'operation'
self.combo_filter.addItem("All Operations")
else:
self.combo_filter.setVisible(False)
def _load_commands(self):
"""Load commands from database."""
cursor = self.conn.execute(f"""
SELECT * FROM {self.table_name}
WHERE is_active = 1
ORDER BY {self.all_columns[1]}
""")
self.all_commands = []
for row in cursor.fetchall():
cmd_dict = {self.all_columns[i]: row[i] for i in range(len(self.all_columns))}
self.all_commands.append(cmd_dict)
# Populate filter dropdown
if self.filter_column:
values = sorted(set(cmd[self.filter_column] for cmd in self.all_commands
if cmd[self.filter_column]))
self.combo_filter.clear()
self.combo_filter.addItem(f"All {self.filter_column.title()}s")
self.combo_filter.addItems(values)
self._display_commands(self.all_commands)
def _display_commands(self, commands):
"""Display commands in table (only visible columns)."""
self.table.setRowCount(len(commands))
for row_idx, cmd in enumerate(commands):
for col_idx, col_name in enumerate(self.visible_columns):
value = cmd.get(col_name, '')
item = QTableWidgetItem(str(value) if value is not None else '')
# Center-align ID
if col_name == 'command_id':
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row_idx, col_idx, item)
# Color by category/operation (dark theme compatible)
color = self._get_row_color(cmd)
if color:
item.setBackground(color)
self.lbl_count.setText(f"Count: {len(commands)}")
def _get_row_color(self, cmd):
"""Get row color based on category/operation (dark theme compatible)."""
if self.command_type == 'uart':
colors = {
"Door": QColor(70, 130, 180), # Steel blue
"Action": QColor(255, 165, 0), # Orange
"Error": QColor(220, 20, 60), # Crimson
"Status": QColor(60, 179, 113), # Medium sea green
"Motor": QColor(218, 165, 32), # Goldenrod
"Sensor": QColor(147, 112, 219), # Medium purple
"Power": QColor(138, 43, 226), # Blue violet
"Info": QColor(100, 149, 237), # Cornflower blue
}
return colors.get(cmd.get('category'))
else: # i2c
operation = cmd.get('operation')
if operation == 'read':
return QColor(60, 179, 113) # Medium sea green
elif operation == 'write':
return QColor(255, 140, 0) # Dark orange
return None
def _filter_table(self):
"""Filter table by search and dropdown."""
search_text = self.txt_search.text().lower()
filter_value = self.combo_filter.currentText()
# Filter commands
filtered = []
for cmd in self.all_commands:
# Filter by dropdown
if self.filter_column and not filter_value.startswith("All"):
if cmd.get(self.filter_column) != filter_value:
continue
# Filter by search text
if search_text:
found = False
for col_name, value in cmd.items():
if search_text in str(value).lower():
found = True
break
if not found:
continue
filtered.append(cmd)
self._display_commands(filtered)
def _on_selection_changed(self):
"""Handle selection change."""
selected = self.table.selectedItems()
has_selection = bool(selected)
# Only auto-enable edit/delete if they're not externally disabled
# (i.e., if Add button is enabled, we're in edit mode)
if self.btn_add.isEnabled():
self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection)
if has_selection:
row = selected[0].row()
# Get command_id from visible columns
cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0
command_id = int(self.table.item(row, cmd_id_col_idx).text())
# Get ALL column data (from all_commands, not just visible)
cmd_data = next((cmd for cmd in self.all_commands
if cmd['command_id'] == command_id), {})
# Emit signal
self.command_selected.emit(command_id, cmd_data)
def _on_double_click(self, row, col):
"""Handle double-click."""
# Get command_id from visible columns
cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0
command_id = int(self.table.item(row, cmd_id_col_idx).text())
# Get ALL column data
cmd_data = next((cmd for cmd in self.all_commands
if cmd['command_id'] == command_id), {})
# Emit signal
self.command_double_clicked.emit(command_id, cmd_data)
def _add_command(self):
"""Add new command."""
dialog = CommandDialog(self.command_type, self.all_columns, parent=self)
if dialog.exec() == QDialog.DialogCode.Accepted:
values = dialog.get_values()
# Build INSERT query
cols = [col for col in self.all_columns if col != 'command_id']
placeholders = ', '.join(['?'] * len(cols))
col_names = ', '.join(cols)
try:
self.conn.execute(f"""
INSERT INTO {self.table_name} ({col_names})
VALUES ({placeholders})
""", [values.get(col, '') for col in cols])
self.conn.commit()
self._load_commands()
QMessageBox.information(self, "Success", "Command added successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to add command:\n{e}")
def _edit_command(self):
"""Edit selected command."""
selected = self.table.selectedItems()
if not selected:
return
row = selected[0].row()
# Get command_id from visible columns
cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0
command_id = int(self.table.item(row, cmd_id_col_idx).text())
# Get current values (all columns)
current_values = next((cmd for cmd in self.all_commands
if cmd['command_id'] == command_id), {})
dialog = CommandDialog(self.command_type, self.all_columns,
current_values, parent=self)
if dialog.exec() == QDialog.DialogCode.Accepted:
values = dialog.get_values()
# Build UPDATE query
set_clause = ', '.join([f"{col} = ?" for col in values.keys()])
try:
self.conn.execute(f"""
UPDATE {self.table_name}
SET {set_clause}
WHERE command_id = ?
""", list(values.values()) + [command_id])
self.conn.commit()
self._load_commands()
QMessageBox.information(self, "Success", "Command updated successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update command:\n{e}")
def _delete_command(self):
"""Delete selected command."""
selected = self.table.selectedItems()
if not selected:
return
row = selected[0].row()
# Get command_id and name
cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0
cmd_name_col_idx = self.visible_columns.index('command_name') if 'command_name' in self.visible_columns else 1
command_id = int(self.table.item(row, cmd_id_col_idx).text())
name = self.table.item(row, cmd_name_col_idx).text()
reply = QMessageBox.question(
self,
"Delete Command",
f"Delete command '{name}'?\n\nThis will mark it as inactive.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
# Soft delete (mark as inactive)
self.conn.execute(f"""
UPDATE {self.table_name}
SET is_active = 0
WHERE command_id = ?
""", (command_id,))
self.conn.commit()
self._load_commands()
QMessageBox.information(self, "Success", "Command deleted successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete command:\n{e}")
def set_enabled_state(self, enabled: bool):
"""Enable/disable table (gray out when disconnected)."""
self.table.setEnabled(enabled)
self.txt_search.setEnabled(enabled)
self.combo_filter.setEnabled(enabled)
# Keep CRUD buttons always enabled
def get_selected_command_id(self):
"""Get selected command ID."""
selected = self.table.selectedItems()
if selected:
return int(self.table.item(selected[0].row(), 0).text())
return None
def refresh(self):
"""Reload commands from database."""
self._load_commands()
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
import sqlite3
app = QApplication(sys.argv)
conn = sqlite3.connect("./database/ehinge.db")
widget = CommandTableWidget(conn, 'uart')
widget.setWindowTitle("Command Table - UART")
widget.resize(900, 600)
widget.show()
sys.exit(app.exec())