""" Custom `ttk.Notebook` to fix various display issues. Hacks to fix various display issues with notebooks and their child widgets on Windows. - Windows: page background should be White, not SystemButtonFace Entire file may be imported by plugins. """ from __future__ import annotations import sys import tkinter as tk from tkinter import ttk, messagebox from PIL import ImageGrab from l10n import translations as tr from collections.abc import Callable if sys.platform == 'win32': PAGEFG = 'SystemWindowText' PAGEBG = 'SystemWindow' # typically white class Notebook(ttk.Notebook): """Custom ttk.Notebook class to fix some display issues.""" def __init__(self, master: ttk.Frame | None = None, **kw): super().__init__(master, **kw) style = ttk.Style() if sys.platform == 'win32': style.configure('nb.TFrame', background=PAGEBG) style.configure('nb.TButton', background=PAGEBG) style.configure('nb.TCheckbutton', foreground=PAGEFG, background=PAGEBG) style.configure('nb.TMenubutton', foreground=PAGEFG, background=PAGEBG) style.configure('nb.TRadiobutton', foreground=PAGEFG, background=PAGEBG) self.grid(padx=10, pady=10, sticky=tk.NSEW) class Frame(ttk.Frame): """Custom ttk.Frame class to fix some display issues.""" def __init__(self, master: ttk.Notebook | None = None, **kw): style = 'nb.TFrame' if sys.platform == 'win32' else None super().__init__(master, style=style, **kw) # type: ignore ttk.Frame(self).grid(pady=5) # Top spacer self.configure(takefocus=1) # let the frame take focus so that no particular child is focused class Label(ttk.Label): """Custom ttk.Label class to fix some display issues.""" def __init__(self, master: ttk.Frame | None = None, **kw): if sys.platform == 'win32': fg_color = PAGEFG else: fg_color = ttk.Style().lookup('TLabel', 'foreground') if sys.platform == 'win32': bg_color = PAGEBG else: bg_color = ttk.Style().lookup('TLabel', 'background') kw['foreground'] = kw.pop('foreground', fg_color) kw['background'] = kw.pop('background', bg_color) super().__init__(master, **kw) class EntryMenu(ttk.Entry): """Extended entry widget that includes a context menu with Copy, Cut-and-Paste commands.""" def __init__(self, *args, **kwargs) -> None: ttk.Entry.__init__(self, *args, **kwargs) self.menu = tk.Menu(self, tearoff=False) self.menu.add_command(label=tr.tl("Copy"), command=self.copy) # LANG: Label for 'Copy' as in 'Copy and Paste' self.menu.add_command(label=tr.tl("Cut"), command=self.cut) # LANG: Label for 'Cut' as in 'Cut and Paste' self.menu.add_separator() # LANG: Label for 'Paste' as in 'Copy and Paste' self.menu.add_command(label=tr.tl("Paste"), command=self.paste) self.menu.add_separator() # LANG: Label for 'Select All' self.menu.add_command(label=tr.tl("Select All"), command=self.select_all) self.bind("", self.display_popup) def display_popup(self, event: tk.Event) -> None: """Display the menu popup.""" self.menu.post(event.x_root, event.y_root) def select_all(self) -> None: """Select all the text within the Entry.""" self.selection_range(0, tk.END) self.focus_set() def copy(self) -> None: """Copy the selected Entry text.""" if self.selection_present(): self.clipboard_clear() self.clipboard_append(self.selection_get()) def cut(self) -> None: """Cut the selected Entry text.""" if self.selection_present(): self.copy() self.delete(tk.SEL_FIRST, tk.SEL_LAST) def paste(self) -> None: """Paste the selected Entry text.""" try: # Attempt to grab an image from the clipboard (apprently also works for files) img = ImageGrab.grabclipboard() if img: # Hijack existing translation, yes it doesn't exactly match here. messagebox.showwarning( tr.tl('Error'), # LANG: Generic error prefix - following text is from Frontier auth service; tr.tl('Cannot paste non-text content.'), # LANG: Can't Paste Images or Files in Text parent=self.master ) return text = self.clipboard_get() if self.selection_present() and text: self.delete(tk.SEL_FIRST, tk.SEL_LAST) self.insert(tk.INSERT, text) except tk.TclError: # No text in clipboard or clipboard is not text pass class Button(ttk.Button): """Custom ttk.Button class to fix some display issues.""" def __init__(self, master: ttk.Frame | None = None, **kw): style = 'nb.TButton' if sys.platform == 'win32' else None super().__init__(master, style=style, **kw) # type: ignore class Checkbutton(ttk.Checkbutton): """Custom ttk.Checkbutton class to fix some display issues.""" def __init__(self, master: ttk.Frame | None = None, **kw): style = 'nb.TCheckbutton' if sys.platform == 'win32' else None super().__init__(master, style=style, **kw) # type: ignore class Radiobutton(ttk.Radiobutton): """Custom ttk.Radiobutton class to fix some display issues.""" def __init__(self, master: ttk.Frame | None = None, **kw): style = 'nb.TRadiobutton' if sys.platform == 'win32' else None super().__init__(master, style=style, **kw) # type: ignore class OptionMenu(ttk.OptionMenu): """Custom ttk.OptionMenu class to fix some display issues.""" def __init__(self, master, variable, default=None, *values, **kw): if sys.platform == 'win32': # OptionMenu derives from Menubutton at the Python level, so uses Menubutton's style ttk.OptionMenu.__init__(self, master, variable, default, *values, style='nb.TMenubutton', **kw) self['menu'].configure(background=PAGEBG) else: ttk.OptionMenu.__init__(self, master, variable, default, *values, **kw) self['menu'].configure(background=ttk.Style().lookup('TMenu', 'background')) # Workaround for https://bugs.python.org/issue25684 for i in range(0, self['menu'].index('end') + 1): self['menu'].entryconfig(i, variable=variable) class ScrollableNotebook(Notebook): """ ScrollableNotebook – A tab bar that scrolls horizontally when there are too many tabs. Based off ttkScrollableNotebook by @muhammeteminturgut. https://github.com/muhammeteminturgut/ttkScrollableNotebook """ def __init__( self, master: ttk.Frame | None = None, tabmenu: bool = False, on_tab_change: Callable | None = None, *args, **kwargs ) -> None: super().__init__(master, **kwargs) # Sliding state self.xLocation: int = 0 self.timer: str | None = None self.menuSpace: int = 50 if tabmenu else 30 self.contentsManaged: list[tk.Widget] = [] # Notebook that holds the actual content widgets self.notebookContent: ttk.Notebook = ttk.Notebook(self, **kwargs) self.notebookContent.pack(fill="both", expand=True) self.notebookContent.bind("", self._reset_slide) # Notebook that displays scrollable tabs self.notebookTab: ttk.Notebook = ttk.Notebook(self, **kwargs) self.notebookTab.place(x=0, y=0) self.notebookTab.bind("<>", self._tab_changer) if on_tab_change: self.notebookTab.bind("<>", on_tab_change, add='+') # Sliding frame and controls slide_frame: ttk.Frame = ttk.Frame(self) slide_frame.place(relx=1.0, x=0, y=1, anchor=tk.NE) if tabmenu: menu_btn = ttk.Label(slide_frame, text="\u2630") menu_btn.bind("", self._bottom_menu) menu_btn.pack(side=tk.RIGHT) left_arrow = ttk.Label(slide_frame, text=" \u276e") left_arrow.bind("", self._left_slide_start) left_arrow.bind("", self._slide_stop) left_arrow.pack(side=tk.LEFT) right_arrow = ttk.Label(slide_frame, text=" \u276f") right_arrow.bind("", self._right_slide_start) right_arrow.bind("", self._slide_stop) right_arrow.pack(side=tk.RIGHT) # Outer Notebook geometry self.grid(padx=10, pady=10, sticky=tk.NSEW) def _bottom_menu(self, event: tk.Event) -> None: menu = tk.Menu(self, tearoff=0) for tab in self.notebookTab.tabs(): label = self.notebookTab.tab(tab, option="text") menu.add_command(label=label, command=lambda t=tab: self.select(t)) # type: ignore try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() def _tab_changer(self, event: tk.Event) -> None: try: idx = self.notebookTab.index("current") self.notebookContent.select(idx) except Exception: pass def _right_slide_start(self, event: tk.Event | None = None) -> None: if self._right_slide(event): self.timer = self.after(100, self._right_slide_start) def _right_slide(self, event: tk.Event | None) -> bool: tabs_w = self.notebookTab.winfo_width() content_w = self.notebookContent.winfo_width() if tabs_w > content_w - self.menuSpace: remaining = content_w - (tabs_w + self.notebookTab.winfo_x()) if remaining <= self.menuSpace + 5: self.xLocation -= 20 self.notebookTab.place(x=self.xLocation, y=0) return True return False def _left_slide_start(self, event: tk.Event | None = None) -> None: if self._left_slide(event): self.timer = self.after(100, self._left_slide_start) def _left_slide(self, event: tk.Event | None) -> bool: if self.notebookTab.winfo_x() != 0: self.xLocation += 20 self.notebookTab.place(x=self.xLocation, y=0) return True return False def _slide_stop(self, event: tk.Event) -> None: if self.timer is not None: self.after_cancel(self.timer) self.timer = None def _reset_slide(self, event: tk.Event | None = None) -> None: self.notebookTab.place(x=0, y=0) self.xLocation = 0 def add(self, child: tk.Widget, **kwargs) -> None: """Add content + visual tab.""" has_tabs = len(self.notebookTab.tabs()) > 0 # Content tab properties content_kwargs = kwargs.copy() content_kwargs["text"] = "" # content tabs never show text if has_tabs: content_kwargs["state"] = "hidden" else: content_kwargs.pop("state", None) # Add content widget self.notebookContent.add(child, **content_kwargs) # Add visible tab self.notebookTab.add(ttk.Frame(self.notebookTab), **kwargs) self.contentsManaged.append(child) def __content_tab_id(self, tab_id: str) -> str: idx = self.notebookTab.tabs().index(tab_id) return self.notebookContent.tabs()[idx] def forget(self, tab_id: str) -> None: # type: ignore """Remove a tab from both the visible tab strip and the hidden content notebook.""" idx = self.notebookTab.index(tab_id) content_tab = self.__content_tab_id(tab_id) self.notebookContent.forget(content_tab) self.notebookTab.forget(tab_id) self.contentsManaged[idx].destroy() self.contentsManaged.pop(idx) def hide(self, tab_id: str) -> None: """Hide a tab without destroying it.""" self.notebookContent.hide(self.__content_tab_id(tab_id)) self.notebookTab.hide(tab_id) def identify(self, x: int, y: int) -> str | None: # type: ignore """Identify the visible tab at a given x/y coord.""" return self.notebookTab.identify(x, y) def index(self, tab_id: str) -> int: """Return the index of a visible tab.""" return self.notebookTab.index(tab_id) def insert(self, pos: int, child: tk.Widget, **kwargs) -> None: """Insert a new tab at a specified position.""" self.notebookContent.insert(pos, child, **kwargs) self.notebookTab.insert(pos, ttk.Frame(self.notebookTab), **kwargs) def select(self, tab_id: str) -> None: # type: ignore """Select a tab.""" self.notebookTab.select(tab_id) def tab(self, tab_id: str, option: str | None = None, **kwargs): """Get/Set Options for tabs.""" c_kwargs = kwargs.copy() c_kwargs["text"] = "" # content tabs never show text self.notebookContent.tab(self.__content_tab_id(tab_id), **c_kwargs) return self.notebookTab.tab(tab_id, option=option, **kwargs) def tabs(self) -> tuple[str, ...]: """Tuple of all tabs.""" return self.notebookTab.tabs() def enable_traversal(self) -> None: """Enable keyboard shortcuts for tabs.""" self.notebookContent.enable_traversal() self.notebookTab.enable_traversal()