From 19ab5df32cfeb80c575e7c8a2b64685b4c80a9e6 Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Mon, 2 Mar 2026 09:38:43 +0000 Subject: [PATCH] chore: responsive tab close button (#609) --- apps/ttkode/ttkode/app/ttkode.py | 18 ++++- demo/showcase/tab.py | 6 ++ libs/pyTermTk/TermTk/TTkCore/ttk.py | 20 ++--- libs/pyTermTk/TermTk/TTkWidgets/container.py | 10 +++ .../TermTk/TTkWidgets/rootcontainer.py | 11 +++ libs/pyTermTk/TermTk/TTkWidgets/tabwidget.py | 80 ++++++++++++++----- libs/pyTermTk/TermTk/TTkWidgets/widget.py | 13 +-- 7 files changed, 117 insertions(+), 41 deletions(-) diff --git a/apps/ttkode/ttkode/app/ttkode.py b/apps/ttkode/ttkode/app/ttkode.py index b96c9227..27d11649 100644 --- a/apps/ttkode/ttkode/app/ttkode.py +++ b/apps/ttkode/ttkode/app/ttkode.py @@ -75,9 +75,23 @@ class _TextDocument(ttk.TextDocumentHighlight): def getTabButtonStyle(self) -> dict: if self._changedStatus: - return {'default':{'closeGlyph':' ● '}} + return { + 'default':{ + 'closeGlyph': { + 'default':' ● ', + 'hovered':' ● ' + } + } + } else: - return {'default':{'closeGlyph':' □ '}} + return { + 'default':{ + 'closeGlyph': { + 'default':' ○ ', + 'hovered':' ○ ' + } + } + } def _handleContentChanged(self) -> None: '''A signal is emitted when the file status change, marking it as modified or not''' diff --git a/demo/showcase/tab.py b/demo/showcase/tab.py index 6f893d9a..da7064f1 100755 --- a/demo/showcase/tab.py +++ b/demo/showcase/tab.py @@ -53,6 +53,12 @@ def demoTab(root=None, border=True): tabWidget1.addMenu("ZZ", ttk.TTkK.RIGHT) tabWidget1.addMenu("KK", ttk.TTkK.RIGHT) + @ttk.pyTTkSlot(int) + def _reportClose(num:int): + tabWidget1.removeTab(num) + + tabWidget1.tabCloseRequested.connect(_reportClose) + return tabWidget1 def main(): diff --git a/libs/pyTermTk/TermTk/TTkCore/ttk.py b/libs/pyTermTk/TermTk/TTkCore/ttk.py index 479b0385..c48f4243 100644 --- a/libs/pyTermTk/TermTk/TTkCore/ttk.py +++ b/libs/pyTermTk/TermTk/TTkCore/ttk.py @@ -320,20 +320,15 @@ class TTk(_TTkRootContainer): # - Drag # - Release focusWidget = self._getFocusWidget() - if ( focusWidget is not None and - ( mevt.evt == TTkK.Drag or - mevt.evt == TTkK.Release ) and - not TTkHelper.isDnD() ) : - x,y = TTkHelper.absPos(focusWidget) + pendingReleaseWidget = self._getPendingMouseReleaseWidget() + + if ( pendingReleaseWidget is not None and + mevt.evt in (TTkK.Drag,TTkK.Release) and + not TTkHelper.isDnD() ) : + x,y = TTkHelper.absPos(pendingReleaseWidget) nmevt = mevt.clone(pos=(mevt.x-x, mevt.y-y)) - focusWidget.mouseEvent(nmevt) + pendingReleaseWidget.mouseEvent(nmevt) else: - # Sometimes the release event is not retrieved - if ( focusWidget and - focusWidget._pendingMouseRelease and - not TTkHelper.isDnD() ): - focusWidget.mouseEvent(mevt.clone(evt=TTkK.Release)) - focusWidget._pendingMouseRelease = False # Adding this Crappy logic to handle a corner case in the drop routine # where the mouse is leaving any widget able to handle the drop event if not self.mouseEvent(mevt): @@ -347,6 +342,7 @@ class TTk(_TTkRootContainer): # Clean the Drag and Drop in case of mouse release if mevt.evt == TTkK.Release: TTkHelper.dndEnd() + self._setPendingMouseReleaseWidget(None) def _time_event(self): # Event.{wait and clear} should be atomic, diff --git a/libs/pyTermTk/TermTk/TTkWidgets/container.py b/libs/pyTermTk/TermTk/TTkWidgets/container.py index 3873436b..138a8330 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/container.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/container.py @@ -155,6 +155,16 @@ class TTkContainer(TTkWidget): self._layout.setParent(self) self.update(updateLayout=True) + def _getPendingMouseReleaseWidget(self) -> Optional[TTkWidget]: + if (_pw:=self.parentWidget()): + return _pw._getPendingMouseReleaseWidget() + return None + + def _setPendingMouseReleaseWidget(self, widget:Optional[TTkWidget]) -> None: + if not (_pw:=self.parentWidget()): + return + _pw._setPendingMouseReleaseWidget(widget) + def _getFocusWidget(self) -> Optional[TTkWidget]: if (_pw:=self.parentWidget()): return _pw._getFocusWidget() diff --git a/libs/pyTermTk/TermTk/TTkWidgets/rootcontainer.py b/libs/pyTermTk/TermTk/TTkWidgets/rootcontainer.py index b333cb33..6adcd47e 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/rootcontainer.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/rootcontainer.py @@ -71,17 +71,28 @@ class _TTkRootContainer(TTkContainer): 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. diff --git a/libs/pyTermTk/TermTk/TTkWidgets/tabwidget.py b/libs/pyTermTk/TermTk/TTkWidgets/tabwidget.py index 08cc1acb..b60f6d8e 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/tabwidget.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/tabwidget.py @@ -172,6 +172,10 @@ _tabStyle:Dict[str,Any] = { 'default': {'color': TTkColor.fgbg("#dddd88","#000044"), 'bgColor': TTkColor.fgbg("#000000","#8888aa"), 'borderColor': TTkColor.RST, + 'closeColor': { + 'default' : TTkColor.CYAN, + 'hovered' : TTkColor.YELLOW + }, 'borderHighlightColors': { 'main' : TTkColor.fg('#00FFFF'), 'fade' : TTkColor.fg('#88FF88'), @@ -262,10 +266,6 @@ class _TTkTabColorButton(TTkWidget): self._tabStatus = tabStatus super().__init__(**kwargs) - def mouseReleaseEvent(self, evt:TTkMouseEvent) -> bool: - self.tcbClicked.emit(self) - return True - def keyEvent(self, evt:TTkKeyEvent) -> bool: if ( evt.type == TTkK.Character and evt.key==" " ) or \ ( evt.type == TTkK.SpecialKey and evt.key == TTkK.Key_Enter ): @@ -280,17 +280,31 @@ class TTkTabButton(_TTkTabColorButton): classStyle = ( _TTkTabColorButton.classStyle | { 'default': _TTkTabColorButton.classStyle['default'] | - {'closeGlyph':' □ '} , + { + 'closeGlyph': { + 'default':' ○ ', + 'hovered':' ○ ' + } + } , 'hover': _TTkTabColorButton.classStyle['hover'] | - {'closeGlyph':' x '} } ) + { + 'closeGlyph':{ + 'default':' □ ', + 'hovered':' ▣ ' + } + } + } + ) '''TTkTabButton''' __slots__ = ( - '_data','_sideEnd', '_buttonStatus', '_closable', + '_data','_sideEnd', '_buttonStatus', '_closable', '_closeHovered', 'closeClicked', '_closeButtonPressed', '_text') + _closeHovered:bool + def __init__(self, *, - text:TTkString='', + text:TTkStringType='', data:object=None, closable:bool=False, **kwargs) -> None: @@ -299,6 +313,7 @@ class TTkTabButton(_TTkTabColorButton): self._buttonStatus = TTkK.Unchecked self._data = data self._closable = closable + self._closeHovered = False self.closeClicked = pyTTkSignal() super().__init__(**kwargs) self._closeButtonPressed = False @@ -308,7 +323,7 @@ class TTkTabButton(_TTkTabColorButton): style = self.currentStyle() size = self.text().termWidth() + 2 if self._closable: - size += len(style['closeGlyph']) + size += len(style['closeGlyph']['default']) self.resize(size, self._tabStatus.barType.vSize()) self.setMinimumSize(size, self._tabStatus.barType.vSize()) self.setMaximumSize(size, self._tabStatus.barType.vSize()) @@ -344,25 +359,42 @@ class TTkTabButton(_TTkTabColorButton): x,y = evt.x,evt.y w,h = self.size() self._closeButtonPressed = False - if self._closable and evt.key == TTkK.MidButton: - self.closeClicked.emit() - return True offY = self._tabStatus.barType.offY() if self._closable and y == offY and w-4<=x bool: x,y = evt.x,evt.y w,h = self.size() offY = self._tabStatus.barType.offY() - if self._closable and y == offY and w-4<=x bool: drag = TTkDrag() @@ -390,7 +422,7 @@ class TTkTabButton(_TTkTabColorButton): borderColor:TTkColor = style['borderColor'] textColor:TTkColor = style['color'] - borderHighlightColors:TTkColor = style['borderHighlightColors'] + borderHighlightColors:Dict[str, TTkColor] = style['borderHighlightColors'] w,h = self.size() offY = self._tabStatus.barType.offY() @@ -543,9 +575,14 @@ class TTkTabButton(_TTkTabColorButton): canvas.drawText(pos=(1,offY), text=self.text(), color=textColor) if self._closable: - closeGlyph = style['closeGlyph'] + if self._closeHovered: + colorCloseHovered = textColor+style['closeColor']['hovered'] + closeGlyph = style['closeGlyph']['hovered'] + else: + colorCloseHovered = textColor+style['closeColor']['default'] + closeGlyph = style['closeGlyph']['default'] closeOff = len(closeGlyph) - canvas.drawText(pos=(w-closeOff-1,offY), text=closeGlyph, color=textColor) + canvas.drawText(pos=(w-closeOff-1,offY), text=closeGlyph, color=colorCloseHovered) class _TTkTabMenuButton(TTkMenuBarButton): def paintEvent(self, canvas: TTkCanvas) -> None: @@ -580,7 +617,8 @@ class _TTkTabScrollerButton(_TTkTabColorButton): # This is a hack to force the action aftet the keypress # And not key release as normally happen to the button def mousePressEvent(self, evt:TTkMouseEvent) -> bool: - return super().mouseReleaseEvent(evt) + self.tcbClicked.emit(self) + return True def mouseReleaseEvent(self, evt:TTkMouseEvent) -> bool: return False def mouseTapEvent(self, evt:TTkMouseEvent) -> bool: diff --git a/libs/pyTermTk/TermTk/TTkWidgets/widget.py b/libs/pyTermTk/TermTk/TTkWidgets/widget.py index d3868fc4..7806dd83 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/widget.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/widget.py @@ -113,7 +113,6 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): '_focus_policy', '_canvas', '_widgetItem', '_visible', - '_pendingMouseRelease', '_enabled', '_style', '_currentStyle', '_toolTip', @@ -136,7 +135,6 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): _canvas:TTkCanvas _widgetItem:TTkWidgetItem _visible:bool - _pendingMouseRelease:bool _enabled:bool _style:Dict _currentStyle:Dict @@ -230,7 +228,6 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): self._name = name if name else self.__class__.__name__ self._parent = parent - self._pendingMouseRelease = False self._x, self._y = pos if pos else (x,y) self._width, self._height = size if size else (width,height) @@ -510,7 +507,6 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): TTkHelper.toolTipClose() if evt.evt == TTkK.Release: - self._pendingMouseRelease = False self._processStyleEvent(TTkWidget._S_NONE) if self.mouseReleaseEvent(evt): return True @@ -525,13 +521,12 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): w.setFocus() w.raiseWidget() if evt.tap == 2 and self.mouseDoubleClickEvent(evt): - #self._pendingMouseRelease = True return True if evt.tap > 1 and self.mouseTapEvent(evt): return True if evt.tap == 1 and self.mousePressEvent(evt): # TTkLog.debug(f"Click {self._name}") - self._pendingMouseRelease = True + self._setPendingMouseRelease() return True if evt.key == TTkK.Wheel: @@ -705,6 +700,12 @@ class TTkWidget(TMouseEvents, TKeyEvents, TDragEvents): if updateParent and self._parent is not None: self._parent.update(updateLayout=True) + def _setPendingMouseRelease(self) -> None: + '''set the Pending Mouse Release Widget''' + if not (_p:=self._parent): + return + _p._setPendingMouseReleaseWidget(self) + @pyTTkSlot() def setFocus(self) -> None: '''Focus the widget'''