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.
 
 
 
 
 

404 lines
15 KiB

# 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.
__all__ = []
from typing import Optional, List, Tuple
from TermTk.TTkCore.TTkTerm.inputkey import TTkKeyEvent
from TermTk.TTkCore.TTkTerm.inputkey import TTkKeyEvent
from TermTk.TTkCore.constant import TTkK
from TermTk.TTkCore.shortcut import TTkShortcut
from TermTk.TTkWidgets.container import TTkContainer
from TermTk.TTkWidgets.widget import TTkWidget
def _absPos(widget: TTkWidget) -> Tuple[int,int]:
wx, wy = 0,0
layout = widget.widgetItem()
while layout:
px, py = layout.pos()
ox, oy = layout.offset()
wx, wy = wx+px+ox, wy+py+oy
layout = layout.parent()
return (wx, wy)
class _TTkOverlay():
__slots__ = ('_widget','_prevFocus','_modal')
_widget:TTkWidget
_prevFocus:Optional[TTkWidget]
_modal:bool
def __init__(
self,
pos:Tuple[int,int],
widget:TTkWidget,
prevFocus:Optional[TTkWidget],
modal:bool):
self._widget = widget
self._prevFocus = prevFocus
self._modal = modal
widget.move(*pos)
class _TTkRootContainer(TTkContainer):
''' _TTkRootContainer:
Internal root container class that manages the application's root widget hierarchy and focus navigation.
This class is not meant to be used directly by application code. It is instantiated internally by :py:class:`TTk`
to provide the top-level container for all widgets and handle keyboard-based focus traversal.
The root container manages focus cycling when Tab/Shift+Tab or arrow keys are pressed and no widget
consumes the event, ensuring focus loops back to the first/last focusable widget.
'''
__slots__ = (
'_pendingMouseReleaseWidget',
'_focusWidget',
'_overlay')
_pendingMouseReleaseWidget:Optional[TTkWidget]
_focusWidget:Optional[TTkWidget]
_overlay:List[_TTkOverlay]
def __init__(self, **kwargs) -> None:
self._pendingMouseReleaseWidget = None
self._focusWidget = None
self._overlay = []
super().__init__(**kwargs)
def _getPendingMouseReleaseWidget(self) -> Optional[TTkWidget]:
return self._pendingMouseReleaseWidget
def _setPendingMouseReleaseWidget(self, widget:Optional[TTkWidget]) -> None:
if self._pendingMouseReleaseWidget is widget:
return
self._pendingMouseReleaseWidget = widget
def _getFocusWidget(self) -> Optional[TTkWidget]:
'''
Returns the currently focused widget.
:return: the widget with focus, or None if no widget has focus
:rtype: :py:class:`TTkWidget` or None
'''
return self._focusWidget
def _setFocusWidget(self, widget:Optional[TTkWidget]) -> None:
'''
Sets the currently focused widget and triggers a repaint.
:param widget: the widget to receive focus, or None to clear focus
:type widget: :py:class:`TTkWidget` or None
'''
if self._focusWidget is widget:
return
self._focusWidget = widget
self.update()
def _loopFocus(self, container:TTkContainer, evt:TTkKeyEvent) -> bool:
if ( (evt.key == TTkK.Key_Tab and evt.mod == TTkK.NoModifier) or
(evt.key in (TTkK.Key_Right, TTkK.Key_Down ) ) ) :
if _nfw:=container._getFirstFocus(widget=None,focusPolicy=TTkK.FocusPolicy.TabFocus):
_nfw.setFocus()
return True
if ( (evt.key == TTkK.Key_Tab and evt.mod == TTkK.ShiftModifier) or
(evt.key in ( TTkK.Key_Left, TTkK.Key_Up ) ) ) :
if _pfw:=container._getLastFocus(widget=None,focusPolicy=TTkK.FocusPolicy.TabFocus):
_pfw.setFocus()
return True
return False
def _handleOverlay(self, evt:TTkKeyEvent) -> bool:
if not self._overlay:
return False
_overlay = self._overlay[-1]
_widget = _overlay._widget
if _widget.keyEvent(evt=evt):
return True
if isinstance(_widget, TTkContainer) and self._loopFocus(evt=evt, container=_widget):
return True
def overlay(self,
caller: Optional[TTkWidget],
widget: TTkWidget,
pos:Tuple[int,int],
modal:bool=False,
forceBoundaries:bool=True,
toolWindow:bool=False) -> None:
'''
Adds a widget as an overlay on top of the current widget hierarchy.
The overlay widget is positioned relative to the caller widget and automatically
adjusted to stay within the root container boundaries (if forceBoundaries is True).
Overlays can be modal (blocking interaction with underlying widgets) or non-modal.
:param caller: the widget relative to which the overlay is positioned, or None to use root
:type caller: :py:class:`TTkWidget` or None
:param widget: the widget to display as an overlay
:type widget: :py:class:`TTkWidget`
:param pos: the (x, y) position offset relative to the caller widget
:type pos: tuple[int, int]
:param modal: if True, blocks interaction with underlying widgets
:type modal: bool
:param forceBoundaries: if True, adjusts position and size to keep overlay within root boundaries
:type forceBoundaries: bool
:param toolWindow: if True, treats the overlay as a tool window without focus management
:type toolWindow: bool
'''
if not caller:
caller = self
x,y = pos
wx, wy = _absPos(caller)
w,h = widget.size()
rw,rh = self.rootLayout().size()
# Try to keep the overlay widget inside the rootContainer boundaries
if forceBoundaries:
wx = max(0, wx+x if wx+x+w < rw else rw-w )
wy = max(0, wy+y if wy+y+h < rh else rh-h )
mw,mh = widget.minimumSize()
ww = min(w,max(mw, rw))
wh = min(h,max(mh, rh))
widget.resize(ww,wh)
else:
wx += x
wy += y
wi = widget.widgetItem()
# Forcing the layer to:
# TTkLayoutItem.LAYER1 = 0x40000000
wi.setLayer(wi.LAYER1)
if toolWindow:
widget.move(wx,wy)
else:
_fw = self._getFocusWidget()
self._overlay.append(_TTkOverlay(
pos=(wx,wy),
widget=widget,
prevFocus=_fw,
modal=modal))
self.rootLayout().addWidget(widget)
widget.raiseWidget()
widget.setFocus()
if isinstance(widget, TTkContainer):
for w in widget.rootLayout().iterWidgets(onlyVisible=True):
w.update()
def _removeOverlay(self) -> None:
'''
Removes the topmost overlay widget and restores focus to the previous widget.
If the overlay is modal, it must be explicitly removed before any underlying
overlays can be removed. Focus is restored to the widget that had focus before
the overlay was added.
'''
if not self._overlay:
return
bkFocus = None
# Remove the first element also if it is modal
self._overlay[-1]._modal = False
while self._overlay:
if self._overlay[-1]._modal:
break
owidget = self._overlay.pop()
bkFocus = owidget._prevFocus
self.rootLayout().removeWidget(owidget._widget)
if _fw:=self._getFocusWidget():
_fw.clearFocus()
if bkFocus:
bkFocus.setFocus()
def _removeOverlayAndChild(self, widget: Optional[TTkWidget]) -> None:
'''
Removes the specified overlay widget and all overlays added after it.
This method finds the root overlay containing the given widget and removes it
along with any child overlays that were added on top of it. Focus is restored
to the widget that had focus before the overlay stack was created.
:param widget: the widget whose root overlay (and children) should be removed
:type widget: :py:class:`TTkWidget` or None
'''
if not widget:
return
if not self._isOverlay(widget):
return
if len(self._overlay) <= 1:
return self._removeOverlay()
rootWidget = self._rootOverlay(widget)
bkFocus = None
found = False
newOverlay = []
for o in self._overlay:
if o._widget == rootWidget:
found = True
bkFocus = o._prevFocus
if not found:
newOverlay.append(o)
else:
self.rootLayout().removeWidget(o._widget)
self._overlay = newOverlay
if bkFocus:
bkFocus.setFocus()
if not found:
self._removeOverlay()
def _removeOverlayChild(self, widget: TTkWidget) -> None:
'''
Removes all overlay widgets that were added after the specified widget.
This method preserves the overlay containing the given widget but removes
all overlays that were added on top of it. If the widget is not found in
the overlay stack, removes the topmost overlay.
:param widget: the widget whose child overlays should be removed
:type widget: :py:class:`TTkWidget`
'''
rootWidget = self._rootOverlay(widget)
found = False
newOverlay = []
for o in self._overlay:
if o._widget == rootWidget:
found = True
newOverlay.append(o)
continue
if not found:
newOverlay.append(o)
else:
self.rootLayout().removeWidget(o._widget)
self._overlay = newOverlay
if not found:
self._removeOverlay()
def _focusLastModal(self) -> None:
'''
Sets focus to the last modal overlay widget in the stack.
This method is used internally to ensure modal overlays maintain focus
when interaction is attempted with underlying widgets.
'''
if modal := self._getLastModal():
modal._widget.setFocus()
def _checkModalOverlay(self, widget: TTkWidget) -> bool:
'''
Checks if a widget is allowed to receive input given the current modal overlay state.
:param widget: the widget to check for input permission
:type widget: :py:class:`TTkWidget`
:return: True if the widget can receive input, False if blocked by a modal overlay
:rtype: bool
'''
#if not TTkHelper._overlay:
# # There are no Overlays
# return True
if not (lastModal := self._getLastModal()):
return True
# if not TTkHelper._overlay[-1]._modal:
# # The last window is not modal
# return True
if not (rootWidget := self._rootOverlay(widget)):
# This widget is not overlay
return False
if rootWidget in [ o._widget for o in self._overlay[self._overlay.index(lastModal):]]:
return True
# if TTkHelper._overlay[-1]._widget == rootWidget:
# return True
return False
def _getLastModal(self) -> Optional[_TTkOverlay]:
'''
Returns the last modal overlay in the stack.
:return: the last modal overlay wrapper, or None if no modal overlays exist
:rtype: :py:class:`_TTkOverlay` or None
'''
modal = None
for o in self._overlay:
if o._modal:
modal = o
return modal
def _isOverlay(self, widget: Optional[TTkWidget]) -> bool:
'''
Checks if a widget is part of the overlay hierarchy.
:param widget: the widget to check
:type widget: :py:class:`TTkWidget` or None
:return: True if the widget is contained in any overlay, False otherwise
:rtype: bool
'''
return self._rootOverlay(widget) is not None
def _rootOverlay(self, widget: Optional[TTkWidget]) -> Optional[TTkWidget]:
'''
Finds the root overlay widget that contains the specified widget.
Traverses the widget's parent hierarchy to find which overlay (if any)
contains it as a child.
:param widget: the widget to search for in the overlay hierarchy
:type widget: :py:class:`TTkWidget` or None
:return: the root overlay widget containing the specified widget, or None if not in any overlay
:rtype: :py:class:`TTkWidget` or None
'''
if not widget:
return None
if not self._overlay:
return None
overlayWidgets = [o._widget for o in self._overlay]
while widget is not None:
if widget in overlayWidgets:
return widget
widget = widget.parentWidget()
return None
def keyEvent(self, evt:TTkKeyEvent) -> bool:
'''
Handles keyboard events for focus navigation.
Implements focus cycling behavior when Tab/Shift+Tab or arrow keys are pressed
and no child widget consumes the event. When the last focusable widget is reached,
focus cycles back to the first widget (and vice versa).
:param evt: the keyboard event
:type evt: :py:class:`TTkKeyEvent`
:return: True if the event was handled, False otherwise
:rtype: bool
'''
if self._handleOverlay(evt=evt):
return True
if super().keyEvent(evt=evt):
return True
# If this is reached after a tab focus event, it means that either
# no focus widgets are defined
# or the last/first focus is reached - the focus need to go to start from the opposite side
return self._loopFocus(evt=evt, container=self)