Files
pyfa/gui/chrome_tabs.py
2020-06-20 15:13:33 -04:00

1515 lines
48 KiB
Python

# ===============================================================================
#
# ToDo: Bug - when selecting close on a tab, sometimes the tab to the right is
# selected, most likely due to determination of mouse position
# ToDo: Tab Selection seems overly complicated. OnLeftDown finds tab at
# position, and then call's CheckTabSelected which calls TabHitTest (when
# we are already aware it will return due to FindTabAtPos)
# ToDo: Perhaps a better way of finding tabs at position instead of looping
# through them and getting their regions. Perhaps some smart trickery with
# mouse pos x (all tabs have same width, so we divide x by width to find
# tab index?). This will also help with finding close buttons.
# ToDo: Fix page preview code (PFNotebookPagePreview)
#
# ===============================================================================
import math
from functools import lru_cache
import wx
import wx.lib.newevent
from gui.bitmap_loader import BitmapLoader
from gui.utils import color as color_utils, draw, fonts
from service.fit import Fit
_t = wx.GetTranslation
_PageChanging, EVT_NOTEBOOK_PAGE_CHANGING = wx.lib.newevent.NewEvent()
_PageChanged, EVT_NOTEBOOK_PAGE_CHANGED = wx.lib.newevent.NewEvent()
_PageAdding, EVT_NOTEBOOK_PAGE_ADDING = wx.lib.newevent.NewEvent()
_PageClosing, EVT_NOTEBOOK_PAGE_CLOSING = wx.lib.newevent.NewEvent()
PageAdded, EVT_NOTEBOOK_PAGE_ADDED = wx.lib.newevent.NewEvent()
PageClosed, EVT_NOTEBOOK_PAGE_CLOSED = wx.lib.newevent.NewEvent()
class VetoAble:
def __init__(self):
self.__vetoed = False
def Veto(self):
self.__vetoed = True
def isVetoed(self):
return self.__vetoed
class NotebookTabChangeEvent:
def __init__(self, old, new):
self.__old = old
self.__new = new
def GetOldSelection(self):
return self.__old
def GetSelection(self):
return self.__new
OldSelection = property(GetOldSelection)
Selection = property(GetSelection)
class PageChanging(_PageChanging, NotebookTabChangeEvent, VetoAble):
def __init__(self, old, new):
_PageChanging.__init__(self)
NotebookTabChangeEvent.__init__(self, old, new)
VetoAble.__init__(self)
class PageChanged(_PageChanged, NotebookTabChangeEvent):
def __init__(self, old, new, *args, **kwargs):
_PageChanged.__init__(self, *args, **kwargs)
NotebookTabChangeEvent.__init__(self, old, new)
class PageClosing(_PageClosing, VetoAble):
def __init__(self, i):
_PageClosing.__init__(self)
self.__index = i
VetoAble.__init__(self)
self.Selection = property(self.GetSelection)
def GetSelection(self):
return self.__index
class PageAdding(_PageAdding, VetoAble):
def __init__(self):
_PageAdding.__init__(self)
VetoAble.__init__(self)
class ChromeNotebook(wx.Panel):
def __init__(self, parent, can_add=True, tabWidthMode=0):
"""
Instance of Notebook. Initializes general layout, includes methods
for setting current page, replacing pages, any public function for the
notebook
width modes:
- 0: legacy (all tabs have equal width)
- 1: all tabs take just enough space to fit text
"""
super().__init__(parent, wx.ID_ANY, size=(-1, -1))
self._pages = []
self._active_page = None
main_sizer = wx.BoxSizer(wx.VERTICAL)
tabs_sizer = wx.BoxSizer(wx.VERTICAL)
self.tabs_container = _TabsContainer(self, can_add=can_add, tabWidthMode=tabWidthMode)
tabs_sizer.Add(self.tabs_container, 0, wx.EXPAND)
if 'wxMSW' in wx.PlatformInfo:
style = wx.DOUBLE_BORDER
else:
style = wx.SIMPLE_BORDER
back_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
content_sizer = wx.BoxSizer(wx.VERTICAL)
self.page_container = wx.Panel(self, style=style)
self.page_container.SetBackgroundColour(back_color)
content_sizer.Add(self.page_container, 1, wx.EXPAND, 5)
main_sizer.Add(tabs_sizer, 0, wx.EXPAND, 5)
main_sizer.Add(content_sizer, 1, wx.EXPAND | wx.BOTTOM, 2)
self.SetSizer(main_sizer)
self.Bind(wx.EVT_SIZE, self.OnSize)
self.Layout()
def GetPage(self, i):
return self._pages[i]
def SetPage(self, i, page):
if i >= len(self._pages) or i is None or page is None:
return
old_page = self._pages[i]
self._pages[i] = page
if old_page == self._active_page:
old_page.Destroy()
self._active_page = page
else:
old_page.Destroy()
page.Reparent(self.page_container)
if self._active_page == page:
self.ShowActive()
def GetBorders(self):
"""Gets border widths to better determine page size in ShowActive()"""
bx = wx.SystemSettings.GetMetric(wx.SYS_BORDER_X)
by = wx.SystemSettings.GetMetric(wx.SYS_BORDER_Y)
if bx < 0:
bx = 1
if by < 0:
by = 1
return bx, by
def ReplaceActivePage(self, page):
self.SetPage(self.GetSelection(), page)
def GetSelectedPage(self):
return self._active_page
def GetPageIndex(self, page):
return self._pages.index(page) if page in self._pages else None
def GetSelection(self):
return self.GetPageIndex(self._active_page)
def GetCurrentPage(self):
return self._active_page
def GetPageCount(self):
return len(self._pages)
def NextPage(self):
"""Used with keyboard shortcut for next page navigation"""
current_page = self.GetSelection()
if current_page is None:
return
if current_page < self.GetPageCount() - 1:
self.SetSelection(current_page + 1)
new_page = current_page + 1
else:
self.SetSelection(0)
new_page = 0
wx.PostEvent(self, PageChanged(current_page, new_page))
def PrevPage(self):
"""Used with keyboard shortcut for previous page navigation"""
current_page = self.GetSelection()
if current_page is None:
return
if current_page > 0:
self.SetSelection(current_page - 1)
new_page = current_page - 1
else:
self.SetSelection(self.GetPageCount() - 1)
new_page = self.GetPageCount() - 1
wx.PostEvent(self, PageChanged(current_page, new_page))
def AddPage(self, win=None, title=None, image: wx.Image=None, closeable=True):
title = title or "Empty Tab"
if self._active_page:
self._active_page.Hide()
if not win:
win = wx.Panel(self)
win.Reparent(self.page_container)
self.page_container.Layout()
self._pages.append(win)
self.tabs_container.AddTab(title, image, closeable)
self._active_page = win
self.ShowActive(True)
def DisablePage(self, page, toggle):
idx = self.GetPageIndex(page)
if toggle and page == self._active_page:
try:
# Set page to the first non-disabled page
self.SetSelection(next(i for i, _ in enumerate(self._pages) if not self.tabs_container.tabs[i].disabled))
except StopIteration:
self.SetSelection(0)
self.tabs_container.DisableTab(idx, toggle)
def SetSelection(self, page, focus=True):
old_selection = self.GetSelection()
if old_selection != page:
self._active_page.Hide()
self._active_page = self._pages[page]
self.tabs_container.SetSelected(page)
self.ShowActive()
if focus:
self._active_page.SetFocus()
wx.PostEvent(self, PageChanged(old_selection, page))
def DeletePage(self, n):
page = self._pages[n]
self._pages.remove(page)
page.Destroy()
self.tabs_container.DeleteTab(n)
selection = self.tabs_container.GetSelected()
if selection is not None:
self._active_page = self._pages[selection]
self.ShowActive()
wx.PostEvent(self, PageChanged(-1, selection))
else:
self._active_page = None
def SwitchPages(self, src, dst):
self._pages[src], self._pages[dst] = self._pages[dst], self._pages[src]
def ShowActive(self, resize_only=False):
"""
Sets the size of the page and shows. The sizing logic adjusts for some
minor sizing errors (scrollbars going beyond bounds)
resize_only
if we are not interested in showing the page, only setting the size
@todo: is resize_only still needed? Was introduced with 8b8b97 in mid
2011 to fix a resizing bug with blank _pages, cannot reproduce
13Sept2014
"""
ww, wh = self.page_container.GetSize()
bx, by = self.GetBorders()
ww -= bx * 4
wh -= by * 4
self._active_page.SetSize((max(ww, -1), max(wh, -1)))
self._active_page.SetPosition((0, 0))
if not resize_only:
self._active_page.Show()
self.Layout()
def IsActive(self, page):
return self._active_page == page
def SetPageTitle(self, i, text, refresh=True):
tab = self.tabs_container.tabs[i]
tab.baseText = text
if refresh:
self.tabs_container.AdjustTabsSize()
self.Refresh()
def SetPageTitleExtra(self, i, text, refresh=True):
tab = self.tabs_container.tabs[i]
tab.extraText = text
if refresh:
self.tabs_container.AdjustTabsSize()
self.Refresh()
def SetPageIcon(self, i, icon, refresh=True):
tab = self.tabs_container.tabs[i]
tab.tab_img = icon
if refresh:
self.tabs_container.AdjustTabsSize()
self.Refresh()
def SetPageTextIcon(self, i, text=wx.EmptyString, icon=None):
self.SetPageTitle(i, text, False)
self.SetPageIcon(i, icon, False)
self.tabs_container.AdjustTabsSize()
self.Refresh()
def Refresh(self):
self.tabs_container.Refresh()
def OnSize(self, event):
w, h = self.GetSize()
self.tabs_container.SetSize((w, -1))
self.tabs_container.UpdateSize()
self.tabs_container.Refresh()
self.Layout()
if self._active_page:
self.ShowActive()
event.Skip()
class _TabRenderer:
def __init__(self, size=(36, 24), text=wx.EmptyString, img: wx.Image=None,
closeable=True):
# tab left/right zones inclination
self.ctab_left = BitmapLoader.getImage("ctableft", "gui")
self.ctab_middle = BitmapLoader.getImage("ctabmiddle", "gui")
self.ctab_right = BitmapLoader.getImage("ctabright", "gui")
self.ctab_close = BitmapLoader.getImage("ctabclose", "gui")
self.left_width = self.ctab_left.GetWidth()
self.right_width = self.ctab_right.GetWidth()
self.middle_width = self.ctab_middle.GetWidth()
self.close_btn_width = self.ctab_close.GetWidth()
width, height = size
self.min_width = self.left_width + self.right_width + self.middle_width
self.min_height = self.ctab_middle.GetHeight()
# set minimum width and height to what is allotted to images
width = max(width, self.min_width)
height = max(height, self.min_height)
self.disabled = False
self.baseText = text
self.extraText = ''
self.tab_size = (width, height)
self.closeable = closeable
self.selected = False
self.close_btn_hovering = False
self.tab_bitmap = None
self.tab_back_bitmap = None
self.padding = 4
self.font = wx.Font(fonts.NORMAL, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False)
self.tab_img = img
self.position = (0, 0) # Not used internally for rendering - helper for tab container
self.InitTab()
@property
def text(self):
return self.baseText + self.extraText
def SetPosition(self, position):
self.position = position
def GetPosition(self):
return self.position
def GetSize(self):
return self.tab_size
def SetSize(self, size):
width, height = size
width = max(width, self.min_width)
height = max(height, self.min_height)
self.tab_size = (width, height)
self.InitTab()
def SetSelected(self, sel=True):
self.selected = sel
self.InitTab()
def GetSelected(self):
return self.selected
def IsSelected(self):
return self.selected
def ShowCloseButtonHovering(self, hover=True):
if self.close_btn_hovering != hover:
self.close_btn_hovering = hover
self._Render()
def GetCloseButtonHoverStatus(self):
return self.close_btn_hovering
def GetTabRegion(self):
new_region = self.CopyRegion(self.tab_region)
new_region.Subtract(self.close_region) if self.closeable else self.tab_region
return new_region
def GetCloseButtonRegion(self):
return self.CopyRegion(self.close_region)
def GetMinSize(self):
ebmp = wx.Bitmap(1, 1)
mdc = wx.MemoryDC()
mdc.SelectObject(ebmp)
mdc.SetFont(self.font)
textSizeX, textSizeY = mdc.GetTextExtent(self.text)
totalSize = self.left_width + self.right_width + textSizeX + self.close_btn_width / 2 + 16 + self.padding * 2
mdc.SelectObject(wx.NullBitmap)
return totalSize, self.tab_height
def SetTabImage(self, img):
self.tab_img = img
def CopyRegion(self, region):
rect = region.GetBox()
newRegion = wx.Region(rect.X, rect.Y, rect.Width, rect.Height)
newRegion.Intersect(region)
return newRegion
def InitTab(self):
self.tab_width, self.tab_height = self.tab_size
self.content_width = self.tab_width - self.left_width - self.right_width
self.tab_region = None
self.close_region = None
self.InitColors()
self.InitBitmaps()
self.ComposeTabBack()
self.InitTabRegions()
self._Render()
def InitBitmaps(self):
"""
Creates bitmap for tab
Takes the bitmaps already set and replaces a known color (black) with
the needed color, while also considering selected state. Color dependant
on platform -- see InitColors().
"""
if self.selected:
tr, tg, tb, ta = self.selected_color
else:
tr, tg, tb, ta = self.inactive_color
ctab_left = self.ctab_left.Copy()
ctab_right = self.ctab_right.Copy()
ctab_middle = self.ctab_middle.Copy()
ctab_left.Replace(0, 0, 0, tr, tg, tb)
ctab_right.Replace(0, 0, 0, tr, tg, tb)
ctab_middle.Replace(0, 0, 0, tr, tg, tb)
self.ctab_left_bmp = wx.Bitmap(ctab_left)
self.ctab_right_bmp = wx.Bitmap(ctab_right)
self.ctab_middle_bmp = wx.Bitmap(ctab_middle)
self.ctab_close_bmp = wx.Bitmap(self.ctab_close)
def ComposeTabBack(self):
"""
Creates the tab background bitmap based upon calculated dimension values
and modified bitmaps via InitBitmaps()
"""
bk_bmp = wx.Bitmap(self.tab_width, self.tab_height)
mdc = wx.MemoryDC()
mdc.SelectObject(bk_bmp)
mdc.Clear()
# draw the left bitmap
mdc.DrawBitmap(self.ctab_left_bmp, 0, 0)
# convert middle bitmap and scale to tab width
cm = self.ctab_middle_bmp.ConvertToImage()
mimg = cm.Scale(self.content_width, self.ctab_middle.GetHeight(),
wx.IMAGE_QUALITY_NORMAL)
mbmp = wx.Bitmap(mimg)
# draw middle bitmap, offset by left
mdc.DrawBitmap(mbmp, self.left_width, 0)
# draw right bitmap offset by left + middle
mdc.DrawBitmap(self.ctab_right_bmp,
self.content_width + self.left_width, 0)
mdc.SelectObject(wx.NullBitmap)
if self.tab_back_bitmap:
del self.tab_back_bitmap
self.tab_back_bitmap = bk_bmp
def InitTabRegions(self):
"""
Initializes regions for tab, which makes it easier to determine if
given coordinates are included in a region
"""
self.tab_region = wx.Region(self.tab_back_bitmap)
self.close_region = wx.Region(self.ctab_close_bmp)
x_offset = self.content_width \
+ self.left_width \
- self.ctab_close_bmp.GetWidth() / 2
y_offset = (self.tab_height - self.ctab_close_bmp.GetHeight()) / 2
self.close_region.Offset(x_offset, y_offset)
def InitColors(self):
"""Determines colors used for tab, based on system settings"""
self.tab_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DFACE)
self.inactive_color = color_utils.GetSuitable(self.tab_color, 0.25)
self.selected_color = color_utils.GetSuitable(self.tab_color, 0.10)
def Render(self):
return self.tab_bitmap
def _Render(self):
"""Renders the tab, complete with the icon, text, and close button"""
if self.tab_bitmap:
del self.tab_bitmap
height = self.tab_height
canvas = wx.Bitmap(self.tab_width, self.tab_height, 24)
mdc = wx.MemoryDC()
mdc.SelectObject(canvas)
mdc.Clear()
mdc.DrawBitmap(self.tab_back_bitmap, 0, 0, True)
# draw the tab icon
if self.tab_img:
bmp = wx.Bitmap(self.tab_img.ConvertToGreyscale() if self.disabled else self.tab_img)
# @todo: is this conditional relevant anymore?
if self.content_width > 16:
# Draw tab icon
mdc.DrawBitmap(
bmp,
self.left_width + self.padding - bmp.GetWidth() / 2,
(height - bmp.GetHeight()) / 2)
# draw close button
if self.closeable:
if self.close_btn_hovering:
cbmp = self.ctab_close_bmp
else:
cimg = self.ctab_close_bmp.ConvertToImage()
cimg = cimg.AdjustChannels(0.7, 0.7, 0.7, 0.3)
cbmp = wx.Bitmap(cimg)
mdc.DrawBitmap(
cbmp,
self.content_width + self.left_width - cbmp.GetWidth() / 2,
(height - cbmp.GetHeight()) / 2)
mdc.SelectObject(wx.NullBitmap)
canvas.SetMaskColour((0x12, 0x23, 0x32))
img = canvas.ConvertToImage()
if not img.HasAlpha():
img.InitAlpha()
bmp = wx.Bitmap(img)
self.tab_bitmap = bmp
# We draw the text separately in order to draw it directly on the native DC, rather than a memory one, because
# drawing text on a memory DC draws it blurry on HD/Retina screens
def DrawText(self, dc):
height = self.tab_height
dc.SetFont(self.font)
if self.tab_img:
text_start = self.left_width + self.padding + self.tab_img.GetWidth() / 2
else:
text_start = self.left_width
maxsize = self.tab_width \
- text_start \
- self.right_width \
- self.padding * 4
color = self.selected_color if self.selected else self.inactive_color
dc.SetTextForeground(color_utils.GetSuitable(color, 1))
# draw text (with no ellipses)
text = draw.GetPartialText(dc, self.text, maxsize, "")
tx, ty = dc.GetTextExtent(text)
dc.DrawText(text, text_start + self.padding, height / 2 - ty / 2)
def __repr__(self):
return "_TabRenderer(text={}, disabled={}) at {}".format(
self.text, self.disabled, hex(id(self))
)
class _AddRenderer:
def __init__(self):
"""Renders the add tab button"""
self.add_img = BitmapLoader.getImage("ctabadd", "gui")
self.width = self.add_img.GetWidth()
self.height = self.add_img.GetHeight()
self.region = None
self.tbmp = wx.Bitmap(self.add_img)
self.add_bitmap = None
self.position = (0, 0)
self.highlighted = False
self.InitRenderer()
def GetPosition(self):
return self.position
def SetPosition(self, pos):
self.position = pos
def GetSize(self):
return self.width, self.height
def GetHeight(self):
return self.height
def GetWidth(self):
return self.width
def InitRenderer(self):
self.region = self.CreateRegion()
self._Render()
def CreateRegion(self):
region = wx.Region(self.tbmp)
return region
def CopyRegion(self, region):
rect = region.GetBox()
new_region = wx.Region(rect.X, rect.Y, rect.Width, rect.Height)
new_region.Intersect(region)
return new_region
def GetRegion(self):
return self.CopyRegion(self.region)
def Highlight(self, highlight=False):
self.highlighted = highlight
self._Render()
def IsHighlighted(self):
return self.highlighted
def Render(self):
return self.add_bitmap
def _Render(self):
if self.add_bitmap:
del self.add_bitmap
alpha = 1 if self.highlighted else 0.3
img = self.add_img.AdjustChannels(1, 1, 1, alpha)
bmp = wx.Bitmap(img)
self.add_bitmap = bmp
class _TabsContainer(wx.Panel):
def __init__(self, parent, pos=(50, 0), size=(100, 22), id=wx.ID_ANY,
can_add=True, tabWidthMode=0):
"""
Defines the tab container. Handles functions such as tab selection and
dragging, and defines minimum width of tabs (all tabs are of equal
width, which is determined via widest tab). Also handles the tab
preview, if any.
"""
super().__init__(parent, id, pos, size)
self.tabWidthMode = tabWidthMode
self.tabs = []
self.width, self.height = size
self.container_height = self.height
self.start_drag = False
self.dragging = False
# amount of overlap of tabs?
self.inclination = 7
if can_add:
self.reserved = 48
else:
self.reserved = self.inclination * 4
# pixel distance to drag before we actually start dragging
self.drag_trail = 5
self.dragx = 0
self.dragy = 0
self.dragged_tab = None
self.drag_trigger = self.drag_trail
self.show_add_button = can_add
self.tab_container_width = self.width - self.reserved
self.fxBmps = {}
self.add_button = _AddRenderer()
self.add_bitmap = self.add_button.Render()
self.preview_timer = None
self.preview_timer_id = wx.ID_ANY
self.preview_wnd = None
self.preview_bmp = None
self.preview_pos = None
self.preview_tab = None
self.Bind(wx.EVT_TIMER, self.OnTimer)
self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeaveWindow)
self.Bind(wx.EVT_PAINT, self.OnPaint)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnErase)
self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
self.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
self.Bind(wx.EVT_MIDDLE_UP, self.OnMiddleUp)
self.Bind(wx.EVT_MOTION, self.OnMotion)
self.Bind(wx.EVT_SIZE, self.OnSize)
self.Bind(wx.EVT_SYS_COLOUR_CHANGED, self.OnSysColourChanged)
self.SetBackgroundStyle(wx.BG_STYLE_PAINT)
def OnSysColourChanged(self, event):
for tab in self.tabs:
tab.InitTab()
self.Refresh()
def OnSize(self, event):
self.UpdateSize()
event.Skip()
def UpdateSize(self):
"""Update tab sizes based on new tab container size"""
width, _ = self.GetSize()
if width != self.width:
self.width = width
self.tab_container_width = self.width - self.reserved
self.AdjustTabsSize()
def OnLeftDown(self, event):
""" Select tab on mouse down and start dragging logic """
mposx, mposy = event.GetPosition()
if not self.start_drag:
tab = self.FindTabAtPos(mposx, mposy)
if tab:
self.CheckTabSelected(tab, mposx, mposy)
if self.show_add_button:
# If we can add tabs, we can drag them. Set flag
self.start_drag = True
tx, ty = tab.GetPosition()
self.dragx = mposx - tx
self.dragy = self.container_height - self.height
self.Refresh()
self.dragged_tab = tab
def OnMiddleUp(self, event):
mposx, mposy = event.GetPosition()
tab = self.FindTabAtPos(mposx, mposy)
if tab is None or not tab.closeable: # if not able to close, return False
return False
index = self.tabs.index(tab)
ev = PageClosing(index)
wx.PostEvent(self.Parent, ev)
if ev.isVetoed():
return False
index = self.GetTabIndex(tab)
self.Parent.DeletePage(index)
wx.PostEvent(self.Parent, PageClosed(index=index))
sel = self.GetSelected()
if sel is not None:
wx.PostEvent(self.Parent, PageChanged(-1, sel))
def OnMotion(self, event):
"""
Determines what happens when the mouse moves. This handles primarily
dragging (region tab can be dragged) as well as checking if we are over
an actionable button.
"""
mposx, mposy = event.GetPosition()
if self.start_drag:
if not self.dragging:
if self.drag_trigger < 0:
self.dragging = True
self.drag_trigger = self.drag_trail
self.CaptureMouse()
else:
self.drag_trigger -= 1
if self.dragging:
# we wish to keep tab within tab container boundaries. To do
# this, we must detect when mouse moves outside of boundaries.
# Set hard limits to 0 and the size of tab container.
dtx = mposx - self.dragx
w, h = self.dragged_tab.GetSize()
dtx = max(dtx, 0)
if dtx + w > self.tab_container_width + self.inclination * 2:
dtx = self.tab_container_width - w + self.inclination * 2
self.dragged_tab.SetPosition((dtx, self.dragy))
# we must modify the surrounding tabs
index = self.GetTabIndex(self.dragged_tab)
left_tab = self.GetTabAtLeft(index)
right_tab = self.GetTabAtRight(index)
if left_tab:
lw, lh = left_tab.GetSize()
lx, ly = left_tab.GetPosition()
if lx + lw / 2 - self.inclination * 2 > dtx:
self.SwitchTabs(index - 1, index, self.dragged_tab)
if right_tab:
rw, rh = right_tab.GetSize()
rx, ry = right_tab.GetPosition()
if rx + rw / 2 + self.inclination * 2 < dtx + w:
self.SwitchTabs(index + 1, index, self.dragged_tab)
self.UpdateTabsPosition(self.dragged_tab)
self.Refresh()
return
# If we aren't dragging, check for actionable buttons under mouse
self.CheckCloseHighlighted(mposx, mposy)
self.CheckAddHighlighted(mposx, mposy)
self.CheckTabPreview(mposx, mposy)
event.Skip()
def OnLeftUp(self, event):
"""Determines what happens when user left clicks (up)"""
mposx, mposy = event.GetPosition()
if self.start_drag and self.dragging:
self.dragging = False
self.start_drag = False
self.dragged_tab = None
self.drag_trigger = self.drag_trail
self.UpdateTabsPosition()
self.Refresh()
if self.HasCapture():
self.ReleaseMouse()
return
if self.start_drag:
self.start_drag = False
self.drag_trigger = self.drag_trail
# Checks if we selected the add button and, if True, returns
if self.CheckAddButton(mposx, mposy):
return
# If there are no tabs, don't waste time
if self.GetTabsCount() == 0:
return
# Gets selected tab (was set when user down clicked)
sel_tab = self.GetSelectedTab()
# Check if we selected close button for selected tab
if self.CheckTabClose(sel_tab, mposx, mposy):
return
# Check if we selected close button for all others
for tab in self.tabs:
if self.CheckTabClose(tab, mposx, mposy):
return
def DisableTab(self, tab, disabled=True):
tb_renderer = self.tabs[tab]
tb_renderer.disabled = disabled
self.AdjustTabsSize()
self.Refresh()
def GetSelectedTab(self):
for tab in self.tabs:
if tab.GetSelected():
return tab
return None
def GetSelected(self):
for tab in self.tabs:
if tab.GetSelected():
return self.tabs.index(tab)
return None
def SetSelected(self, tabIndex):
"""Set tab as selected given its index"""
old_sel_tab = self.GetSelectedTab()
old_sel_tab.SetSelected(False)
self.tabs[tabIndex].SetSelected(True)
self.Refresh()
def CheckTabSelected(self, tab, x, y):
"""
Selects the tab at x, y. If the tab at x, y is already selected, simply
return true. Otherwise, perform TabHitTest and set tab at position to
selected
"""
old_sel_tab = self.GetSelectedTab()
if old_sel_tab == tab:
return True
if self.TabHitTest(tab, x, y):
if tab.disabled:
return
tab.SetSelected(True)
old_sel_tab.SetSelected(False)
ev = PageChanging(self.tabs.index(old_sel_tab), self.tabs.index(tab))
wx.PostEvent(self.Parent, ev)
if ev.isVetoed():
return False
self.Refresh()
sel_tab = self.tabs.index(tab)
self.Parent.SetSelection(sel_tab)
wx.PostEvent(self.Parent, PageChanged(self.tabs.index(old_sel_tab),
self.tabs.index(tab)))
return True
return False
def CheckTabClose(self, tab, x, y):
"""
Determines if close button was selected for the given tab by comparing
x and y position with known close button region
"""
if not tab.closeable: # if not able to close, return False
return False
region = tab.GetCloseButtonRegion()
posx, posy = tab.GetPosition()
region.Offset(posx, posy)
if region.Contains(x, y):
index = self.tabs.index(tab)
ev = PageClosing(index)
wx.PostEvent(self.Parent, ev)
if ev.isVetoed():
return False
index = self.GetTabIndex(tab)
self.Parent.DeletePage(index)
wx.PostEvent(self.Parent, PageClosed(index=index))
sel = self.GetSelected()
if sel is not None:
wx.PostEvent(self.Parent, PageChanged(-1, sel))
return True
return False
def CheckAddButton(self, x, y):
"""
Determines if add button was selected by comparing x and y position with
add button region
"""
if not self.show_add_button: # if not able to add, return False
return
region = self.add_button.GetRegion()
ax, ay = self.add_button.GetPosition()
region.Offset(ax, ay)
if region.Contains(x, y):
ev = PageAdding()
wx.PostEvent(self.Parent, ev)
if ev.isVetoed():
return False
self.Parent.AddPage()
wx.PostEvent(self.Parent, PageAdded())
return True
def CheckCloseHighlighted(self, x, y):
"""
Checks if mouse pos at x, y is over a close button. If so, set the
close hovering flag for that tab
"""
dirty = False
for tab in self.tabs:
region = tab.GetCloseButtonRegion()
posx, posy = tab.GetPosition()
region.Offset(posx, posy)
if region.Contains(x, y):
if not tab.GetCloseButtonHoverStatus():
tab.ShowCloseButtonHovering(True)
dirty = True
else:
if tab.GetCloseButtonHoverStatus():
tab.ShowCloseButtonHovering(False)
dirty = True
if dirty:
self.Refresh()
break
def FindTabAtPos(self, x, y):
if self.GetTabsCount() == 0:
return None
# test current tab first
sel_tab = self.GetSelectedTab()
if self.TabHitTest(sel_tab, x, y):
return sel_tab
# test all other tabs next
for tab in self.tabs:
if self.TabHitTest(tab, x, y):
return tab
return None
def TabHitTest(self, tab, x, y):
""" Test if x and y are contained within a tabs region """
tabRegion = tab.GetTabRegion()
tabPos = tab.GetPosition()
tabPosX, tabPosY = tabPos
tabRegion.Offset(tabPosX, tabPosY)
if tabRegion.Contains(x, y):
return True
return False
def GetTabAtLeft(self, tab_index):
return self.tabs[tab_index - 1] if tab_index > 0 else None
def GetTabAtRight(self, tab_index):
if tab_index < self.GetTabsCount() - 1:
return self.tabs[tab_index + 1]
else:
return None
def SwitchTabs(self, src, dst, dragged_tab=None):
self.tabs[src], self.tabs[dst] = self.tabs[dst], self.tabs[src]
self.UpdateTabsPosition(dragged_tab)
self.Parent.SwitchPages(src, dst)
self.Refresh()
def GetTabIndex(self, tab):
return self.tabs.index(tab)
def CheckTabPreview(self, mposx, mposy):
"""
Checks to see if we have a tab preview and sets up the timer for it
to display
"""
sFit = Fit.getInstance()
if not sFit.serviceFittingOptions["showTooltip"] or False:
return
if self.preview_timer:
if self.preview_timer.IsRunning():
if self.preview_wnd:
self.preview_timer.Stop()
return
if self.preview_wnd:
self.preview_wnd.Show(False)
del self.preview_wnd
self.preview_wnd = None
for tab in self.tabs:
if not tab.GetSelected():
if self.TabHitTest(tab, mposx, mposy):
try:
page = self.Parent.GetPage(self.GetTabIndex(tab))
if hasattr(page, "Snapshot"):
if not self.preview_timer:
self.preview_timer = wx.Timer(
self, self.preview_timer_id)
self.preview_tab = tab
self.preview_timer.Start(500, True)
break
except (KeyboardInterrupt, SystemExit):
raise
except:
pass
def CheckAddHighlighted(self, x, y):
"""
Checks to see if x, y are in add button region, and sets the highlight
flag
"""
if not self.show_add_button:
return
region = self.add_button.GetRegion()
ax, ay = self.add_button.GetPosition()
region.Offset(ax, ay)
if region.Contains(x, y):
if not self.add_button.IsHighlighted():
self.add_button.Highlight(True)
self.Refresh()
else:
if self.add_button.IsHighlighted():
self.add_button.Highlight(False)
self.Refresh()
def OnPaint(self, event):
mdc = wx.AutoBufferedPaintDC(self)
# if 'wxMac' in wx.PlatformInfo:
# color = wx.Colour(0, 0, 0)
# brush = wx.Brush(color)
# # @todo: what needs to be changed with wxPheonix?
# from Carbon.Appearance import kThemeBrushDialogBackgroundActive
# brush.MacSetTheme(kThemeBrushDialogBackgroundActive)
# else:
color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DFACE)
brush = wx.Brush(color)
if "wxGTK" not in wx.PlatformInfo:
mdc.SetBackground(brush)
mdc.Clear()
selected = None
if self.show_add_button:
ax, ay = self.add_button.GetPosition()
mdc.DrawBitmap(self.add_button.Render(), ax, ay, True)
for i in range(len(self.tabs) - 1, -1, -1):
tab = self.tabs[i]
posx, posy = tab.GetPosition()
if not tab.IsSelected():
# drop shadow first
mdc.DrawBitmap(self.fxBmps[tab], posx, posy, True)
bmp = tab.Render()
img = bmp.ConvertToImage()
img = img.AdjustChannels(1, 1, 1, 0.85)
bmp = wx.Bitmap(img)
mdc.DrawBitmap(bmp, posx, posy, True)
mdc.SetDeviceOrigin(posx, posy)
tab.DrawText(mdc)
mdc.SetDeviceOrigin(0, 0)
else:
selected = tab
# we draw selected tab separately (instead of in else) so as to not hide
# the front bit behind the preceding tab
if selected:
posx, posy = selected.GetPosition()
# drop shadow first
mdc.DrawBitmap(self.fxBmps[selected], posx, posy, True)
bmp = selected.Render()
if self.dragging:
img = bmp.ConvertToImage()
img = img.AdjustChannels(1.2, 1.2, 1.2, 0.7)
bmp = wx.Bitmap(img)
mdc.DrawBitmap(bmp, posx, posy, True)
mdc.SetDeviceOrigin(posx, posy)
selected.DrawText(mdc)
mdc.SetDeviceOrigin(0, 0)
def OnErase(self, event):
pass
def UpdateTabFX(self):
""" Updates tab drop shadow bitmap """
self.fxBmps.clear()
for tab in self.tabs:
tabW, tabH = tab.tab_size
self.fxBmps[tab] = self.GetTabFx(tabW, self.height + 1)
@lru_cache(maxsize=50)
def GetTabFx(self, width, height):
renderer = _TabRenderer((width, height))
fx_bmp = renderer.Render()
img = fx_bmp.ConvertToImage()
if not img.HasAlpha():
img.InitAlpha()
img = img.Blur(2)
img = img.AdjustChannels(0.3, 0.3, 0.3, 0.35)
return wx.Bitmap(img)
def AddTab(self, title=wx.EmptyString, img=None, closeable=False):
self.ClearTabsSelected()
tab_renderer = _TabRenderer((200, self.height), title, img, closeable)
tab_renderer.SetSelected(True)
self.tabs.append(tab_renderer)
self.AdjustTabsSize()
self.Refresh()
def ClearTabsSelected(self):
for tab in self.tabs:
tab.SetSelected(False)
def DeleteTab(self, tab):
tab_renderer = self.tabs[tab]
was_selected = tab_renderer.GetSelected()
self.tabs.remove(tab_renderer)
if tab_renderer:
del tab_renderer
# determine our new selection
if was_selected and self.GetTabsCount() > 0:
if tab > self.GetTabsCount() - 1:
self.tabs[self.GetTabsCount() - 1].SetSelected(True)
else:
self.tabs[tab].SetSelected(True)
self.AdjustTabsSize()
self.Refresh()
def GetTabsCount(self):
return len(self.tabs)
def AdjustTabsSize(self):
"""
Adjust tab sizes to ensure that they are all consistent and can fit into
the tab container.
"""
if self.tabWidthMode == 1:
if self.GetTabsCount() > 0:
availableW = self.tab_container_width
overlapSavedW = max(0, len(self.tabs)) * self.inclination * 2
tabsGrouped = {}
for tab in self.tabs:
tabW, _ = tab.GetMinSize()
tabsGrouped.setdefault(math.ceil(tabW), []).append(tab)
clippedTabs = []
clipW = max(tabsGrouped, default=0)
def getUnclippedW():
unclippedW = 0
for w, tabs in tabsGrouped.items():
unclippedW += w * len(tabs)
return unclippedW
while tabsGrouped:
# Check if we're within width limit
neededW = 0
for w, tabs in tabsGrouped.items():
neededW += w * len(tabs)
if clippedTabs:
neededW += clipW * len(clippedTabs)
if neededW <= availableW + overlapSavedW:
break
# If we're not, extract widest tab group and mark it for clipping
currentTabs = tabsGrouped.pop(max(tabsGrouped))
clippedTabs.extend(currentTabs)
proposedClipWidth = math.floor((availableW + overlapSavedW - getUnclippedW()) / len(clippedTabs))
if not tabsGrouped or proposedClipWidth >= max(tabsGrouped, default=0):
clipW = max(0, proposedClipWidth)
break
else:
clipW = max(tabsGrouped)
# Assign width for unclipped tabs
for w, tabs in tabsGrouped.items():
for tab in tabs:
tab.SetSize((w, self.height))
if clippedTabs:
# Some width remains to be used due to rounding to integer
extraWTotal = availableW + overlapSavedW - getUnclippedW() - clipW * len(clippedTabs)
extraWPerTab = math.ceil(extraWTotal / len(clippedTabs))
# Assign width for clipped tabs
for tab in clippedTabs:
extraW = min(extraWTotal, extraWPerTab)
extraWTotal -= extraW
tab.SetSize((clipW + extraW, self.height))
else:
# first we loop through our tabs and calculate the the largest tab. This
# is the size that we will base our calculations off
max_width = 100 # Tab should be at least 100
for tab in self.tabs:
mw, _ = tab.GetMinSize() # Tab min size includes tab contents
max_width = max(mw, max_width)
tabWidth = 0
# Divide tab container by number of tabs and add inclination. This will
# return the ideal max size for the containers size
if self.GetTabsCount() > 0:
dx = self.tab_container_width / self.GetTabsCount() + self.inclination * 2
tabWidth = min(dx, max_width)
# Apply new size to all tabs
for tab in self.tabs:
tab.SetSize((tabWidth, self.height))
# update drop shadow based on new sizes
self.UpdateTabFX()
self.UpdateTabsPosition()
def UpdateTabsPosition(self, skip_tab=None):
tabsWidth = 0
for tab in self.tabs:
tabsWidth += tab.tab_width - self.inclination * 2
pos = tabsWidth
selected = None
for i in range(len(self.tabs) - 1, -1, -1):
tab = self.tabs[i]
width = tab.tab_width - self.inclination * 2
pos -= width
if not tab.IsSelected():
tab.SetPosition((pos, self.container_height - self.height))
else:
selected = tab
selpos = pos
if selected is not skip_tab:
selected.SetPosition((selpos, self.container_height - self.height))
self.add_button.SetPosition((round(tabsWidth) + self.inclination * 2,
self.container_height - self.height / 2 - self.add_button.GetHeight() / 3))
def OnLeaveWindow(self, event):
if self.start_drag and not self.dragging:
self.dragging = False
self.start_drag = False
self.dragged_tab = None
self.drag_trigger = self.drag_trail
if self.HasCapture():
self.ReleaseMouse()
if self.preview_wnd:
self.preview_wnd.Show(False)
del self.preview_wnd
self.preview_wnd = None
event.Skip()
def OnTimer(self, event):
mposx, mposy = wx.GetMousePosition()
cposx, cposy = self.ScreenToClient((mposx, mposy))
if self.FindTabAtPos(cposx, cposy) == self.preview_tab:
if not self.preview_tab.GetSelected():
page = self.Parent.GetPage(self.GetTabIndex(self.preview_tab))
if page.Snapshot():
self.preview_wnd = PFNotebookPagePreview(
self,
(mposx + 3, mposy + 3),
page.Snapshot(),
self.preview_tab.text)
self.preview_wnd.Show()
event.Skip()
class PFNotebookPagePreview(wx.Frame):
def __init__(self, parent, pos, bitmap, title):
super().__init__(parent, id=wx.ID_ANY, title=wx.EmptyString, pos=pos,
size=wx.DefaultSize, style=wx.NO_BORDER |
wx.FRAME_NO_TASKBAR |
wx.STAY_ON_TOP)
self.SetBackgroundStyle(wx.BG_STYLE_PAINT)
self.title = title
self.bitmap = bitmap
self.SetSize((bitmap.GetWidth(), bitmap.GetHeight()))
self.Bind(wx.EVT_PAINT, self.OnWindowPaint)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnWindowEraseBk)
self.Bind(wx.EVT_TIMER, self.OnTimer)
self.timer = wx.Timer(self, wx.ID_ANY)
self.timerSleep = None
self.timerSleepId = wx.NewId()
self.direction = 1
self.padding = 15
self.transp = 0
hfont = wx.Font(fonts.NORMAL, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False)
self.SetFont(hfont)
tx, ty = self.GetTextExtent(self.title)
tx += self.padding * 2
if bitmap.GetWidth() < tx:
width = tx
else:
width = bitmap.GetWidth()
self.SetSize((width, bitmap.GetHeight() + 16))
self.SetTransparent(0)
self.Refresh()
def OnTimer(self, event):
self.transp += 20 * self.direction
if self.transp > 220:
self.transp = 220
self.timer.Stop()
if self.transp < 0:
self.transp = 0
self.timer.Stop()
super().Show(False)
self.Destroy()
return
self.SetTransparent(self.transp)
def RaiseParent(self):
wnd = self
lastwnd = None
while wnd is not None:
lastwnd = wnd
wnd = wnd.Parent
if lastwnd:
lastwnd.Raise()
def Show(self, showWnd=True):
if showWnd:
super().Show(showWnd)
self.RaiseParent()
self.direction = 1
self.timer.Start(10)
else:
self.direction = -1
self.timer.Start(10)
def OnWindowEraseBk(self, event):
pass
def OnWindowPaint(self, event):
rect = self.GetRect()
canvas = wx.Bitmap(rect.width, rect.height)
mdc = wx.BufferedPaintDC(self)
mdc.SelectObject(canvas)
color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
mdc.SetBackground(wx.Brush(color))
mdc.Clear()
font = wx.Font(11, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False)
mdc.SetFont(font)
x, y = mdc.GetTextExtent(self.title)
mdc.SetBrush(wx.Brush(wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)))
mdc.DrawRectangle(0, 0, rect.width, 16)
mdc.SetTextForeground(wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW))
mdc.DrawBitmap(self.bitmap, 0, 16)
mdc.SetPen(wx.Pen("#000000", width=1))
mdc.SetBrush(wx.TRANSPARENT_BRUSH)
mdc.DrawRectangle(0, 16, rect.width, rect.height - 16)