1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609
|
#------------------------------------------------------------------------------
# Copyright (c) 2013-2025, Nucleic Development Team.
#
# Distributed under the terms of the Modified BSD License.
#
# The full license is in the file LICENSE, distributed with this software.
#------------------------------------------------------------------------------
import sys
from enum import IntEnum
from weakref import WeakKeyDictionary
from atom.api import Int, Typed
from enaml.styling import StyleCache
from enaml.widgets.notebook import ProxyNotebook
from .QtCore import Qt, QEvent, QSize, Signal
from .QtGui import QResizeEvent
from .QtWidgets import (
QTabWidget, QTabBar, QApplication, QStackedWidget
)
from .qt_constraints_widget import QtConstraintsWidget
from .qt_page import QtPage
from .styleutil import translate_notebook_style
TAB_POSITIONS = {
'top': QTabWidget.North,
'bottom': QTabWidget.South,
'left': QTabWidget.West,
'right': QTabWidget.East,
}
DOCUMENT_MODES = {
'document': True,
'preferences': False,
}
class QNotebook(QTabWidget):
""" A custom QTabWidget which handles children of type QPage.
"""
class SizeHintMode(IntEnum):
""" An int enum defining the size hint modes of the notebook.
"""
#: The size hint is the union of all tabs.
Union = 0
#: The size hint is the size hint of the current tab.
Current = 1
#: Proxy the SizeHintMode values as if it were an anonymous enum.
Union = SizeHintMode.Union
Current = SizeHintMode.Current
#: A signal emitted when a LayoutRequest event is posted to the
#: notebook widget. This will typically occur when the size hint
#: of the notebook is no longer valid.
layoutRequested = Signal()
def __init__(self, *args, **kwargs):
""" Initialize a QNotebook.
Parameters
----------
*args, **kwargs
The positional and keyword arguments needed to create
a QTabWidget.
"""
super(QNotebook, self).__init__(*args, **kwargs)
self.tabCloseRequested.connect(self.onTabCloseRequested)
self._hidden_pages = WeakKeyDictionary()
self._size_hint = QSize()
self._min_size_hint = QSize()
self._size_hint_mode = QNotebook.Union
#--------------------------------------------------------------------------
# Private API
#--------------------------------------------------------------------------
def _refreshTabBar(self):
""" Trigger an immediate relayout and refresh of the tab bar.
"""
# The public QTabBar api does not provide a way to trigger the
# 'layoutTabs' method of QTabBarPrivate and there are certain
# operations (such as modifying a tab close button) which need
# to have that happen. This method provides a workaround by
# sending a dummy resize event to the tab bar, followed by one
# to the tab widget.
app = QApplication.instance()
if app is not None:
bar = self.tabBar()
size = bar.size()
event = QResizeEvent(size, size)
app.sendEvent(bar, event)
size = self.size()
event = QResizeEvent(size, size)
app.sendEvent(self, event)
#--------------------------------------------------------------------------
# Signal Handlers
#--------------------------------------------------------------------------
def onTabCloseRequested(self, index):
""" The handler for the 'tabCloseRequested' signal.
"""
self.widget(index).requestClose()
#--------------------------------------------------------------------------
# Public API
#--------------------------------------------------------------------------
def event(self, event):
""" A custom event handler which handles LayoutRequest events.
When a LayoutRequest event is posted to this widget, it will
emit the `layoutRequested` signal. This allows an external
consumer of this widget to update their external layout.
"""
res = super(QNotebook, self).event(event)
if event.type() == QEvent.LayoutRequest:
self._size_hint = QSize()
self._min_size_hint = QSize()
self.layoutRequested.emit()
return res
def sizeHint(self):
""" A reimplemented size hint handler.
"""
# Cached for performance. Invalidated on a layout request.
hint = self._size_hint
if hint.isValid():
return hint
# QTabWidget does not allow assigning a custom QStackedWidget,
# so the default sizeHint is computed, and the effects of the
# stack size hint are replaced by the current tab's size hint.
hint = super(QNotebook, self).sizeHint()
if self._size_hint_mode == QNotebook.Current:
stack = self.findChild(QStackedWidget)
if stack is not None:
curr = stack.currentWidget()
if curr is not None:
hint -= stack.sizeHint()
hint += curr.sizeHint()
self._size_hint = hint
return hint
def minimumSizeHint(self):
""" A reimplemented minimum size hint handler.
"""
# Cached for performance. Invalidated on a layout request.
hint = self._size_hint
if hint.isValid():
return hint
# QTabWidget does not allow assigning a custom QStackedWidget,
# so the default minimumSizeHint is computed, and the effects
# of the stack size hint are replaced by the current tab's
# minimum size hint.
hint = super(QNotebook, self).minimumSizeHint()
if self._size_hint_mode == QNotebook.Current:
stack = self.findChild(QStackedWidget)
if stack is not None:
curr = stack.currentWidget()
if curr is not None:
hint -= stack.minimumSizeHint()
hint += curr.minimumSizeHint()
self._size_hint = hint
return hint
def sizeHintMode(self):
""" Get the size hint mode of the notebook.
Returns
-------
result : QNotebook.SizeHintMode
The size hint mode enum value for the notebook.
"""
return self._size_hint_mode
def setSizeHintMode(self, mode):
""" Set the size hint mode of the notebook.
Parameters
----------
mode : QNotebook.SizeHintMode
The size hint mode for the notebook.
"""
assert isinstance(mode, QNotebook.SizeHintMode)
self._size_hint = QSize()
self._min_size_hint = QSize()
self._size_hint_mode = mode
def showPage(self, page):
""" Show a hidden QPage instance in the notebook.
If the page is not owned by the notebook, this is a no-op.
Parameters
----------
page : QPage
The hidden QPage instance to show in the notebook.
"""
index = self.indexOf(page)
if index == -1:
index = self._hidden_pages.pop(page, -1)
if index != -1:
self.insertPage(index, page)
def hidePage(self, page):
""" Hide the given QPage instance in the notebook.
If the page is not owned by the notebook, this is a no-op.
Parameters
----------
page : QPage
The QPage instance to hide in the notebook.
"""
index = self.indexOf(page)
if index != -1:
self.removeTab(index)
page.hide()
self._hidden_pages[page] = index
def addPage(self, page):
""" Add a QPage instance to the notebook.
This method should be used in favor of the 'addTab' method.
Parameters
----------
page : QPage
The QPage instance to add to the notebook.
"""
self.insertPage(self.count(), page)
def insertPage(self, index, page):
""" Insert a QPage instance into the notebook.
This should be used in favor of the 'insertTab' method.
Parameters
----------
index : int
The index at which to insert the page.
page : QPage
The QPage instance to add to the notebook.
"""
if page.isOpen():
index = min(index, self.count())
self.insertTab(index, page, page.title())
self.setTabIcon(index, page.icon())
self.setTabToolTip(index, page.toolTip())
self.setTabEnabled(index, page.isTabEnabled())
self.setTabCloseButtonVisible(index, page.isClosable())
else:
page.hide()
self._hidden_pages[page] = index
def movePage(self, page, index: int):
""" Move a QPage instance within the notebook.
Parameters
----------
page : QPage
The QPage instance to add to the notebook.
index : int
The index to which to move the page.
"""
self.tabBar().moveTab(self.indexOf(page), index)
def removePage(self, page):
""" Remove a QPage instance from the notebook.
If the page does not exist in the notebook, this is a no-op.
Parameters
----------
page : QPage
The QPage instance to remove from the notebook.
"""
index = self.indexOf(page)
if index != -1:
self.removeTab(index)
page.hide()
def setTabCloseButtonVisible(self, index, visible, refresh=True):
""" Set whether the close button for the given tab is visible.
The 'tabsClosable' property must be set to True for this to
have effect.
Parameters
----------
index : int
The index of the target page.
visible : bool
Whether or not the close button for the tab should be
visible.
refresh : bool, optional
Whether or not to refresh the tab bar at the end of the
operation. The default is True.
"""
# When changing the visibility of a button, we also change its
# size so that the tab can layout properly.
if index >= 0 and index < self.count():
tabBar = self.tabBar()
btn1 = tabBar.tabButton(index, QTabBar.LeftSide)
btn2 = tabBar.tabButton(index, QTabBar.RightSide)
if btn1 is not None:
btn1.setVisible(visible)
if not visible:
btn1.resize(0, 0)
else:
btn1.resize(btn1.sizeHint())
if btn2 is not None:
btn2.setVisible(visible)
if not visible:
btn2.resize(0, 0)
else:
btn2.resize(btn2.sizeHint())
if refresh:
self._refreshTabBar()
def setTabsClosable(self, closable):
""" Set the tab closable state for the widget.
This is an overridden parent class method which extends the
logic to account for the closable state on the individual
pages.
Parameters
----------
closable : bool
Whether or not the tabs should be closable.
"""
super(QNotebook, self).setTabsClosable(closable)
# When setting tabs closable to False, the default logic of
# QTabBar is to delete the close button on the tab. When the
# closable flag is set to True a new close button is created
# for every tab, unless one has already been provided. This
# means we need to make an extra pass over each tab to sync
# the state of the buttons when the flag is set to True.
if closable:
setVisible = self.setTabCloseButtonVisible
for index in range(self.count()):
page = self.widget(index)
setVisible(index, page.isClosable(), refresh=False)
self._refreshTabBar()
#: A mapping Enaml -> Qt size hint modes.
SIZE_HINT_MODE = {
'union': QNotebook.Union,
'current': QNotebook.Current,
}
#: A guard flag for the tab change
CHANGE_GUARD = 0x01
class QtNotebook(QtConstraintsWidget, ProxyNotebook):
""" A Qt implementation of an Enaml ProxyNotebook.
"""
#: A reference to the widget created by the proxy.
widget = Typed(QNotebook)
#: A bitfield of guard flags for the object.
_guard = Int(0)
#--------------------------------------------------------------------------
# Initialization API
#--------------------------------------------------------------------------
def create_widget(self):
""" Create the underlying notebook widget.
"""
widget = QNotebook(self.parent_widget())
if sys.platform == 'darwin':
# On OSX, the widget item layout rect is too small.
# Setting this attribute forces the widget item to
# use the widget rect for layout.
widget.setAttribute(Qt.WA_LayoutUsesWidgetRect, True)
self.widget = widget
def init_widget(self):
""" Initialize the underlying widget.
"""
super(QtNotebook, self).init_widget()
d = self.declaration
self.set_tab_style(d.tab_style)
self.set_tab_position(d.tab_position)
self.set_tabs_closable(d.tabs_closable)
self.set_tabs_movable(d.tabs_movable)
self.set_size_hint_mode(d.size_hint_mode, update=False)
# the selected tab is synchronized during init_layout
def init_layout(self):
""" Handle the layout initialization for the notebook.
"""
super(QtNotebook, self).init_layout()
widget = self.widget
for page in self.pages():
widget.addPage(page)
self.init_selected_tab()
widget.layoutRequested.connect(self.on_layout_requested)
widget.currentChanged.connect(self.on_current_changed)
#--------------------------------------------------------------------------
# Overrides
#--------------------------------------------------------------------------
def refresh_style_sheet(self):
""" A reimplemented styling method.
The notebook has an embedded tab bar and tabs that needs stylesheet
processing.
"""
super().refresh_style_sheet()
self.refresh_tab_bar_style_sheet()
def refresh_tab_bar_style_sheet(self):
""" Refresh the notebook pseudo element styles.
"""
parts = []
name = self.widget.objectName()
for style in StyleCache.styles(self.declaration):
t = translate_notebook_style(name, style)
if t:
parts.append(t)
if len(parts) > 0:
stylesheet = '\n\n'.join(parts)
else:
stylesheet = ''
self.widget.tabBar().setStyleSheet(stylesheet)
#--------------------------------------------------------------------------
# Utility Methods
#--------------------------------------------------------------------------
def pages(self):
""" Get the pages defined for the notebook.
"""
for p in self.declaration.pages():
w = p.proxy.widget
if w is not None:
yield w
def find_page(self, name):
""" Find the page with the given name.
Parameters
----------
name : unicode
The object name for the page of interest.
Returns
-------
result : QPage or None
The target page or None if one is not found.
"""
for page in self.pages():
if page.objectName() == name:
return page
def init_selected_tab(self):
""" Initialize the selected tab.
This should be called only during widget initialization.
"""
d = self.declaration
if d.selected_tab:
self.set_selected_tab(d.selected_tab)
else:
current = self.widget.currentWidget()
name = current.objectName() if current is not None else ''
self._guard |= CHANGE_GUARD
try:
d.selected_tab = name
finally:
self._guard &= ~CHANGE_GUARD
#--------------------------------------------------------------------------
# Child Events
#--------------------------------------------------------------------------
def child_added(self, child):
""" Handle the child added event for a QtNotebook.
"""
super(QtNotebook, self).child_added(child)
if isinstance(child, QtPage):
for index, dchild in enumerate(self.children()):
if child is dchild:
self.widget.insertPage(index, child.widget)
def child_removed(self, child):
""" Handle the child removed event for a QtNotebook.
"""
super(QtNotebook, self).child_removed(child)
if isinstance(child, QtPage):
self.widget.removePage(child.widget)
#--------------------------------------------------------------------------
# Signal Handlers
#--------------------------------------------------------------------------
def on_layout_requested(self):
""" Handle the `layoutRequested` signal from the QNotebook.
"""
self.geometry_updated()
def on_current_changed(self):
""" Handle the 'currentChanged' signal from the QNotebook.
"""
if not self._guard & CHANGE_GUARD:
self._guard |= CHANGE_GUARD
try:
page = self.widget.currentWidget()
name = page.objectName() if page is not None else ''
self.declaration.selected_tab = name
finally:
self._guard &= ~CHANGE_GUARD
#--------------------------------------------------------------------------
# ProxyNotebook API
#--------------------------------------------------------------------------
def page_moved(self, child: QtPage):
"""Handle a page being moved QtNotebook."""
for index, dchild in enumerate(self.children()):
if child is dchild:
self.widget.movePage(child.widget, index)
def set_tab_style(self, style):
""" Set the tab style for the tab bar in the widget.
"""
self.widget.setDocumentMode(DOCUMENT_MODES[style])
def set_tab_position(self, position):
""" Set the position of the tab bar in the widget.
"""
self.widget.setTabPosition(TAB_POSITIONS[position])
def set_tabs_closable(self, closable):
""" Set whether or not the tabs are closable.
"""
self.widget.setTabsClosable(closable)
def set_tabs_movable(self, movable):
""" Set whether or not the tabs are movable.
"""
self.widget.setMovable(movable)
def set_selected_tab(self, name):
""" Set the selected tab of the widget.
"""
if not self._guard & CHANGE_GUARD:
page = self.find_page(name)
if page is None:
import warnings
msg = "cannot select tab '%s' - tab not found"
warnings.warn(msg % name, UserWarning)
return
self._guard |= CHANGE_GUARD
try:
self.widget.setCurrentWidget(page)
finally:
self._guard &= ~CHANGE_GUARD
def set_size_hint_mode(self, mode, update=True):
""" Set the size hint mode for the widget.
"""
self.widget.setSizeHintMode(SIZE_HINT_MODE[mode])
if update:
self.geometry_updated()
|