# MIT License
#
# Copyright (c) 2021 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__ = ['TTkSplitter']
from typing import Union,List,Optional
from TermTk.TTkCore.constant import TTkK
from TermTk.TTkCore.cfg import TTkCfg
from TermTk.TTkCore.canvas import TTkCanvas
from TermTk.TTkCore.color import TTkColor
from TermTk.TTkCore.string import TTkString, TTkStringType
from TermTk.TTkCore.TTkTerm.inputmouse import TTkMouseEvent
from TermTk.TTkLayouts.layout import TTkLayout
from TermTk.TTkWidgets.widget import TTkWidget
from TermTk.TTkWidgets.container import TTkContainer
[docs]
class TTkSplitter(TTkContainer):
'''TTkSplitter:
A container widget that arranges child widgets with adjustable splitter bars.
::
Horizontal Splitter:
┌─────────╥─────────╥─────────┐
│ Widget1 ║ Widget2 ║ Widget3 │
│ ║ ║ │
└─────────╨─────────╨─────────┘
Vertical Splitter:
┌──────────────────────────────────┐
│ Widget 1 │
╞══════════════════════════════════╡
│ Widget 2 │
╞══════════════════════════════════╡
│ Widget 3 │
└──────────────────────────────────┘
The splitter allows users to redistribute space between child widgets by dragging
the splitter bars. Widgets can have fixed or proportional sizes.
Demo: `splitter.py <https://github.com/ceccopierangiolieugenio/pyTermTk/blob/main/demo/showcase/splitter.py>`_
(`online <https://ceccopierangiolieugenio.github.io/pyTermTk-Docs/sandbox/sandbox.html?filePath=demo/showcase/splitter.py>`__)
.. code-block:: python
import TermTk as ttk
root = ttk.TTk(layout=ttk.TTkGridLayout())
splitter = ttk.TTkSplitter(parent=root, orientation=ttk.TTkK.HORIZONTAL)
splitter.addWidget(ttk.TTkTestWidgetSizes(border=True), size=20)
splitter.addWidget(ttk.TTkTestWidgetSizes(border=True))
splitter.addWidget(ttk.TTkTestWidgetSizes(border=True), size=30)
root.mainloop()
'''
classStyle = {
'default': {'glyphs' : {
TTkK.VERTICAL : ('╞','═','╡'),
TTkK.HORIZONTAL : ('╥','║','╨') },
'color': TTkColor.fgbg("#dddddd","#222222"),
'borderColor': TTkColor.RST },
'disabled': {'color': TTkColor.fg('#888888'),
'borderColor':TTkColor.fg('#888888')},
'focus': {'color': TTkColor.fgbg("#ffddff","#222222"),
'borderColor': TTkColor.fg("#ffffaa")}
}
__slots__ = (
'_orientation', '_separators', '_refSizes',
'_items', '_titles', '_separatorSelected',
'_border')
_items:List[Union[TTkWidget,TTkLayout]]
_titles:List[Optional[TTkString]]
_separators:List[int]
_refSizes:List[Optional[int]]
'''Reference sizes for each widget in the splitter'''
_separatorSelected:Optional[int]
def __init__(self, *,
border:bool=False,
orientation:TTkK.Direction=TTkK.HORIZONTAL,
**kwargs) -> None:
''' Initialize the splitter
:param border: Draw a border around the splitter, defaults to False
:type border: bool, optional
:param orientation: Splitter orientation (:py:class:`TTkK.Direction.HORIZONTAL` or :py:class:`TTkK.Direction.VERTICAL`), defaults to :py:class:`TTkK.Direction.HORIZONTAL`
:type orientation: :py:class:`TTkK.Direction`, optional
'''
self._items = []
self._titles = []
self._separators = []
self._refSizes = []
self._border = border
self._orientation = orientation
self._separatorSelected = None
super().__init__(**kwargs)
self.setBorder(border)
self.setFocusPolicy(TTkK.ClickFocus)
class _SplitterLayout(TTkLayout):
def insertWidget(_, index, widget):
self.insertWidget(index, widget)
def addWidget(_, widget):
self.addWidget(widget)
def inserItem(_, item):
self.inserItem(item)
def addItem(_, item):
self.addItem(item)
self.setLayout(_SplitterLayout())
[docs]
def setBorder(self, border:bool) -> None:
''' Set whether to draw a border around the splitter
:param border: True to show border, False to hide
:type border: bool
'''
self._border = border
if border: self.setPadding(1,1,1,1)
else: self.setPadding(0,0,0,0)
self.update()
[docs]
def border(self) -> bool:
''' Get the current border state
:return: True if border is visible, False otherwise
:rtype: bool
'''
return self._border
[docs]
def orientation(self) -> TTkK.Direction:
''' Get the current splitter orientation
:return: The orientation (HORIZONTAL or VERTICAL)
:rtype: :py:class:`TTkK.Direction`
'''
return self._orientation
[docs]
def setOrientation(self, orientation:TTkK.Direction) -> None:
''' Set the splitter orientation
:param orientation: The new orientation (HORIZONTAL or VERTICAL)
:type orientation: :py:class:`TTkK.Direction`
'''
if orientation == self._orientation: return
if orientation not in (TTkK.HORIZONTAL, TTkK.VERTICAL): return
self._orientation = orientation
self._updateGeometries()
[docs]
def clean(self) -> None:
''' Remove all widgets and items from the splitter '''
for _i in reversed(self._items):
if isinstance(_i,TTkWidget):
self.removeWidget(_i)
else:
self.removeItem(_i)
[docs]
def count(self) -> int:
''' Get the number of items in the splitter
:return: The count of widgets/items
:rtype: int
'''
return len(self._items)
[docs]
def indexOf(self, widget:Union[TTkWidget,TTkLayout]) -> int:
''' Get the index of a widget or layout in the splitter
:param widget: The widget or layout to find
:type widget: :py:class:`TTkWidget` or :py:class:`TTkLayout`
:return: The index of the item
:rtype: int
'''
return self._items.index(widget)
[docs]
def replaceItem(self, index:int, item:TTkLayout, title:Optional[TTkStringType]=None) -> None:
''' Replace the layout at the specified index
:param index: The index to replace at
:type index: int
:param item: The new layout
:type item: :py:class:`TTkLayout`
:param title: Optional title for the item, defaults to None
:type title: str, :py:class:`TTkString`, optional
'''
if index >= len(self._items):
return self.addItem(item, title=title)
TTkLayout.removeItem(self.layout(), self._items[index])
TTkLayout.insertItem(self.layout(), index, item)
self._items[index] = item
self._titles[index] = title if isinstance(title,TTkString) else TTkString(title) if isinstance(title,str) else TTkString()
w,h = self.size()
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries()
[docs]
def removeItem(self, item:TTkLayout) -> None:
''' Remove a layout from the splitter
:param item: The layout to remove
:type item: :py:class:`TTkLayout`
'''
index = self.indexOf(item)
self._items.pop(index)
self._refSizes.pop(index)
self._separators.pop(index)
TTkLayout.removeItem(self.layout(), item)
w,h = self.size()
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries()
def removeWidget(self, widget:TTkWidget) -> None:
''' Remove a widget from the splitter
:param widget: The widget to remove
:type widget: :py:class:`TTkWidget`
'''
index = self.indexOf(widget)
self._items.pop(index)
self._refSizes.pop(index)
self._separators.pop(index)
TTkLayout.removeWidget(self.layout(), widget)
w,h = self.size()
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries()
[docs]
def addItem(self, item:TTkLayout, size:Optional[int]=None, title:Optional[TTkStringType]=None) -> None:
''' Add a layout to the end of the splitter
:param item: The layout to add
:type item: :py:class:`TTkLayout`
:param size: Fixed size for the item in characters, defaults to None (proportional)
:type size: int, optional
:param title: Optional title for the item, defaults to None
:type title: str, :py:class:`TTkString`, optional
'''
self.insertItem(len(self._items), item, size=size, title=title)
[docs]
def insertItem(self, index:int, item:TTkLayout, size:Optional[int]=None, title:Optional[TTkStringType]=None) -> None:
''' Insert a layout at the specified index
:param index: The index to insert at
:type index: int
:param item: The layout to insert
:type item: :py:class:`TTkLayout`
:param size: Fixed size for the item in characters, defaults to None (proportional)
:type size: int, optional
:param title: Optional title for the item, defaults to None
:type title: str, :py:class:`TTkString`, optional
'''
TTkLayout.insertItem(self.layout(), index, item)
self._insertWidgetItem(index, item, size=size, title=title)
def addWidget(self, widget:TTkWidget, size:Optional[int]=None, title:Optional[TTkStringType]=None) -> None:
''' Add a widget to the end of the splitter
:param widget: The widget to add
:type widget: :py:class:`TTkWidget`
:param size: Fixed size for the widget in characters, defaults to None (proportional)
:type size: int, optional
:param title: Optional title for the widget, defaults to None
:type title: str, :py:class:`TTkString`, optional
'''
self.insertWidget(len(self._items), widget, size=size, title=title)
def _insertWidgetItem(self, index:int, widgetItem:Union[TTkWidget,TTkLayout], size:Optional[int]=None, title:Optional[TTkStringType]=None) -> None:
''' Internal method to insert a widget or layout item
:param index: The index to insert at
:type index: int
:param widgetItem: The widget or layout to insert
:type widgetItem: :py:class:`TTkWidget` or :py:class:`TTkLayout`
:param size: Fixed size for the item, defaults to None
:type size: int, optional
:param title: Optional title, defaults to None
:type title: str, :py:class:`TTkString`, optional
'''
self._items.insert(index, widgetItem)
self._titles.insert(index, title if isinstance(title,TTkString) else TTkString(title) if isinstance(title,str) else None)
# assign the same slice to all the widgets
self._refSizes.insert(index, size)
w,h = self.size()
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries()
if self.parentWidget():
self.parentWidget().update(repaint=True, updateLayout=True)
[docs]
def setSizes(self, sizes:List[Optional[int]]) -> None:
''' Set the sizes for all widgets in the splitter
:param sizes: List of sizes in characters (None for proportional sizing)
:type sizes: list of int or None
'''
ls = len(self._separators)
sizes=sizes[:ls]+[None]*max(0,ls-len(sizes))
self._refSizes = sizes.copy()
w,h = self.size()
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries()
def _minMaxSizeBefore(self, index:int) -> tuple[int,int]:
''' Calculate minimum and maximum sizes before the selected separator
:param index: The separator index
:type index: int
:return: Tuple of (minimum_size, maximum_size)
:rtype: tuple[int,int]
'''
if self._separatorSelected is None:
return 0, 0x1000
# this is because there is a hidden splitter at position -1
minsize = -1
maxsize = -1
for i in range(self._separatorSelected+1):
item = self._items[i]
minsize += item.minDimension(self._orientation)+1
maxsize += item.maxDimension(self._orientation)+1
return minsize, maxsize
def _minMaxSizeAfter(self, index:int) -> tuple[int,int]:
''' Calculate minimum and maximum sizes after the selected separator
:param index: The separator index
:type index: int
:return: Tuple of (minimum_size, maximum_size)
:rtype: tuple[int,int]
'''
if self._separatorSelected is None:
return 0, 0x1000
minsize = 0x0
maxsize = 0x0
for i in range(self._separatorSelected+1, len(self._separators)):
item = self._items[i]
minsize += item.minDimension(self._orientation)+1
maxsize += item.maxDimension(self._orientation)+1
return minsize, maxsize
def _updateGeometries(self, resized:bool=False) -> None:
''' Internal method to update widget geometries based on splitter positions
:param resized: True if called from resize event, defaults to False
:type resized: bool, optional
'''
if not self.isVisible() or not self._items: return
w,h = self.size()
if w==h==0: return
sep = self._separators = self._separators[0:len(self._items)]
if self._border:
w-=2
h-=2
def _processGeometry(index, forward):
item = self._items[index]
pa = -1 if index==0 else sep[index-1]
pb = sep[index]
if self._orientation == TTkK.HORIZONTAL:
newPos = pa+1
size = w-newPos
else:
newPos = pa+1
size = h-newPos
if i<=len(sep)-2: # this is not the last widget
size = pb-newPos
maxsize = item.maxDimension(self._orientation)
minsize = item.minDimension(self._orientation)
if size > maxsize: size = maxsize
elif size < minsize: size = minsize
if forward:
sep[index]=pa+size+1
elif i>0 :
sep[index-1]=pa=pb-size-1
if self._orientation == TTkK.HORIZONTAL:
item.setGeometry(pa+1,0,size,h)
else:
item.setGeometry(0,pa+1,w,size)
pass
selected = 0
if self._orientation == TTkK.HORIZONTAL:
size = w
else:
size = h
if self._separatorSelected is not None:
selected = self._separatorSelected
sepPos = sep[selected]
minsize,maxsize = self._minMaxSizeBefore(selected)
# TTkLog.debug(f"before:{minsize,maxsize}")
if sepPos > maxsize: sep[selected] = maxsize
if sepPos < minsize: sep[selected] = minsize
minsize,maxsize = self._minMaxSizeAfter(selected)
# TTkLog.debug(f"after:{minsize,maxsize}")
if sepPos < size-maxsize: sep[selected] = size-maxsize
if sepPos > size-minsize: sep[selected] = size-minsize
if resized:
l = len(sep)
for i in reversed(range(l)):
_processGeometry(i, False)
for i in range(l):
_processGeometry(i, True)
else:
for i in reversed(range(selected+1)):
_processGeometry(i, False)
for i in range(selected+1, len(sep)):
_processGeometry(i, True)
if self._separatorSelected is not None:
s:List[Optional[int]] = [ b-a for a,b in zip([0]+self._separators,self._separators)]
self._refSizes = s
self.update()
def _processRefSizes(self, w:int, h:int) -> None:
''' Process reference sizes and calculate separator positions
This method handles both fixed and proportional widget sizing:
- When :py:attr:`_refSizes` contains None values, remaining space is distributed proportionally
- When all sizes are fixed, they are scaled to fit the available space
- The last widget always receives any remaining space to prevent rounding errors
:param w: Available width
:type w: int
:param h: Available height
:type h: int
'''
self._separatorSelected = None
if self._orientation == TTkK.HORIZONTAL:
sizeRef = w
else:
sizeRef = h
if sizeRef==0:
self._separators = [0]*len(self._items)
return
# get the sum of the fixed sizes
if None in self._refSizes:
fixSize = sum(filter(None, self._refSizes))
numVarSizes = len([x for x in self._refSizes if x is None])
avalSize = sizeRef-fixSize
varSize = avalSize//numVarSizes
sizes:List[int] = []
for s in self._refSizes:
if s is None:
avalSize -= varSize
newSize = varSize + avalSize if avalSize<varSize else 0
sizes.append(newSize)
else:
sizes.append(s)
sizes = [varSize if s is None else s for s in self._refSizes]
else:
sizes = [s for s in self._refSizes if s is not None]
sizeRef = sum(sizes)
self._separators = [sum(sizes[:i+1]) for i in range(len(sizes))]
# Adjust separators to the new size;
if sizeRef > 0:
if self._orientation == TTkK.HORIZONTAL:
diff = w/sizeRef
else:
diff = h/sizeRef
self._separators = [int(i*diff) for i in self._separators]
def resizeEvent(self, w:int, h:int) -> None:
''' Handle resize events and update widget geometries
This method recalculates all separator positions and widget sizes
when the splitter is resized, maintaining the proportional or fixed
size relationships defined by :py:meth:`setSizes` or drag operations.
:param w: New width
:type w: int
:param h: New height
:type h: int
'''
b = 2 if self._border else 0
self._processRefSizes(w-b,h-b)
self._updateGeometries(resized=True)
def mousePressEvent(self, evt:TTkMouseEvent) -> bool:
self._separatorSelected = None
x,y = evt.x, evt.y
if self._border:
x-=1 ; y-=1
# TTkLog.debug(f"{self._separators} {evt}")
for i, val in enumerate(self._separators):
if self._orientation == TTkK.HORIZONTAL:
if x == val:
self._separatorSelected = i
self._updateGeometries()
else:
if y == val:
self._separatorSelected = i
self._updateGeometries()
return self._separatorSelected is not None
def mouseDragEvent(self, evt:TTkMouseEvent) -> bool:
if self._separatorSelected is not None:
x,y = evt.x, evt.y
if self._border:
x-=1 ; y-=1
if self._orientation == TTkK.HORIZONTAL:
self._separators[self._separatorSelected] = x
else:
self._separators[self._separatorSelected] = y
self._updateGeometries()
return True
return False
def focusOutEvent(self):
self._separatorSelected = None
def minimumHeight(self) -> int:
''' Get the minimum height required for the splitter
For vertical splitters, returns the sum of all child minimum heights plus separators.
For horizontal splitters, returns the maximum child minimum height.
:return: The minimum height in characters
:rtype: int
'''
ret = b = 2 if self._border else 0
if not self._items: return ret
if self._orientation == TTkK.VERTICAL:
for item in self._items:
ret+=item.minimumHeight()+1
ret = max(0,ret-1)
else:
for item in self._items:
ret = max(ret,item.minimumHeight()+b)
return ret
def minimumWidth(self) -> int:
''' Get the minimum width required for the splitter
For horizontal splitters, returns the sum of all child minimum widths plus separators.
For vertical splitters, returns the maximum child minimum width.
:return: The minimum width in characters
:rtype: int
'''
ret = b = 2 if self._border else 0
if not self._items: return ret
if self._orientation == TTkK.HORIZONTAL:
for item in self._items:
ret+=item.minimumWidth()+1
ret = max(0,ret-1)
else:
for item in self._items:
ret = max(ret,item.minimumWidth()+b)
return ret
def maximumHeight(self) -> int:
''' Get the maximum height allowed for the splitter
For vertical splitters, returns the sum of all child maximum heights plus separators.
For horizontal splitters, returns the minimum child maximum height.
:return: The maximum height in characters
:rtype: int
'''
b = 2 if self._border else 0
if not self._items: return 0x10000
if self._orientation == TTkK.VERTICAL:
ret = b
for item in self._items:
ret+=item.maximumHeight()+1
ret = max(b,ret-1)
else:
ret = 0x10000
for item in self._items:
ret = min(ret,item.maximumHeight()+b)
return ret
def maximumWidth(self) -> int:
''' Get the maximum width allowed for the splitter
For horizontal splitters, returns the sum of all child maximum widths plus separators.
For vertical splitters, returns the minimum child maximum width.
:return: The maximum width in characters
:rtype: int
'''
b = 2 if self._border else 0
if not self._items: return 0x10000
if self._orientation == TTkK.HORIZONTAL:
ret = b
for item in self._items:
ret+=item.maximumWidth()+1
ret = max(b,ret-1)
else:
ret = 0x10000
for item in self._items:
ret = min(ret,item.maximumWidth()+b)
return ret
def paintEvent(self, canvas:TTkCanvas) -> None:
style = self.currentStyle()
color = style['color']
borderColor = style['borderColor']
off = 0
w,h = self.size()
if self._border:
off= 1
canvas.drawBox(pos=(0,0),size=(w,h),color=borderColor)
glyphs = style['glyphs'][self._orientation]
if self._orientation == TTkK.HORIZONTAL:
for i in self._separators[:-1]:
canvas.drawChar(pos=(i+off,0 ), char=glyphs[0], color=borderColor)
canvas.drawChar(pos=(i+off,h-1), char=glyphs[2], color=borderColor)
if h>2:
canvas.fill(pos=(i+off,1),size=(1,h-2), char=glyphs[1], color=borderColor)
# canvas.drawVLine(pos=(i+off,0), size=h,color=borderColor)
else:
for i in self._separators[:-1]:
canvas.drawChar(pos=(0, i+off), char=glyphs[0], color=borderColor)
canvas.drawChar(pos=(w-1,i+off), char=glyphs[2], color=borderColor)
if w>2:
canvas.fill(pos=(1,i+off),size=(w-2,1), char=glyphs[1], color=borderColor)
# canvas.drawHLine(pos=(0,i+off), size=w,color=borderColor)
if self._orientation == TTkK.HORIZONTAL and self._border:
for i,t in enumerate(self._titles):
if not t: continue
a = (off + self._separators[i-1]) if i>0 else 0
b = off + self._separators[i]
canvas.drawBoxTitle(
pos=(a,0),
size=(b-a+1,1),
text=t,
color=borderColor,
colorText=color)
elif self._orientation == TTkK.VERTICAL:
for i,t in enumerate(self._titles):
if i == 0 and not self._border: continue
if not t: continue
a = (off + self._separators[i-1]) if i>0 else 0
grid = 0 if i == 0 else 5
canvas.drawBoxTitle(
pos=(0,a),
size=(w,1),
grid=grid,
text=t,
color=borderColor,
colorText=color)