4 changed files with 559 additions and 130 deletions
@ -0,0 +1,484 @@ |
|||||||
|
# MIT License |
||||||
|
# |
||||||
|
# Copyright (c) 2025 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com> |
||||||
|
# |
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
# of this software and associated documentation files (the "Software"), to deal |
||||||
|
# in the Software without restriction, including without limitation the rights |
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
# copies of the Software, and to permit persons to whom the Software is |
||||||
|
# furnished to do so, subject to the following conditions: |
||||||
|
# |
||||||
|
# The above copyright notice and this permission notice shall be included in all |
||||||
|
# copies or substantial portions of the Software. |
||||||
|
# |
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
# SOFTWARE. |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
__all__ = ['TTkTableProxyEdit', 'TTkTableEditLeaving', 'TTkTableProxyEditWidget'] |
||||||
|
|
||||||
|
from dataclasses import dataclass |
||||||
|
from enum import Enum, auto |
||||||
|
from typing import Union, Tuple, Type, List, Optional, Any |
||||||
|
|
||||||
|
from TermTk.TTkCore.constant import TTkK |
||||||
|
from TermTk.TTkCore.string import TTkString, TTkStringType |
||||||
|
from TermTk.TTkCore.signal import pyTTkSignal, pyTTkSlot |
||||||
|
from TermTk.TTkCore.TTkTerm.inputkey import TTkKeyEvent |
||||||
|
|
||||||
|
from TermTk.TTkWidgets.widget import TTkWidget |
||||||
|
from TermTk.TTkWidgets.texedit import TTkTextEdit, TTkTextEditView |
||||||
|
from TermTk.TTkWidgets.spinbox import TTkSpinBox |
||||||
|
from TermTk.TTkWidgets.TTkPickers.textpicker import TTkTextPicker |
||||||
|
|
||||||
|
class TTkTableEditLeaving(Enum): |
||||||
|
''' Enum indicating the direction the user is leaving the table cell editor |
||||||
|
|
||||||
|
Used by :py:class:`TTkTableProxyEditWidget` to signal navigation intent |
||||||
|
''' |
||||||
|
NONE = auto() |
||||||
|
TOP = auto() |
||||||
|
BOTTOM = auto() |
||||||
|
LEFT = auto() |
||||||
|
RIGHT = auto() |
||||||
|
|
||||||
|
|
||||||
|
class TTkTableProxyEditWidget(TTkWidget): |
||||||
|
''' Protocol for table cell editor widgets |
||||||
|
|
||||||
|
Any widget implementing these signals can be used as a table cell editor. |
||||||
|
The protocol ensures consistent behavior across different editor types. |
||||||
|
|
||||||
|
Example implementation:: |
||||||
|
|
||||||
|
class MyEditor(TTkLineEdit): |
||||||
|
__slots__ = ('leavingTriggered', 'dataChanged') |
||||||
|
|
||||||
|
def __init__(self, **kwargs): |
||||||
|
self.leavingTriggered = pyTTkSignal(TTkTableEditLeaving) |
||||||
|
self.dataChanged = pyTTkSignal(object) |
||||||
|
super().__init__(**kwargs) |
||||||
|
self.textChanged.connect(self.dataChanged.emit) |
||||||
|
''' |
||||||
|
|
||||||
|
leavingTriggered: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the user navigates out of the editor |
||||||
|
|
||||||
|
:param direction: The direction of navigation |
||||||
|
:type direction: TTkTableEditLeaving |
||||||
|
''' |
||||||
|
|
||||||
|
dataChanged: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the editor data changes |
||||||
|
|
||||||
|
:param data: The new data value |
||||||
|
:type data: object |
||||||
|
''' |
||||||
|
|
||||||
|
@staticmethod |
||||||
|
def editWidgetFactory(data: object) -> TTkTableProxyEditWidget: |
||||||
|
''' Factory method to create an editor widget from data |
||||||
|
|
||||||
|
:param data: The initial data value for the editor |
||||||
|
:type data: object |
||||||
|
:return: A new editor widget instance |
||||||
|
:rtype: TTkTableProxyEditWidget |
||||||
|
:raises NotImplementedError: Must be implemented by subclasses |
||||||
|
''' |
||||||
|
raise NotImplementedError() |
||||||
|
|
||||||
|
def getCellData(self) -> object: |
||||||
|
''' Get the current data value from the editor |
||||||
|
|
||||||
|
:return: The current cell data |
||||||
|
:rtype: object |
||||||
|
:raises NotImplementedError: Must be implemented by subclasses |
||||||
|
''' |
||||||
|
raise NotImplementedError() |
||||||
|
|
||||||
|
def proxyDispose(self) -> None: |
||||||
|
''' Clean up the editor widget and disconnect signals |
||||||
|
''' |
||||||
|
self.leavingTriggered.clear() |
||||||
|
self.dataChanged.clear() |
||||||
|
self.close() |
||||||
|
|
||||||
|
class _TextEditViewProxy(TTkTextEditView, TTkTableProxyEditWidget): |
||||||
|
''' Text editor view for table cells |
||||||
|
|
||||||
|
Extends :py:class:`TTkTextEditView` with table-specific signals |
||||||
|
for navigation and data change notification. |
||||||
|
''' |
||||||
|
__slots__ = ('leavingTriggered', 'dataChanged') |
||||||
|
|
||||||
|
leavingTriggered: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the user navigates out of the editor |
||||||
|
|
||||||
|
:param direction: The direction of navigation |
||||||
|
:type direction: TTkTableEditLeaving |
||||||
|
''' |
||||||
|
|
||||||
|
dataChanged: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the text content changes |
||||||
|
|
||||||
|
:param data: The new text value |
||||||
|
:type data: Union[str, TTkString] |
||||||
|
''' |
||||||
|
|
||||||
|
def __init__(self, **kwargs): |
||||||
|
''' Initialize the text edit view proxy |
||||||
|
|
||||||
|
:param kwargs: Additional keyword arguments passed to parent |
||||||
|
:type kwargs: dict |
||||||
|
''' |
||||||
|
self.leavingTriggered = pyTTkSignal(TTkTableEditLeaving) |
||||||
|
self.dataChanged = pyTTkSignal(object) |
||||||
|
super().__init__(**kwargs) |
||||||
|
self.textChanged.connect(self._emitDataChanged) |
||||||
|
|
||||||
|
def keyEvent(self, evt: TTkKeyEvent) -> bool: |
||||||
|
''' Handle keyboard events for navigation and data entry |
||||||
|
|
||||||
|
:param evt: The keyboard event |
||||||
|
:type evt: TTkKeyEvent |
||||||
|
:return: True if event was handled, False otherwise |
||||||
|
:rtype: bool |
||||||
|
''' |
||||||
|
if (evt.type == TTkK.SpecialKey): |
||||||
|
_cur = self.textCursor() |
||||||
|
_doc = self.document() |
||||||
|
_line = _cur.anchor().line |
||||||
|
_pos = _cur.anchor().pos |
||||||
|
_lineCount = _doc.lineCount() |
||||||
|
if evt.mod == TTkK.NoModifier: |
||||||
|
if evt.key == TTkK.Key_Enter: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.BOTTOM) |
||||||
|
return True |
||||||
|
elif evt.key == TTkK.Key_Up: |
||||||
|
if _line == 0: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.TOP) |
||||||
|
return True |
||||||
|
elif evt.key == TTkK.Key_Down: |
||||||
|
if _lineCount == 1: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.BOTTOM) |
||||||
|
return True |
||||||
|
elif evt.key == TTkK.Key_Left: |
||||||
|
if _pos == _line == 0: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.LEFT) |
||||||
|
return True |
||||||
|
elif evt.key == TTkK.Key_Right: |
||||||
|
if _lineCount == 1 and _pos == len(_doc.toPlainText()): |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.RIGHT) |
||||||
|
return True |
||||||
|
elif (evt.type == TTkK.SpecialKey and |
||||||
|
evt.mod == TTkK.ControlModifier | TTkK.AltModifier and |
||||||
|
evt.key == TTkK.Key_M): |
||||||
|
evt.mod = TTkK.NoModifier |
||||||
|
evt.key = TTkK.Key_Enter |
||||||
|
return super().keyEvent(evt) |
||||||
|
|
||||||
|
@pyTTkSlot() |
||||||
|
def _emitDataChanged(self) -> None: |
||||||
|
''' Emit dataChanged signal when text changes |
||||||
|
''' |
||||||
|
txt = self.toRawText() |
||||||
|
val = str(txt) if txt.isPlainText() else txt |
||||||
|
self.dataChanged.emit(val) |
||||||
|
|
||||||
|
class _TextEditProxy(TTkTextEdit, TTkTableProxyEditWidget): |
||||||
|
''' Text editor for table cells |
||||||
|
|
||||||
|
Extends :py:class:`TTkTextEdit` with table-specific signals |
||||||
|
for navigation and data change notification. |
||||||
|
''' |
||||||
|
__slots__ = ('leavingTriggered', 'dataChanged') |
||||||
|
|
||||||
|
leavingTriggered: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the user navigates out of the editor |
||||||
|
|
||||||
|
:param direction: The direction of navigation |
||||||
|
:type direction: TTkTableEditLeaving |
||||||
|
''' |
||||||
|
|
||||||
|
dataChanged: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the text content changes |
||||||
|
|
||||||
|
:param data: The new text value |
||||||
|
:type data: Union[str, TTkString] |
||||||
|
''' |
||||||
|
|
||||||
|
def __init__(self, **kwargs): |
||||||
|
''' Initialize the text edit proxy |
||||||
|
|
||||||
|
:param kwargs: Additional keyword arguments passed to parent |
||||||
|
:type kwargs: dict |
||||||
|
''' |
||||||
|
self.leavingTriggered = pyTTkSignal(TTkTableEditLeaving) |
||||||
|
self.dataChanged = pyTTkSignal(object) |
||||||
|
tew = _TextEditViewProxy() |
||||||
|
super().__init__(**kwargs | {'textEditView': tew}) |
||||||
|
tew.leavingTriggered.connect(self.leavingTriggered.emit) |
||||||
|
tew.dataChanged.connect(self.dataChanged.emit) |
||||||
|
|
||||||
|
@staticmethod |
||||||
|
def editWidgetFactory(data: Any) -> TTkTableProxyEditWidget: |
||||||
|
''' Factory method to create a text editor from string data |
||||||
|
|
||||||
|
:param data: The initial text value |
||||||
|
:type data: Union[str, TTkString] |
||||||
|
:return: A new text editor instance |
||||||
|
:rtype: TTkTableProxyEditWidget |
||||||
|
:raises ValueError: If data is not a string or TTkString |
||||||
|
''' |
||||||
|
if not isinstance(data, (TTkString, str)): |
||||||
|
raise ValueError(f"{data} is not a TTkStringType") |
||||||
|
te = _TextEditProxy() |
||||||
|
te.setText(data) |
||||||
|
return te |
||||||
|
|
||||||
|
def getCellData(self) -> TTkStringType: |
||||||
|
''' Get the current text value from the editor |
||||||
|
|
||||||
|
:return: The current text content |
||||||
|
:rtype: Union[str, TTkString] |
||||||
|
''' |
||||||
|
txt = self.toRawText() |
||||||
|
val = str(txt) if txt.isPlainText() else txt |
||||||
|
return val |
||||||
|
|
||||||
|
class _SpinBoxProxy(TTkSpinBox, TTkTableProxyEditWidget): |
||||||
|
''' Numeric editor for table cells |
||||||
|
|
||||||
|
Extends :py:class:`TTkSpinBox` with table-specific signals |
||||||
|
for navigation and data change notification. |
||||||
|
''' |
||||||
|
__slots__ = ('leavingTriggered', 'dataChanged') |
||||||
|
|
||||||
|
leavingTriggered: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the user navigates out of the editor |
||||||
|
|
||||||
|
:param direction: The direction of navigation |
||||||
|
:type direction: TTkTableEditLeaving |
||||||
|
''' |
||||||
|
|
||||||
|
dataChanged: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the numeric value changes |
||||||
|
|
||||||
|
:param data: The new numeric value |
||||||
|
:type data: Union[int, float] |
||||||
|
''' |
||||||
|
|
||||||
|
def __init__(self, **kwargs): |
||||||
|
''' Initialize the spin box proxy |
||||||
|
|
||||||
|
:param kwargs: Additional keyword arguments passed to parent |
||||||
|
:type kwargs: dict |
||||||
|
''' |
||||||
|
self.leavingTriggered = pyTTkSignal(TTkTableEditLeaving) |
||||||
|
self.dataChanged = pyTTkSignal(object) |
||||||
|
super().__init__(**kwargs) |
||||||
|
self.valueChanged.connect(self.dataChanged.emit) |
||||||
|
|
||||||
|
@staticmethod |
||||||
|
def editWidgetFactory(data: Any) -> TTkTableProxyEditWidget: |
||||||
|
''' Factory method to create a spin box from numeric data |
||||||
|
|
||||||
|
:param data: The initial numeric value |
||||||
|
:type data: Union[int, float] |
||||||
|
:return: A new spin box instance |
||||||
|
:rtype: TTkTableProxyEditWidget |
||||||
|
:raises ValueError: If data is not an int or float |
||||||
|
''' |
||||||
|
if not isinstance(data, (int, float)): |
||||||
|
raise ValueError(f"{data} is not a int or float") |
||||||
|
sb = _SpinBoxProxy( |
||||||
|
minimum=-1000000, |
||||||
|
maximum=1000000, |
||||||
|
value=data) |
||||||
|
return sb |
||||||
|
|
||||||
|
def getCellData(self) -> Union[float, int]: |
||||||
|
''' Get the current numeric value from the editor |
||||||
|
|
||||||
|
:return: The current spin box value |
||||||
|
:rtype: Union[int, float] |
||||||
|
''' |
||||||
|
return self.value() |
||||||
|
|
||||||
|
def keyEvent(self, evt: TTkKeyEvent) -> bool: |
||||||
|
''' Handle keyboard events for navigation |
||||||
|
|
||||||
|
:param evt: The keyboard event |
||||||
|
:type evt: TTkKeyEvent |
||||||
|
:return: True if event was handled, False otherwise |
||||||
|
:rtype: bool |
||||||
|
''' |
||||||
|
if (evt.type == TTkK.SpecialKey): |
||||||
|
if evt.mod == TTkK.NoModifier: |
||||||
|
if evt.key == TTkK.Key_Enter: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.RIGHT) |
||||||
|
return True |
||||||
|
return super().keyEvent(evt) |
||||||
|
|
||||||
|
|
||||||
|
class _TextPickerProxy(TTkTextPicker, TTkTableProxyEditWidget): |
||||||
|
''' Rich text editor for table cells |
||||||
|
|
||||||
|
Extends :py:class:`TTkTextPicker` with table-specific signals |
||||||
|
for navigation and data change notification. |
||||||
|
''' |
||||||
|
__slots__ = ('leavingTriggered', 'dataChanged') |
||||||
|
|
||||||
|
leavingTriggered: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the user navigates out of the editor |
||||||
|
|
||||||
|
:param direction: The direction of navigation |
||||||
|
:type direction: TTkTableEditLeaving |
||||||
|
''' |
||||||
|
|
||||||
|
dataChanged: pyTTkSignal |
||||||
|
''' |
||||||
|
This signal is emitted when the rich text content changes |
||||||
|
|
||||||
|
:param data: The new TTkString value |
||||||
|
:type data: TTkString |
||||||
|
''' |
||||||
|
|
||||||
|
def __init__(self, **kwargs): |
||||||
|
''' Initialize the text picker proxy |
||||||
|
|
||||||
|
:param kwargs: Additional keyword arguments passed to parent |
||||||
|
:type kwargs: dict |
||||||
|
''' |
||||||
|
self.leavingTriggered = pyTTkSignal(TTkTableEditLeaving) |
||||||
|
self.dataChanged = pyTTkSignal(object) |
||||||
|
super().__init__(**kwargs) |
||||||
|
self.textChanged.connect(self._textChanged) |
||||||
|
|
||||||
|
@pyTTkSlot() |
||||||
|
def _textChanged(self): |
||||||
|
''' Internal slot to emit dataChanged signal |
||||||
|
''' |
||||||
|
self.dataChanged.emit(self.getCellData()) |
||||||
|
|
||||||
|
@staticmethod |
||||||
|
def editWidgetFactory(data: Any) -> TTkTableProxyEditWidget: |
||||||
|
''' Factory method to create a text picker from string data |
||||||
|
|
||||||
|
:param data: The initial text value |
||||||
|
:type data: Union[str, TTkString] |
||||||
|
:return: A new text picker instance |
||||||
|
:rtype: TTkTableProxyEditWidget |
||||||
|
:raises ValueError: If data is not a string or TTkString |
||||||
|
''' |
||||||
|
if not isinstance(data, (TTkString, str)): |
||||||
|
raise ValueError(f"{data} is not a TTkStringType") |
||||||
|
te = _TextPickerProxy( |
||||||
|
text=data, |
||||||
|
autoSize=False, |
||||||
|
wrapMode=TTkK.NoWrap) |
||||||
|
return te |
||||||
|
|
||||||
|
def getCellData(self) -> TTkString: |
||||||
|
''' Get the current rich text value from the editor |
||||||
|
|
||||||
|
:return: The current TTkString content |
||||||
|
:rtype: TTkString |
||||||
|
''' |
||||||
|
return self.getTTkString() |
||||||
|
|
||||||
|
def keyEvent(self, evt: TTkKeyEvent) -> bool: |
||||||
|
''' Handle keyboard events for navigation |
||||||
|
|
||||||
|
:param evt: The keyboard event |
||||||
|
:type evt: TTkKeyEvent |
||||||
|
:return: True if event was handled, False otherwise |
||||||
|
:rtype: bool |
||||||
|
''' |
||||||
|
if (evt.type == TTkK.SpecialKey): |
||||||
|
if evt.mod == TTkK.NoModifier: |
||||||
|
if evt.key == TTkK.Key_Enter: |
||||||
|
self.leavingTriggered.emit(TTkTableEditLeaving.RIGHT) |
||||||
|
return True |
||||||
|
return super().keyEvent(evt) |
||||||
|
|
||||||
|
@dataclass |
||||||
|
class TTkProxyEditDef(): |
||||||
|
''' Definition for table cell editor proxy |
||||||
|
|
||||||
|
:param types: Tuple of data types this editor handles (e.g., (int, float)) |
||||||
|
:type types: Tuple[type, ...] |
||||||
|
:param class_def: Widget class implementing TTkTableProxyEditWidget protocol |
||||||
|
:type class_def: Type[TTkTableProxyEditWidget] |
||||||
|
:param rich: Whether this editor supports rich text formatting |
||||||
|
:type rich: bool |
||||||
|
''' |
||||||
|
types: Tuple[type, ...] |
||||||
|
class_def: Type[TTkTableProxyEditWidget] |
||||||
|
rich: bool = False |
||||||
|
|
||||||
|
class TTkTableProxyEdit(): |
||||||
|
''' Proxy class for managing table cell editors |
||||||
|
|
||||||
|
Creates and configures appropriate editor widgets based on cell data type. |
||||||
|
All editors implement the :py:class:`TTkTableProxyEditWidget` protocol. |
||||||
|
|
||||||
|
Automatically selects the correct editor type: |
||||||
|
- :py:class:`_SpinBoxProxy` for int and float values |
||||||
|
- :py:class:`_TextEditProxy` for plain text strings |
||||||
|
- :py:class:`_TextPickerProxy` for rich text (TTkString with formatting) |
||||||
|
|
||||||
|
Example usage:: |
||||||
|
|
||||||
|
proxy = TTkTableProxyEdit() |
||||||
|
editor = proxy.getProxyWidget(data=42, rich=False) |
||||||
|
if editor: |
||||||
|
editor.leavingTriggered.connect(handleNavigation) |
||||||
|
editor.dataChanged.connect(handleDataChange) |
||||||
|
''' |
||||||
|
__slots__ = ('_proxies',) |
||||||
|
_proxies: List[TTkProxyEditDef] |
||||||
|
|
||||||
|
def __init__(self): |
||||||
|
''' Initialize the table proxy edit manager |
||||||
|
''' |
||||||
|
self._proxies = [ |
||||||
|
TTkProxyEditDef(class_def=_SpinBoxProxy, types=(int, float)), |
||||||
|
TTkProxyEditDef(class_def=_TextEditProxy, types=(str, TTkString)), |
||||||
|
TTkProxyEditDef(class_def=_TextEditProxy, types=(str,), rich=True), |
||||||
|
TTkProxyEditDef(class_def=_TextPickerProxy, types=(TTkString,), rich=True), |
||||||
|
] |
||||||
|
|
||||||
|
def getProxyWidget(self, data, rich: bool = False) -> Optional[TTkTableProxyEditWidget]: |
||||||
|
''' Get an appropriate editor widget for the given data |
||||||
|
|
||||||
|
:param data: The data value to edit |
||||||
|
:type data: object |
||||||
|
:param rich: Whether rich text editing is required |
||||||
|
:type rich: bool |
||||||
|
:return: An editor widget instance, or None if no suitable editor found |
||||||
|
:rtype: Optional[TTkTableProxyEditWidget] |
||||||
|
''' |
||||||
|
for proxy in self._proxies: |
||||||
|
if proxy.rich == rich and isinstance(data, proxy.types): |
||||||
|
return proxy.class_def.editWidgetFactory(data) |
||||||
|
for proxy in self._proxies: |
||||||
|
if isinstance(data, proxy.types): |
||||||
|
return proxy.class_def.editWidgetFactory(data) |
||||||
|
return None |
||||||
Loading…
Reference in new issue