scustomtkinter 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scustomtkinter/__init__.py +81 -0
- scustomtkinter/assets/images/error.png +0 -0
- scustomtkinter/assets/images/info.png +0 -0
- scustomtkinter/assets/images/warning.png +0 -0
- scustomtkinter/assets/sCTkThemes.json +1963 -0
- scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter/sctk_button_primary.py +366 -0
- scustomtkinter/sctk_button_secondary.py +354 -0
- scustomtkinter/sctk_button_tertiary.py +375 -0
- scustomtkinter/sctk_checkbox.py +270 -0
- scustomtkinter/sctk_combobox.py +155 -0
- scustomtkinter/sctk_core.py +15 -0
- scustomtkinter/sctk_dial.py +1472 -0
- scustomtkinter/sctk_dialog.py +701 -0
- scustomtkinter/sctk_dialog_toplevel.py +268 -0
- scustomtkinter/sctk_entry_primary.py +400 -0
- scustomtkinter/sctk_entry_secondary.py +342 -0
- scustomtkinter/sctk_file_explorer.py +879 -0
- scustomtkinter/sctk_frame.py +210 -0
- scustomtkinter/sctk_frame_labeled_primary.py +667 -0
- scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
- scustomtkinter/sctk_frequency_display.py +636 -0
- scustomtkinter/sctk_label_primary.py +283 -0
- scustomtkinter/sctk_label_secondary.py +275 -0
- scustomtkinter/sctk_label_tertiary.py +278 -0
- scustomtkinter/sctk_messagebox.py +275 -0
- scustomtkinter/sctk_notebook.py +1103 -0
- scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
- scustomtkinter/sctk_optionmenu_primary.py +378 -0
- scustomtkinter/sctk_optionmenu_secondary.py +346 -0
- scustomtkinter/sctk_path_chooser.py +516 -0
- scustomtkinter/sctk_progress_bar.py +274 -0
- scustomtkinter/sctk_radiobutton.py +360 -0
- scustomtkinter/sctk_scroll_mixin.py +546 -0
- scustomtkinter/sctk_scrollable_frame.py +795 -0
- scustomtkinter/sctk_scrollbar.py +313 -0
- scustomtkinter/sctk_segmentedbutton.py +424 -0
- scustomtkinter/sctk_selector.py +581 -0
- scustomtkinter/sctk_separator.py +308 -0
- scustomtkinter/sctk_slider.py +264 -0
- scustomtkinter/sctk_smeter.py +405 -0
- scustomtkinter/sctk_smeter_bar.py +557 -0
- scustomtkinter/sctk_spinbox.py +664 -0
- scustomtkinter/sctk_switch.py +463 -0
- scustomtkinter/sctk_tableview.py +869 -0
- scustomtkinter/sctk_tabview.py +409 -0
- scustomtkinter/sctk_textbox_primary.py +291 -0
- scustomtkinter/sctk_textbox_secondary.py +225 -0
- scustomtkinter/sctk_toplevel.py +153 -0
- scustomtkinter/themeable_widget.py +938 -0
- scustomtkinter-0.2.1.dist-info/METADATA +336 -0
- scustomtkinter-0.2.1.dist-info/RECORD +101 -0
- scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
- scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
- scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
- scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
- scustomtkinter_pygubu/__init__.py +1 -0
- scustomtkinter_pygubu/designer/__init__.py +0 -0
- scustomtkinter_pygubu/designer/plugin.py +1854 -0
- scustomtkinter_pygubu/designer/properties.py +247 -0
- scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
- scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
- scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkCorebo.py +120 -0
- scustomtkinter_pygubu/sCTkDialbo.py +371 -0
- scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
- scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
- scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
- scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
- scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
- scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
- scustomtkinter_pygubu/sCTkFramebo.py +46 -0
- scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
- scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
- scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
- scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
- scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
- scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
- scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
- scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
- scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
- scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
- scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
- scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
- scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
- scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
- scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
- scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
- scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
- scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
- scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
- scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
- scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
- scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkNotebook
|
|
4
|
+
|
|
5
|
+
A multi-page container with its tabs down the left or right edge, rather than
|
|
6
|
+
across the top. The same idea as sCTkTabview, turned ninety degrees.
|
|
7
|
+
|
|
8
|
+
WHY THIS IS NOT A SUBCLASS OF sCTkTabview. Native CTkTabview builds its tab
|
|
9
|
+
strip from a CTkSegmentedButton, which lays its buttons out in a single grid
|
|
10
|
+
ROW and offers no vertical mode. Everything about the strip -- its geometry,
|
|
11
|
+
its selection handling, its colours -- comes from that widget. Reusing it
|
|
12
|
+
would mean overriding nearly all of it, so this class owns its strip outright.
|
|
13
|
+
|
|
14
|
+
WHY EVERYTHING IS ON ONE CANVAS. Tab labels run along the strip rather than
|
|
15
|
+
across it, so the text is rotated ninety degrees, and a Tk button cannot
|
|
16
|
+
rotate its text. That alone would only need a canvas for the strip -- but the
|
|
17
|
+
page outline has to break where the selected tab meets it, the way a real
|
|
18
|
+
notebook divider does, and a border drawn by a frame cannot have a gap cut in
|
|
19
|
+
it. Outline and tabs are therefore drawn together, on a single canvas
|
|
20
|
+
spanning the whole widget, and the content pages are placed on top of it.
|
|
21
|
+
|
|
22
|
+
Selection, hover and hit-testing are consequently this class's own code
|
|
23
|
+
rather than a button's.
|
|
24
|
+
"""
|
|
25
|
+
import sys
|
|
26
|
+
import tkinter as tk
|
|
27
|
+
import tkinter.font as tkfont
|
|
28
|
+
import math
|
|
29
|
+
import customtkinter as ctk
|
|
30
|
+
|
|
31
|
+
from . import themeable_widget as _tw
|
|
32
|
+
from .themeable_widget import ThemeableWidget
|
|
33
|
+
from .sctk_frame import sCTkFrame
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class sCTkNotebook(ctk.CTkFrame, ThemeableWidget):
|
|
37
|
+
"""A tabbed container with a vertical tab strip."""
|
|
38
|
+
|
|
39
|
+
# Required at the TOP LEVEL of the theme block.
|
|
40
|
+
_REQUIRED_THEME_KEYS = (
|
|
41
|
+
"fg_color", "font", "text_color", "selected_text_color",
|
|
42
|
+
"tab_fg_color", "tab_selected_color", "tab_hover_color",
|
|
43
|
+
"border_color",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Required inside disabled_map. The strip and the outline dim; the page
|
|
47
|
+
# area does not, matching the dials and sCTkScrollableFrame -- the content
|
|
48
|
+
# carries its own state, and a greyed page would hide it.
|
|
49
|
+
_REQUIRED_DISABLED_KEYS = (
|
|
50
|
+
"text_color", "tab_fg_color", "tab_selected_color", "border_color",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Keys this widget reads itself must not reach CTkFrame, which raises on
|
|
54
|
+
# anything it does not recognise.
|
|
55
|
+
_NATIVE_FRAME_KWARGS = frozenset({
|
|
56
|
+
"width", "height", "bg_color",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
# Geometry, in UNSCALED pixels. Every one of these goes through
|
|
60
|
+
# _apply_widget_scaling() before it reaches the canvas, so the strip grows
|
|
61
|
+
# with the rest of the interface on a scaled display. Drawing raw pixels
|
|
62
|
+
# on a CTkCanvas is the easy mistake here: the canvas widget scales, the
|
|
63
|
+
# coordinates inside it do not.
|
|
64
|
+
TAB_GAP = 2 # space between adjacent tabs
|
|
65
|
+
TAB_PAD = 18 # space either end of a label, along the tab
|
|
66
|
+
TAB_CORNER = 6 # corner rounding on the outer edge of a tab
|
|
67
|
+
# An angled tab's corners are CHAMFERED -- a straight cut between a point
|
|
68
|
+
# measured in from the corner along each edge. Independent, because the
|
|
69
|
+
# two edges are not equivalent: x runs across the strip, whose width is
|
|
70
|
+
# fixed, while y runs along the tab, whose length depends on the label
|
|
71
|
+
# and on whether the text is rotated.
|
|
72
|
+
TAB_CHAMFER_X = 6 # in from the outer corner, across the strip
|
|
73
|
+
TAB_CHAMFER_Y = 6 # in from the outer corner, along the tab
|
|
74
|
+
TAB_CHAMFER_MAX_FRACTION = 0.25 # ceiling on the Y cut, per end, as a
|
|
75
|
+
# fraction of the tab's length
|
|
76
|
+
PAGE_CORNER = 8 # corner rounding on the page outline
|
|
77
|
+
PAGE_INSET = 8 # gap between the outline and a page's contents
|
|
78
|
+
BORDER_WIDTH = 2 # page outline thickness
|
|
79
|
+
STRIP_MARGIN = 6 # space above the first tab
|
|
80
|
+
SEPARATOR_INSET = 5 # how far a separator stops short of each edge
|
|
81
|
+
|
|
82
|
+
def __init__(self, master=None, side="left", tab_width=34,
|
|
83
|
+
tab_style="rounded", text_orientation="auto",
|
|
84
|
+
show_page_border=True, show_tab_separators=False,
|
|
85
|
+
state="normal", command=None, **kw):
|
|
86
|
+
"""
|
|
87
|
+
Args:
|
|
88
|
+
master: Parent container.
|
|
89
|
+
side: Which edge the strip sits on, "left" or "right".
|
|
90
|
+
tab_width: Width of the strip in pixels. Explicit rather than
|
|
91
|
+
measured, because the strip takes space from the pages and a
|
|
92
|
+
layout that shifts when a tab is renamed is worse than one the
|
|
93
|
+
caller sets.
|
|
94
|
+
tab_style: "rounded" or "angled". Rounded matches the rest of the
|
|
95
|
+
library; angled is the shape of a real notebook divider.
|
|
96
|
+
text_orientation: How the labels sit on the tabs.
|
|
97
|
+
"auto" reads outside-in -- up a left strip, down a right one,
|
|
98
|
+
the way a book's spine is set. "up" and "down" force one
|
|
99
|
+
direction whichever side the strip is on. "horizontal" lays
|
|
100
|
+
the text flat, which needs a tab_width wide enough to hold
|
|
101
|
+
the longest label.
|
|
102
|
+
show_page_border: Whether to draw the outline around the page,
|
|
103
|
+
broken where the selected tab meets it.
|
|
104
|
+
show_tab_separators: Draws a line across the strip between
|
|
105
|
+
adjacent tabs. Useful with horizontal text, where stacked
|
|
106
|
+
labels can otherwise run together; less so with rotated text,
|
|
107
|
+
where the gap alone reads clearly. Off by default.
|
|
108
|
+
state: "normal" or "disabled".
|
|
109
|
+
**kw: Native CTkFrame arguments, or theme-key overrides.
|
|
110
|
+
"""
|
|
111
|
+
self._side = self._check_side(side)
|
|
112
|
+
self._tab_width = int(tab_width)
|
|
113
|
+
self._tab_style = self._check_style(tab_style)
|
|
114
|
+
self._text_orientation = self._check_orientation(text_orientation)
|
|
115
|
+
self._show_page_border = bool(show_page_border)
|
|
116
|
+
self._show_tab_separators = bool(show_tab_separators)
|
|
117
|
+
|
|
118
|
+
ThemeableWidget.__init__(self, kw)
|
|
119
|
+
|
|
120
|
+
# Colours come from the RAW registry, not final_kw.
|
|
121
|
+
#
|
|
122
|
+
# ThemeableWidget strips its CUSTOM_VECTOR_KEYS -- text_color among
|
|
123
|
+
# them -- out of final_kw so they cannot reach a native constructor.
|
|
124
|
+
# Reading them back from final_kw finds nothing, which is the trap
|
|
125
|
+
# that left the whole dial family rendering in hardcoded fallbacks.
|
|
126
|
+
# See sCTkDial.md, "Reading theme colours".
|
|
127
|
+
raw_block = _tw.GLOBAL_THEME_REGISTRY.get(self.__class__.__name__) or {}
|
|
128
|
+
raw_colors = {k: v for k, v in raw_block.items()
|
|
129
|
+
if not isinstance(v, dict)}
|
|
130
|
+
self._local_defaults = ThemeableWidget._convert_lists_to_tuples(raw_colors)
|
|
131
|
+
self._local_defaults.update(self.final_kw)
|
|
132
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
133
|
+
self._validate_theme_keys()
|
|
134
|
+
|
|
135
|
+
native_kwargs = {k: v for k, v in self.final_kw.items()
|
|
136
|
+
if k in self._NATIVE_FRAME_KWARGS}
|
|
137
|
+
super().__init__(master, fg_color="transparent", border_width=0,
|
|
138
|
+
**native_kwargs)
|
|
139
|
+
|
|
140
|
+
self._state = "normal" if str(state).lower() == "normal" else "disabled"
|
|
141
|
+
|
|
142
|
+
# name -> sCTkFrame, in insertion order. Ordinary dicts preserve it,
|
|
143
|
+
# and tab order is insertion order.
|
|
144
|
+
self._pages = {}
|
|
145
|
+
self._current = None
|
|
146
|
+
|
|
147
|
+
# Called with the tab's name when the OPERATOR selects it. Not fired
|
|
148
|
+
# by set(): a programmatic selection is the application saying what
|
|
149
|
+
# it already knows, and firing there makes construction order
|
|
150
|
+
# load-bearing -- the lesson sCTkSpinbox.set() taught. Matches
|
|
151
|
+
# CTkTabview, whose command also fires only on a click.
|
|
152
|
+
self._command = command
|
|
153
|
+
self._hover = None # name under the pointer, or None
|
|
154
|
+
self._tab_bounds = {} # name -> (y_top, y_bottom), for hit-testing
|
|
155
|
+
self._scroll = 0 # strip scroll offset, in pixels
|
|
156
|
+
|
|
157
|
+
# NAMED self.canvas, NOT self._canvas.
|
|
158
|
+
#
|
|
159
|
+
# CTkFrame keeps its own background canvas in self._canvas, so
|
|
160
|
+
# assigning that name here replaces the frame's reference to it and
|
|
161
|
+
# its _draw() would then paint on this widget's canvas instead. The
|
|
162
|
+
# dials and the S-meters use the bare name for exactly this reason.
|
|
163
|
+
self.canvas = ctk.CTkCanvas(self, highlightthickness=0, bd=0)
|
|
164
|
+
# GRIDDED, not placed, and lowered behind the pages.
|
|
165
|
+
#
|
|
166
|
+
# place() takes a widget out of the geometry negotiation entirely: a
|
|
167
|
+
# placed page contributes nothing to what its parent requests, so the
|
|
168
|
+
# notebook asked for nothing and every size had to be set by hand.
|
|
169
|
+
# Grid keeps the propagation -- the cell takes the pages' requested
|
|
170
|
+
# size, the notebook takes the cell's -- while lower() keeps the
|
|
171
|
+
# canvas behind them.
|
|
172
|
+
self.canvas.grid(row=0, column=0, columnspan=2, sticky="nsew")
|
|
173
|
+
# NOT lowered.
|
|
174
|
+
#
|
|
175
|
+
# Lowering this canvas put it beneath CTkFrame's OWN background
|
|
176
|
+
# canvas, and "transparent" in CustomTkinter does not mean "do not
|
|
177
|
+
# paint" -- it means "paint the parent's colour". So the frame's
|
|
178
|
+
# background covered the tab strip completely and the widget rendered
|
|
179
|
+
# as an empty rectangle, while every print said the tabs were there.
|
|
180
|
+
#
|
|
181
|
+
# Nothing needs lowering anyway: this canvas is created before the
|
|
182
|
+
# page host, so it is already below it in the stacking order, which is
|
|
183
|
+
# the only relationship that matters.
|
|
184
|
+
|
|
185
|
+
# Pages sit ON TOP of the canvas, inside the outline.
|
|
186
|
+
# Created AFTER the canvas, so it stacks above it -- see the note
|
|
187
|
+
# there. lift() states it rather than relying on creation order.
|
|
188
|
+
self._page_host = ctk.CTkFrame(self, fg_color="transparent",
|
|
189
|
+
border_width=0)
|
|
190
|
+
self._page_host.lift()
|
|
191
|
+
self._page_host.grid_rowconfigure(0, weight=1)
|
|
192
|
+
self._page_host.grid_columnconfigure(0, weight=1)
|
|
193
|
+
self.grid_rowconfigure(0, weight=1)
|
|
194
|
+
|
|
195
|
+
self.canvas.bind("<Button-1>", self._on_click)
|
|
196
|
+
self.canvas.bind("<Motion>", self._on_motion)
|
|
197
|
+
self.canvas.bind("<Leave>", self._on_leave)
|
|
198
|
+
self.canvas.bind("<Configure>", lambda e: self._relayout())
|
|
199
|
+
for seq in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
|
|
200
|
+
self.canvas.bind(seq, self._on_wheel)
|
|
201
|
+
# macOS trackpads deliver <TouchpadScroll>, a separate event from
|
|
202
|
+
# <MouseWheel>, with far more of them and far finer values. Binding
|
|
203
|
+
# only the wheel means a real mouse scrolls the strip and a trackpad
|
|
204
|
+
# does nothing at all. Same split ScrollBindingMixin handles for the
|
|
205
|
+
# scrolling containers.
|
|
206
|
+
# GUARDED: <TouchpadScroll> does not exist on every Tk build.
|
|
207
|
+
#
|
|
208
|
+
# It arrived in Tk 8.7, so a macOS running an older Tk raises here and
|
|
209
|
+
# the widget cannot be constructed at all:
|
|
210
|
+
#
|
|
211
|
+
# TclError: bad event type or keysym "TouchpadScroll"
|
|
212
|
+
#
|
|
213
|
+
# The wheel bindings above still work there; only the finer trackpad
|
|
214
|
+
# events are lost, which is a degradation rather than a failure.
|
|
215
|
+
# ScrollBindingMixin has guarded this all along, which is why the
|
|
216
|
+
# scrolling containers never showed the fault.
|
|
217
|
+
if sys.platform == "darwin":
|
|
218
|
+
try:
|
|
219
|
+
self.canvas.bind("<TouchpadScroll>", self._on_touchpad)
|
|
220
|
+
except tk.TclError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
# Laid out ONCE at construction, not only when a tab is added.
|
|
224
|
+
#
|
|
225
|
+
# _relayout() is what configures the grid columns -- the strip's
|
|
226
|
+
# minsize and the page column's weight. Without this call an empty
|
|
227
|
+
# notebook had neither, so both columns collapsed and the canvas,
|
|
228
|
+
# gridded across them, had no width to draw in. The widget rendered
|
|
229
|
+
# as nothing at all until the first tab arrived.
|
|
230
|
+
self._relayout()
|
|
231
|
+
|
|
232
|
+
self._finalize_themeable_lifecycle()
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# Argument checking
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _check_side(value):
|
|
239
|
+
side = str(value).lower()
|
|
240
|
+
if side not in ("left", "right"):
|
|
241
|
+
raise ValueError(f'side must be "left" or "right", not "{value}"')
|
|
242
|
+
return side
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _check_style(value):
|
|
246
|
+
style = str(value).lower()
|
|
247
|
+
if style not in ("rounded", "angled"):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f'tab_style must be "rounded" or "angled", not "{value}"')
|
|
250
|
+
return style
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _check_orientation(value):
|
|
254
|
+
orientation = str(value).lower()
|
|
255
|
+
if orientation not in ("auto", "up", "down", "horizontal"):
|
|
256
|
+
raise ValueError(
|
|
257
|
+
'text_orientation must be "auto", "up", "down" or '
|
|
258
|
+
f'"horizontal", not "{value}"')
|
|
259
|
+
return orientation
|
|
260
|
+
|
|
261
|
+
def _validate_theme_keys(self):
|
|
262
|
+
"""
|
|
263
|
+
Hard-fails at construction on an incomplete theme block, naming the
|
|
264
|
+
missing key and where it belongs. The library-wide pattern: a missing
|
|
265
|
+
key is an error, never a silently substituted colour.
|
|
266
|
+
"""
|
|
267
|
+
name = self.__class__.__name__
|
|
268
|
+
for key in self._REQUIRED_THEME_KEYS:
|
|
269
|
+
if self._local_defaults.get(key) is None:
|
|
270
|
+
raise KeyError(
|
|
271
|
+
f"'{name}' theme block is missing '{key}' at the top level "
|
|
272
|
+
f"of sCTkThemes.json.")
|
|
273
|
+
for key in self._REQUIRED_DISABLED_KEYS:
|
|
274
|
+
if self._custom_disabled_map.get(key) is None:
|
|
275
|
+
raise KeyError(
|
|
276
|
+
f"'{name}' theme block is missing '{key}' in disabled_map.")
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
# Scaling helpers
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# Geometry keys the theme may override. Absent from a block, the class
|
|
282
|
+
# attribute of the same name in upper case applies -- so a theme need
|
|
283
|
+
# only mention the ones it wants to change, and none of them are
|
|
284
|
+
# required.
|
|
285
|
+
_THEMEABLE_GEOMETRY = {
|
|
286
|
+
"tab_corner": "TAB_CORNER",
|
|
287
|
+
"tab_chamfer_x": "TAB_CHAMFER_X",
|
|
288
|
+
"tab_chamfer_y": "TAB_CHAMFER_Y",
|
|
289
|
+
"tab_gap": "TAB_GAP",
|
|
290
|
+
"tab_pad": "TAB_PAD",
|
|
291
|
+
"page_corner": "PAGE_CORNER",
|
|
292
|
+
"page_inset": "PAGE_INSET",
|
|
293
|
+
"border_width": "BORDER_WIDTH",
|
|
294
|
+
"separator_inset": "SEPARATOR_INSET",
|
|
295
|
+
"strip_margin": "STRIP_MARGIN",
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
def _geom(self, key):
|
|
299
|
+
"""
|
|
300
|
+
A geometry value, scaled, from the theme or the class attribute.
|
|
301
|
+
|
|
302
|
+
Theme first so a palette can carry its own proportions -- a squarer
|
|
303
|
+
theme wants a smaller tab_corner -- while the class attributes stay
|
|
304
|
+
the defaults and remain settable per instance for one-off tuning.
|
|
305
|
+
"""
|
|
306
|
+
value = self._local_defaults.get(key)
|
|
307
|
+
if value is None:
|
|
308
|
+
value = getattr(self, self._THEMEABLE_GEOMETRY[key])
|
|
309
|
+
return self._apply_widget_scaling(float(value))
|
|
310
|
+
|
|
311
|
+
def _sx(self, value):
|
|
312
|
+
"""Scales a dimension for drawing. Used on every geometry constant."""
|
|
313
|
+
return self._apply_widget_scaling(value)
|
|
314
|
+
|
|
315
|
+
def _scaled_font(self):
|
|
316
|
+
"""
|
|
317
|
+
The theme font with its size scaled.
|
|
318
|
+
|
|
319
|
+
A canvas text item takes a plain font tuple, which CustomTkinter's own
|
|
320
|
+
font scaling never sees -- so without this the labels would stay the
|
|
321
|
+
same size while everything around them grew.
|
|
322
|
+
|
|
323
|
+
THE WHOLE TUPLE GOES IN, not the size. _apply_font_scaling takes a
|
|
324
|
+
font and returns it with its size scaled; handed a bare integer it
|
|
325
|
+
raises ValueError.
|
|
326
|
+
|
|
327
|
+
That mistake was here and invisible: the except below swallowed it
|
|
328
|
+
and returned the unscaled font, so the tab labels never scaled and
|
|
329
|
+
nothing ever said so. A try/except around a call you got wrong turns
|
|
330
|
+
a loud failure into a feature that quietly does not work.
|
|
331
|
+
"""
|
|
332
|
+
font = self._local_defaults.get("font")
|
|
333
|
+
if not isinstance(font, (list, tuple)):
|
|
334
|
+
return font
|
|
335
|
+
try:
|
|
336
|
+
return self._apply_font_scaling(tuple(font))
|
|
337
|
+
except Exception:
|
|
338
|
+
# A genuine fallback now, rather than the normal path.
|
|
339
|
+
return tuple(font)
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Colour resolution
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
def _colour(self, key):
|
|
345
|
+
"""The current value for a key, honouring the disabled state."""
|
|
346
|
+
if self._state == "disabled":
|
|
347
|
+
val = self._custom_disabled_map.get(key)
|
|
348
|
+
if val is not None:
|
|
349
|
+
return self._resolve_color(val)
|
|
350
|
+
return self._resolve_color(self._local_defaults.get(key))
|
|
351
|
+
|
|
352
|
+
def _page_background(self):
|
|
353
|
+
"""
|
|
354
|
+
The colour behind the pages, resolved to something drawable.
|
|
355
|
+
|
|
356
|
+
A raw canvas cannot render CustomTkinter's "transparent" pseudo-value,
|
|
357
|
+
so a transparent theme falls back to whatever is actually behind the
|
|
358
|
+
widget -- the same accommodation sCTkFileExplorer's canvas makes.
|
|
359
|
+
"""
|
|
360
|
+
bg = self._resolve_color(self._local_defaults.get("fg_color"))
|
|
361
|
+
if bg == "transparent":
|
|
362
|
+
detected = self._detect_color_of_master()
|
|
363
|
+
if detected in (None, "transparent"):
|
|
364
|
+
return self._resolve_color(
|
|
365
|
+
ctk.ThemeManager.theme["CTk"]["fg_color"])
|
|
366
|
+
return self._resolve_color(detected)
|
|
367
|
+
return bg
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
# Layout
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
def _content_rect(self):
|
|
373
|
+
"""
|
|
374
|
+
(x0, y0, x1, y1) of the page area, in canvas coordinates.
|
|
375
|
+
|
|
376
|
+
The strip occupies tab_width on the chosen side; the page area is
|
|
377
|
+
everything else.
|
|
378
|
+
"""
|
|
379
|
+
w = max(self.canvas.winfo_width(), 1)
|
|
380
|
+
h = max(self.canvas.winfo_height(), 1)
|
|
381
|
+
strip = self._sx(self._tab_width)
|
|
382
|
+
if self._side == "left":
|
|
383
|
+
return strip, 0, w, h
|
|
384
|
+
return 0, 0, w - strip, h
|
|
385
|
+
|
|
386
|
+
def _relayout(self):
|
|
387
|
+
"""
|
|
388
|
+
Reserves the strip's column, insets the page host, then redraws.
|
|
389
|
+
|
|
390
|
+
The strip is a grid column with a minimum size rather than a placed
|
|
391
|
+
rectangle, so the pages keep contributing their requested size to the
|
|
392
|
+
notebook's own -- see the note where the canvas is gridded.
|
|
393
|
+
"""
|
|
394
|
+
strip = int(self._sx(self._tab_width))
|
|
395
|
+
pad = int(self._geom("page_inset") + self._geom("border_width")) \
|
|
396
|
+
if self._show_page_border else 0
|
|
397
|
+
|
|
398
|
+
strip_col, page_col = (0, 1) if self._side == "left" else (1, 0)
|
|
399
|
+
self.grid_columnconfigure(strip_col, minsize=strip, weight=0)
|
|
400
|
+
self.grid_columnconfigure(page_col, weight=1)
|
|
401
|
+
|
|
402
|
+
self._page_host.grid(row=0, column=page_col, sticky="nsew",
|
|
403
|
+
padx=pad, pady=pad)
|
|
404
|
+
self._draw_notebook()
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Drawing
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
def _text_angle(self):
|
|
410
|
+
"""
|
|
411
|
+
The canvas rotation for a label, in degrees.
|
|
412
|
+
|
|
413
|
+
"auto" reads outside-in: up a left-hand strip, down a right-hand one,
|
|
414
|
+
which is how a book's spine is set and stays readable whichever side
|
|
415
|
+
the strip moves to. The other three override that -- "up" and "down"
|
|
416
|
+
pin the direction regardless of side, which matters if a panel has
|
|
417
|
+
notebooks on both edges and you want their labels to match.
|
|
418
|
+
"""
|
|
419
|
+
if self._text_orientation == "horizontal":
|
|
420
|
+
return 0
|
|
421
|
+
if self._text_orientation == "up":
|
|
422
|
+
return 90
|
|
423
|
+
if self._text_orientation == "down":
|
|
424
|
+
return 270
|
|
425
|
+
return 90 if self._side == "left" else 270
|
|
426
|
+
|
|
427
|
+
def _measure(self, text):
|
|
428
|
+
"""
|
|
429
|
+
Length a label needs ALONG the strip, in pixels.
|
|
430
|
+
|
|
431
|
+
Measured rather than estimated from character count: a proportional
|
|
432
|
+
font makes "IIII" and "WWWW" differ by more than a factor of two, and
|
|
433
|
+
tabs sized by character count would collide or leave gaps.
|
|
434
|
+
"""
|
|
435
|
+
try:
|
|
436
|
+
probe = tkfont.Font(font=self._scaled_font())
|
|
437
|
+
if self._text_orientation == "horizontal":
|
|
438
|
+
# Flat text runs ACROSS the strip, so what a tab needs along
|
|
439
|
+
# it is the line height, not the label's width. The width
|
|
440
|
+
# becomes tab_width's problem -- see the note in the
|
|
441
|
+
# constructor.
|
|
442
|
+
return probe.metrics("linespace") + self._geom("tab_pad")
|
|
443
|
+
return probe.measure(str(text)) + self._geom("tab_pad") * 2
|
|
444
|
+
except Exception:
|
|
445
|
+
# Before the widget is mapped, or with a font Tk cannot resolve.
|
|
446
|
+
# Eight pixels a character is wrong but bounded, and the next
|
|
447
|
+
# <Configure> redraw measures properly.
|
|
448
|
+
return self._sx((len(str(text)) * 8) + (self._geom("tab_pad") * 2))
|
|
449
|
+
|
|
450
|
+
def _tab_polygon(self, y0, y1, selected=False):
|
|
451
|
+
"""
|
|
452
|
+
Points for one tab, in the current style.
|
|
453
|
+
|
|
454
|
+
The INNER edge -- the one against the page -- is always square, so a
|
|
455
|
+
selected tab reads as continuous with the page beside it. Only the
|
|
456
|
+
outer edge is shaped.
|
|
457
|
+
"""
|
|
458
|
+
x_out = 0 if self._side == "left" else self.canvas.winfo_width()
|
|
459
|
+
strip = self._sx(self._tab_width)
|
|
460
|
+
# Only the SELECTED tab overlaps the page outline.
|
|
461
|
+
#
|
|
462
|
+
# It needs to: the outline is a stroked line centred on its path, so
|
|
463
|
+
# butting exactly against it leaves a hairline, and reaching past it
|
|
464
|
+
# closes the join.
|
|
465
|
+
#
|
|
466
|
+
# An unselected tab must NOT, and did at first. Every tab covered the
|
|
467
|
+
# border where it sat while the gaps between them left it showing,
|
|
468
|
+
# which turned a continuous edge into a dashed one.
|
|
469
|
+
# The selected tab crosses the outline; an unselected one reaches
|
|
470
|
+
# only to the middle of it.
|
|
471
|
+
#
|
|
472
|
+
# The outline is stroked, so it straddles its path -- half inside the
|
|
473
|
+
# page, half out. A tab stopping at the path's coordinate leaves that
|
|
474
|
+
# outer half uncovered, which reads as the border being a pixel
|
|
475
|
+
# thinner beside the tabs than it is elsewhere.
|
|
476
|
+
bw = self._geom("border_width")
|
|
477
|
+
overlap = bw if selected else (bw / 2.0)
|
|
478
|
+
if self._side == "left":
|
|
479
|
+
x_in = strip + overlap
|
|
480
|
+
direction = 1
|
|
481
|
+
else:
|
|
482
|
+
x_in = self.canvas.winfo_width() - strip - overlap
|
|
483
|
+
direction = -1
|
|
484
|
+
|
|
485
|
+
if self._tab_style == "angled":
|
|
486
|
+
# A rectangle with its two OUTER corners cut off.
|
|
487
|
+
#
|
|
488
|
+
# The cut runs between a point dx in from the corner along the
|
|
489
|
+
# outer edge and a point dy in from it along the top or bottom
|
|
490
|
+
# edge -- so the tab still reaches the edge of the strip, and only
|
|
491
|
+
# the corners are missing.
|
|
492
|
+
#
|
|
493
|
+
# This replaces a trapezium whose entire outer edge was inset by
|
|
494
|
+
# the slant, leaving the tab floating clear of the strip edge
|
|
495
|
+
# rather than sitting against it. At small slants the shape read
|
|
496
|
+
# as wrong without it being obvious why.
|
|
497
|
+
dx = min(self._geom("tab_chamfer_x"),
|
|
498
|
+
abs(x_in - x_out) * 0.4)
|
|
499
|
+
dy = min(self._geom("tab_chamfer_y"),
|
|
500
|
+
(y1 - y0) * self.TAB_CHAMFER_MAX_FRACTION)
|
|
501
|
+
return [x_in, y0,
|
|
502
|
+
x_out + (dx * direction), y0,
|
|
503
|
+
x_out, y0 + dy,
|
|
504
|
+
x_out, y1 - dy,
|
|
505
|
+
x_out + (dx * direction), y1,
|
|
506
|
+
x_in, y1]
|
|
507
|
+
|
|
508
|
+
# Genuine quarter circles, not a chamfer.
|
|
509
|
+
#
|
|
510
|
+
# Joining the two corner points with a straight line -- which is what
|
|
511
|
+
# this did first -- reads as a 45-degree cut, visibly different from
|
|
512
|
+
# the rounded corners on a CTkButton sitting beside it. Six segments
|
|
513
|
+
# per corner is enough to look like a curve at any sane tab width.
|
|
514
|
+
r = self._geom("tab_corner")
|
|
515
|
+
cx = x_out + (r * direction)
|
|
516
|
+
pts = [x_in, y0]
|
|
517
|
+
if direction > 0:
|
|
518
|
+
# Left strip: top corner sweeps 270 -> 180, bottom 180 -> 90.
|
|
519
|
+
pts += self._arc_points(cx, y0 + r, 270, 180, r)
|
|
520
|
+
pts += self._arc_points(cx, y1 - r, 180, 90, r)
|
|
521
|
+
else:
|
|
522
|
+
# Right strip: 270 -> 360, then 360 -> 450.
|
|
523
|
+
#
|
|
524
|
+
# 450, not 90. _arc_points interpolates linearly between the two
|
|
525
|
+
# angles, so 360 -> 90 sweeps BACKWARDS through 300, 200, 100 --
|
|
526
|
+
# 270 degrees the long way round instead of 90 the short way. The
|
|
527
|
+
# polygon looped back on itself and left a white notch at the
|
|
528
|
+
# corner of every right-hand tab.
|
|
529
|
+
pts += self._arc_points(cx, y0 + r, 270, 360, r)
|
|
530
|
+
pts += self._arc_points(cx, y1 - r, 360, 450, r)
|
|
531
|
+
pts += [x_in, y1]
|
|
532
|
+
return pts
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def _arc_points(cx, cy, start_deg, end_deg, radius, steps=6):
|
|
536
|
+
"""Points along a circular arc, for building rounded polygons."""
|
|
537
|
+
pts = []
|
|
538
|
+
for i in range(steps + 1):
|
|
539
|
+
a = math.radians(start_deg + (end_deg - start_deg) * i / steps)
|
|
540
|
+
pts.extend([cx + radius * math.cos(a), cy + radius * math.sin(a)])
|
|
541
|
+
return pts
|
|
542
|
+
|
|
543
|
+
def _page_outline_points(self, gap):
|
|
544
|
+
"""
|
|
545
|
+
An open path around the page area, broken where the selected tab is.
|
|
546
|
+
|
|
547
|
+
`gap` is (y_top, y_bottom) on the strip-side edge to leave out, or
|
|
548
|
+
None to close the rectangle. Returned as a single polyline starting at
|
|
549
|
+
one lip of the gap and running the long way round to the other, which
|
|
550
|
+
is what makes the outline appear to curve into the tab.
|
|
551
|
+
"""
|
|
552
|
+
x0, y0, x1, y1 = self._content_rect()
|
|
553
|
+
r = self._geom("page_corner")
|
|
554
|
+
bw = self._geom("border_width")
|
|
555
|
+
# Half the stroke, so the drawn line sits inside the rectangle rather
|
|
556
|
+
# than straddling its edge and clipping at the widget boundary.
|
|
557
|
+
h = bw / 2.0
|
|
558
|
+
x0, y0, x1, y1 = x0 + h, y0 + h, x1 - h, y1 - h
|
|
559
|
+
|
|
560
|
+
arc = self._arc_points
|
|
561
|
+
|
|
562
|
+
if self._side == "left":
|
|
563
|
+
edge = x0 # the strip-side edge
|
|
564
|
+
top_start = [edge, y0 + r]
|
|
565
|
+
path = []
|
|
566
|
+
if gap:
|
|
567
|
+
path += [edge, gap[0]] # upper lip of the gap
|
|
568
|
+
path += [edge, y0 + r]
|
|
569
|
+
path += arc(x0 + r, y0 + r, 180, 270, r) # top-left
|
|
570
|
+
path += [x1 - r, y0]
|
|
571
|
+
path += arc(x1 - r, y0 + r, 270, 360, r) # top-right
|
|
572
|
+
path += [x1, y1 - r]
|
|
573
|
+
path += arc(x1 - r, y1 - r, 0, 90, r) # bottom-right
|
|
574
|
+
path += [x0 + r, y1]
|
|
575
|
+
path += arc(x0 + r, y1 - r, 90, 180, r) # bottom-left
|
|
576
|
+
if gap:
|
|
577
|
+
path += [edge, gap[1]] # lower lip of the gap
|
|
578
|
+
else:
|
|
579
|
+
path += [edge, y0 + r]
|
|
580
|
+
return path
|
|
581
|
+
|
|
582
|
+
edge = x1
|
|
583
|
+
path = []
|
|
584
|
+
if gap:
|
|
585
|
+
path += [edge, gap[0]]
|
|
586
|
+
path += [edge, y0 + r]
|
|
587
|
+
path += arc(x1 - r, y0 + r, 0, -90, r) # top-right
|
|
588
|
+
path += [x0 + r, y0]
|
|
589
|
+
path += arc(x0 + r, y0 + r, 270, 180, r) # top-left
|
|
590
|
+
path += [x0, y1 - r]
|
|
591
|
+
path += arc(x0 + r, y1 - r, 180, 90, r) # bottom-left
|
|
592
|
+
path += [x1 - r, y1]
|
|
593
|
+
path += arc(x1 - r, y1 - r, 90, 0, r) # bottom-right
|
|
594
|
+
if gap:
|
|
595
|
+
path += [edge, gap[1]]
|
|
596
|
+
else:
|
|
597
|
+
path += [edge, y0 + r]
|
|
598
|
+
return path
|
|
599
|
+
|
|
600
|
+
def _total_tab_length(self):
|
|
601
|
+
"""Pixels the tabs need along the strip, including the gaps."""
|
|
602
|
+
gap = self._geom("tab_gap")
|
|
603
|
+
total = self._geom("strip_margin")
|
|
604
|
+
for name in self._pages:
|
|
605
|
+
total += self._measure(name) + gap
|
|
606
|
+
return total
|
|
607
|
+
|
|
608
|
+
def required_length(self):
|
|
609
|
+
"""
|
|
610
|
+
The height this widget wants, so every tab is reachable without
|
|
611
|
+
scrolling.
|
|
612
|
+
|
|
613
|
+
Provided because the widget cannot insist: a parent packing it with
|
|
614
|
+
fill="both" decides its size regardless. Use it to set a minimum on
|
|
615
|
+
the containing window, or ignore it and let the strip scroll.
|
|
616
|
+
"""
|
|
617
|
+
return int(self._total_tab_length() + self._geom("strip_margin"))
|
|
618
|
+
|
|
619
|
+
def _max_scroll(self):
|
|
620
|
+
"""How far the strip can scroll before the last tab is flush."""
|
|
621
|
+
overflow = self._total_tab_length() - max(self.canvas.winfo_height(), 1)
|
|
622
|
+
return max(int(overflow), 0)
|
|
623
|
+
|
|
624
|
+
def _draw_notebook(self):
|
|
625
|
+
"""
|
|
626
|
+
Repaints outline and tabs, and recomputes the hit-test bounds.
|
|
627
|
+
|
|
628
|
+
NOT named _draw(). CTkFrame has its own _draw(no_color_updates=...)
|
|
629
|
+
and calls it from its constructor, so a method of that name here
|
|
630
|
+
overrides it and the widget fails to build:
|
|
631
|
+
|
|
632
|
+
TypeError: _draw() got an unexpected keyword argument
|
|
633
|
+
'no_color_updates'
|
|
634
|
+
|
|
635
|
+
Worth knowing for any widget that subclasses a CustomTkinter class
|
|
636
|
+
and wants a drawing method of its own.
|
|
637
|
+
"""
|
|
638
|
+
if not hasattr(self, "_canvas"):
|
|
639
|
+
return
|
|
640
|
+
try:
|
|
641
|
+
if not self.canvas.winfo_exists():
|
|
642
|
+
return
|
|
643
|
+
except Exception:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
self.canvas.delete("all")
|
|
647
|
+
self._tab_bounds.clear()
|
|
648
|
+
self.canvas.configure(bg=self._page_background())
|
|
649
|
+
|
|
650
|
+
font = self._scaled_font()
|
|
651
|
+
angle = self._text_angle()
|
|
652
|
+
|
|
653
|
+
# Pass one: work out where every tab sits, so the outline knows where
|
|
654
|
+
# to leave its gap before anything is drawn.
|
|
655
|
+
gap_amount = self._geom("tab_gap")
|
|
656
|
+
y = self._geom("strip_margin") - self._scroll
|
|
657
|
+
for name in self._pages:
|
|
658
|
+
length = self._measure(name)
|
|
659
|
+
self._tab_bounds[name] = (y, y + length)
|
|
660
|
+
y += length + gap_amount
|
|
661
|
+
|
|
662
|
+
# Separators go under the tabs, so a selected tab covers the two
|
|
663
|
+
# beside it and reads as sitting in front.
|
|
664
|
+
if self._show_tab_separators:
|
|
665
|
+
strip = self._sx(self._tab_width)
|
|
666
|
+
sx0 = 0 if self._side == "left" else self.canvas.winfo_width() - strip
|
|
667
|
+
sx1 = strip if self._side == "left" else self.canvas.winfo_width()
|
|
668
|
+
half = self._geom("tab_gap") / 2.0
|
|
669
|
+
# Stopped short at both ends. A separator running the full width
|
|
670
|
+
# of the strip reads as a division of the whole panel rather than
|
|
671
|
+
# a line between two tabs, and collides with the page border at
|
|
672
|
+
# its inner end.
|
|
673
|
+
# The OUTER end stops where the tab corners start -- matched to
|
|
674
|
+
# the shape actually being drawn, so it ends with the curve or the
|
|
675
|
+
# cut rather than a pixel or two past it.
|
|
676
|
+
#
|
|
677
|
+
# The INNER end runs all the way to the page border. Stopping
|
|
678
|
+
# short of it left the separator floating, most visibly on a
|
|
679
|
+
# right-hand strip: the tabs meet the frame, so a line between two
|
|
680
|
+
# of them should too.
|
|
681
|
+
shape_inset = self._geom("tab_corner") \
|
|
682
|
+
if self._tab_style == "rounded" else self._geom("tab_chamfer_x")
|
|
683
|
+
outer_inset = max(shape_inset, self._geom("separator_inset"))
|
|
684
|
+
inner_inset = 0
|
|
685
|
+
if self._side == "left":
|
|
686
|
+
sx0, sx1 = sx0 + outer_inset, sx1 - inner_inset
|
|
687
|
+
else:
|
|
688
|
+
sx0, sx1 = sx0 + inner_inset, sx1 - outer_inset
|
|
689
|
+
names = list(self._tab_bounds)
|
|
690
|
+
for i, name in enumerate(names[:-1]):
|
|
691
|
+
y_between = self._tab_bounds[name][1] + half
|
|
692
|
+
self.canvas.create_line(sx0, y_between, sx1, y_between,
|
|
693
|
+
fill=self._colour("border_color"),
|
|
694
|
+
width=max(self._sx(1), 1))
|
|
695
|
+
|
|
696
|
+
# DRAW ORDER MATTERS, and this is the third arrangement.
|
|
697
|
+
#
|
|
698
|
+
# Unselected tabs, then the page outline, then the selected tab. The
|
|
699
|
+
# outline running OVER the unselected tabs is what keeps it crisp
|
|
700
|
+
# along the strip edge: any attempt to have the tabs meet it exactly
|
|
701
|
+
# leaves a half-pixel of the stroke uncovered, because the stroke
|
|
702
|
+
# straddles its own path. Letting the border win is simpler than
|
|
703
|
+
# trying to butt against it.
|
|
704
|
+
#
|
|
705
|
+
# The selected tab goes last, so it covers the border and reads as
|
|
706
|
+
# sitting in front of the page -- and the outline is drawn with a gap
|
|
707
|
+
# there anyway, so there is nothing to cover but the lips.
|
|
708
|
+
for name, (y0, y1) in self._tab_bounds.items():
|
|
709
|
+
if name == self._current:
|
|
710
|
+
continue
|
|
711
|
+
self._paint_tab(name, y0, y1, font, angle)
|
|
712
|
+
|
|
713
|
+
if self._show_page_border:
|
|
714
|
+
gap = self._tab_bounds.get(self._current) if self._current else None
|
|
715
|
+
self.canvas.create_line(
|
|
716
|
+
*self._page_outline_points(gap),
|
|
717
|
+
fill=self._colour("border_color"),
|
|
718
|
+
width=self._geom("border_width"),
|
|
719
|
+
capstyle="round", joinstyle="round", smooth=False)
|
|
720
|
+
|
|
721
|
+
if self._current in self._tab_bounds:
|
|
722
|
+
y0, y1 = self._tab_bounds[self._current]
|
|
723
|
+
self._paint_tab(self._current, y0, y1, font, angle)
|
|
724
|
+
|
|
725
|
+
def _paint_tab(self, name, y0, y1, font, angle):
|
|
726
|
+
"""Draws one tab and its label."""
|
|
727
|
+
if name == self._current:
|
|
728
|
+
fill = self._colour("tab_selected_color")
|
|
729
|
+
text_fill = self._colour("selected_text_color")
|
|
730
|
+
elif name == self._hover and self._state != "disabled":
|
|
731
|
+
fill = self._colour("tab_hover_color")
|
|
732
|
+
text_fill = self._colour("text_color")
|
|
733
|
+
else:
|
|
734
|
+
fill = self._colour("tab_fg_color")
|
|
735
|
+
text_fill = self._colour("text_color")
|
|
736
|
+
|
|
737
|
+
outline = self._colour("border_color") if name == self._current else fill
|
|
738
|
+
self.canvas.create_polygon(
|
|
739
|
+
self._tab_polygon(y0, y1, selected=(name == self._current)),
|
|
740
|
+
fill=fill, outline=outline,
|
|
741
|
+
width=self._geom("border_width") if name == self._current else 1)
|
|
742
|
+
|
|
743
|
+
strip = self._sx(self._tab_width)
|
|
744
|
+
cx = strip / 2 if self._side == "left" \
|
|
745
|
+
else self.canvas.winfo_width() - (strip / 2)
|
|
746
|
+
self.canvas.create_text(cx, (y0 + y1) / 2, text=str(name),
|
|
747
|
+
angle=angle, fill=text_fill, font=font)
|
|
748
|
+
|
|
749
|
+
# ------------------------------------------------------------------
|
|
750
|
+
# Interaction
|
|
751
|
+
# ------------------------------------------------------------------
|
|
752
|
+
def _on_strip(self, x):
|
|
753
|
+
"""Whether an x coordinate falls on the tab strip."""
|
|
754
|
+
strip = self._sx(self._tab_width)
|
|
755
|
+
if self._side == "left":
|
|
756
|
+
return x <= strip
|
|
757
|
+
return x >= self.canvas.winfo_width() - strip
|
|
758
|
+
|
|
759
|
+
def _tab_at(self, x, y):
|
|
760
|
+
"""The tab name at a canvas coordinate, or None."""
|
|
761
|
+
if not self._on_strip(x):
|
|
762
|
+
return None
|
|
763
|
+
for name, (y0, y1) in self._tab_bounds.items():
|
|
764
|
+
if y0 <= y <= y1:
|
|
765
|
+
return name
|
|
766
|
+
return None
|
|
767
|
+
|
|
768
|
+
def _on_click(self, event):
|
|
769
|
+
if self._state == "disabled":
|
|
770
|
+
return "break"
|
|
771
|
+
name = self._tab_at(event.x, event.y)
|
|
772
|
+
if name is not None and name != self._current:
|
|
773
|
+
self.set(name)
|
|
774
|
+
if callable(self._command):
|
|
775
|
+
self._command(name)
|
|
776
|
+
return "break"
|
|
777
|
+
|
|
778
|
+
def _on_motion(self, event):
|
|
779
|
+
if self._state == "disabled":
|
|
780
|
+
return
|
|
781
|
+
name = self._tab_at(event.x, event.y)
|
|
782
|
+
if name != self._hover:
|
|
783
|
+
self._hover = name
|
|
784
|
+
self._draw_notebook()
|
|
785
|
+
|
|
786
|
+
def _on_leave(self, event=None):
|
|
787
|
+
if self._hover is not None:
|
|
788
|
+
self._hover = None
|
|
789
|
+
self._draw_notebook()
|
|
790
|
+
|
|
791
|
+
def _on_wheel(self, event):
|
|
792
|
+
"""
|
|
793
|
+
Scrolls the strip when the tabs do not fit.
|
|
794
|
+
|
|
795
|
+
Only over the strip, and only when there is something to scroll --
|
|
796
|
+
otherwise the wheel belongs to whatever is on the page.
|
|
797
|
+
"""
|
|
798
|
+
if not self._on_strip(getattr(event, "x", 0)):
|
|
799
|
+
return
|
|
800
|
+
ceiling = self._max_scroll()
|
|
801
|
+
if ceiling <= 0:
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
if getattr(event, "num", None) == 4:
|
|
805
|
+
delta = -1
|
|
806
|
+
elif getattr(event, "num", None) == 5:
|
|
807
|
+
delta = 1
|
|
808
|
+
else:
|
|
809
|
+
delta = -1 if getattr(event, "delta", 0) > 0 else 1
|
|
810
|
+
|
|
811
|
+
self._scroll_by(delta)
|
|
812
|
+
return "break"
|
|
813
|
+
|
|
814
|
+
# Accumulated trackpad movement, and the threshold it must cross before
|
|
815
|
+
# the strip moves. Acting on every event is unusably fast -- the same
|
|
816
|
+
# reason ScrollBindingMixin accumulates.
|
|
817
|
+
TOUCHPAD_THRESHOLD = 12.0
|
|
818
|
+
|
|
819
|
+
@staticmethod
|
|
820
|
+
def _decode_touchpad_delta(raw_delta):
|
|
821
|
+
"""
|
|
822
|
+
Pulls the signed Y component out of a packed <TouchpadScroll> delta.
|
|
823
|
+
|
|
824
|
+
Tk packs X and Y into one 32-bit value. The low sixteen bits are Y,
|
|
825
|
+
two's complement -- so the test is `>= 0x8000`, not `> 32768`. Those
|
|
826
|
+
differ at exactly 32768, the smallest NEGATIVE value, which the looser
|
|
827
|
+
test reads as positive and inverts the direction.
|
|
828
|
+
"""
|
|
829
|
+
raw = raw_delta & 0xFFFFFFFF
|
|
830
|
+
delta_y = raw & 0xFFFF
|
|
831
|
+
if delta_y >= 0x8000:
|
|
832
|
+
delta_y -= 0x10000
|
|
833
|
+
return delta_y
|
|
834
|
+
|
|
835
|
+
def _on_touchpad(self, event):
|
|
836
|
+
"""Trackpad equivalent of _on_wheel, gated by an accumulator."""
|
|
837
|
+
if not self._on_strip(getattr(event, "x", 0)):
|
|
838
|
+
return
|
|
839
|
+
if self._max_scroll() <= 0:
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
delta_y = self._decode_touchpad_delta(getattr(event, "delta", 0))
|
|
843
|
+
if delta_y == 0:
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
accumulated = getattr(self, "_touch_accum", 0.0)
|
|
847
|
+
# Reversing direction resets, so a change of direction responds at
|
|
848
|
+
# once rather than having to cancel out what built up going the other
|
|
849
|
+
# way.
|
|
850
|
+
if (accumulated > 0) != (delta_y > 0):
|
|
851
|
+
accumulated = 0.0
|
|
852
|
+
accumulated += delta_y
|
|
853
|
+
|
|
854
|
+
if abs(accumulated) >= self.TOUCHPAD_THRESHOLD:
|
|
855
|
+
self._scroll_by(-1 if accumulated > 0 else 1)
|
|
856
|
+
accumulated = 0.0
|
|
857
|
+
self._touch_accum = accumulated
|
|
858
|
+
return "break"
|
|
859
|
+
|
|
860
|
+
def _scroll_by(self, steps):
|
|
861
|
+
"""Moves the strip, clamped to what there is to scroll."""
|
|
862
|
+
ceiling = self._max_scroll()
|
|
863
|
+
new = min(max(self._scroll + steps * self._sx(24), 0), ceiling)
|
|
864
|
+
if new != self._scroll:
|
|
865
|
+
self._scroll = new
|
|
866
|
+
self._draw_notebook()
|
|
867
|
+
|
|
868
|
+
# ------------------------------------------------------------------
|
|
869
|
+
# Public API -- mirrors sCTkTabview where the two overlap
|
|
870
|
+
# ------------------------------------------------------------------
|
|
871
|
+
def add(self, name):
|
|
872
|
+
"""
|
|
873
|
+
Creates a tab and returns its content page, an sCTkFrame.
|
|
874
|
+
|
|
875
|
+
Returns the page directly rather than requiring a separate tab()
|
|
876
|
+
call, matching sCTkTabview.
|
|
877
|
+
"""
|
|
878
|
+
name = str(name)
|
|
879
|
+
if name in self._pages:
|
|
880
|
+
raise ValueError(f"tab '{name}' already exists")
|
|
881
|
+
page = sCTkFrame(self._page_host, fg_color="transparent",
|
|
882
|
+
border_width=0)
|
|
883
|
+
page.grid(row=0, column=0, sticky="nsew")
|
|
884
|
+
# A page's own grid needs weights, or a child gridded into it lands in
|
|
885
|
+
# a cell with no room and collapses to nothing. A child frame survives
|
|
886
|
+
# that because it has a requested size of its own to insist on, which
|
|
887
|
+
# is why nesting one made the difference.
|
|
888
|
+
page.grid_rowconfigure(0, weight=1)
|
|
889
|
+
page.grid_columnconfigure(0, weight=1)
|
|
890
|
+
self._pages[name] = page
|
|
891
|
+
if self._current is None:
|
|
892
|
+
self._current = name
|
|
893
|
+
|
|
894
|
+
# The SELECTED page is re-raised after every addition.
|
|
895
|
+
#
|
|
896
|
+
# All the pages share one grid cell, and a widget created later stacks
|
|
897
|
+
# above one created earlier -- so each new tab quietly covered the
|
|
898
|
+
# selected one. Adding a widget to the first tab put it on a page that
|
|
899
|
+
# was already buried, and it vanished; adding to the last tab worked,
|
|
900
|
+
# because that page happened to be on top.
|
|
901
|
+
#
|
|
902
|
+
# Raising once when _current was first set was not enough: the
|
|
903
|
+
# stacking changes every time a page is added.
|
|
904
|
+
self._pages[self._current].tkraise()
|
|
905
|
+
|
|
906
|
+
self._relayout()
|
|
907
|
+
return page
|
|
908
|
+
|
|
909
|
+
def tab(self, name):
|
|
910
|
+
"""The content page for a tab. Raises KeyError if it does not exist."""
|
|
911
|
+
name = str(name)
|
|
912
|
+
if name not in self._pages:
|
|
913
|
+
raise KeyError(f"no tab named '{name}'")
|
|
914
|
+
return self._pages[name]
|
|
915
|
+
|
|
916
|
+
def delete(self, name):
|
|
917
|
+
"""Removes a tab and destroys its page."""
|
|
918
|
+
name = str(name)
|
|
919
|
+
if name not in self._pages:
|
|
920
|
+
raise KeyError(f"no tab named '{name}'")
|
|
921
|
+
page = self._pages.pop(name)
|
|
922
|
+
try:
|
|
923
|
+
page.destroy()
|
|
924
|
+
except Exception:
|
|
925
|
+
pass
|
|
926
|
+
if self._current == name:
|
|
927
|
+
self._current = next(iter(self._pages), None)
|
|
928
|
+
if self._current is not None:
|
|
929
|
+
self._pages[self._current].tkraise()
|
|
930
|
+
self._relayout()
|
|
931
|
+
|
|
932
|
+
def rename(self, old_name, new_name):
|
|
933
|
+
"""
|
|
934
|
+
Renames a tab, keeping its position and its page.
|
|
935
|
+
|
|
936
|
+
Re-keys the page registry as well as the label. Renaming by deleting
|
|
937
|
+
and re-adding would move the tab to the end and destroy its children.
|
|
938
|
+
"""
|
|
939
|
+
old_name, new_name = str(old_name), str(new_name)
|
|
940
|
+
if old_name not in self._pages:
|
|
941
|
+
raise KeyError(f"no tab named '{old_name}'")
|
|
942
|
+
if new_name in self._pages:
|
|
943
|
+
raise ValueError(f"tab '{new_name}' already exists")
|
|
944
|
+
# Rebuilt rather than mutated, to keep the insertion order.
|
|
945
|
+
self._pages = {(new_name if k == old_name else k): v
|
|
946
|
+
for k, v in self._pages.items()}
|
|
947
|
+
if self._current == old_name:
|
|
948
|
+
self._current = new_name
|
|
949
|
+
self._draw_notebook()
|
|
950
|
+
|
|
951
|
+
def set(self, name):
|
|
952
|
+
"""Selects a tab and raises its page."""
|
|
953
|
+
name = str(name)
|
|
954
|
+
if name not in self._pages:
|
|
955
|
+
raise KeyError(f"no tab named '{name}'")
|
|
956
|
+
self._current = name
|
|
957
|
+
self._pages[name].tkraise()
|
|
958
|
+
self._draw_notebook()
|
|
959
|
+
|
|
960
|
+
def get(self):
|
|
961
|
+
"""The selected tab's name, or None when there are no tabs."""
|
|
962
|
+
return self._current
|
|
963
|
+
|
|
964
|
+
def tabs(self):
|
|
965
|
+
"""Tab names, in order."""
|
|
966
|
+
return list(self._pages)
|
|
967
|
+
|
|
968
|
+
# ------------------------------------------------------------------
|
|
969
|
+
# State
|
|
970
|
+
# ------------------------------------------------------------------
|
|
971
|
+
def get_state(self):
|
|
972
|
+
"""Equivalent to state() with no argument."""
|
|
973
|
+
return self.state()
|
|
974
|
+
|
|
975
|
+
def state(self, mode=None):
|
|
976
|
+
"""
|
|
977
|
+
Gets or sets "normal"/"disabled".
|
|
978
|
+
|
|
979
|
+
Disabling dims the strip and the outline and stops tab selection. It
|
|
980
|
+
does NOT cascade to the widgets on a page -- same as sCTkTabview, and
|
|
981
|
+
the caller's responsibility.
|
|
982
|
+
"""
|
|
983
|
+
if mode is None:
|
|
984
|
+
return self._state
|
|
985
|
+
mode = str(mode).lower()
|
|
986
|
+
self._state = "disabled" if mode == "disabled" else "normal"
|
|
987
|
+
self._hover = None
|
|
988
|
+
self._draw_notebook()
|
|
989
|
+
return self._state
|
|
990
|
+
|
|
991
|
+
# ------------------------------------------------------------------
|
|
992
|
+
# Configuration
|
|
993
|
+
# ------------------------------------------------------------------
|
|
994
|
+
def configure(self, *args, **kwargs):
|
|
995
|
+
"""Standard configuration, plus Pygubu's single-argument query form."""
|
|
996
|
+
if len(args) == 1:
|
|
997
|
+
if isinstance(args[0], dict):
|
|
998
|
+
kwargs = {**args[0], **kwargs}
|
|
999
|
+
else:
|
|
1000
|
+
pname = args[0]
|
|
1001
|
+
if pname == "state":
|
|
1002
|
+
return ("state", "state", "state", "normal", self._state)
|
|
1003
|
+
if pname == "side":
|
|
1004
|
+
return ("side", "side", "Side", "left", self._side)
|
|
1005
|
+
if pname == "tab_style":
|
|
1006
|
+
return ("tab_style", "tab_style", "TabStyle", "rounded",
|
|
1007
|
+
self._tab_style)
|
|
1008
|
+
if pname == "text_orientation":
|
|
1009
|
+
return ("text_orientation", "text_orientation",
|
|
1010
|
+
"TextOrientation", "auto", self._text_orientation)
|
|
1011
|
+
if pname == "show_tab_separators":
|
|
1012
|
+
return ("show_tab_separators", "show_tab_separators",
|
|
1013
|
+
"ShowTabSeparators", "False",
|
|
1014
|
+
str(self._show_tab_separators))
|
|
1015
|
+
if pname == "show_page_border":
|
|
1016
|
+
return ("show_page_border", "show_page_border",
|
|
1017
|
+
"ShowPageBorder", "True", str(self._show_page_border))
|
|
1018
|
+
if pname == "tab_width":
|
|
1019
|
+
return ("tab_width", "tab_width", "TabWidth", 34,
|
|
1020
|
+
self._tab_width)
|
|
1021
|
+
return self._configure_query(pname)
|
|
1022
|
+
|
|
1023
|
+
self._record_theme_overrides(kwargs)
|
|
1024
|
+
|
|
1025
|
+
if "state" in kwargs:
|
|
1026
|
+
self.state(kwargs.pop("state"))
|
|
1027
|
+
|
|
1028
|
+
if "command" in kwargs:
|
|
1029
|
+
self._command = kwargs.pop("command")
|
|
1030
|
+
|
|
1031
|
+
if "side" in kwargs:
|
|
1032
|
+
new_side = self._check_side(kwargs.pop("side"))
|
|
1033
|
+
if new_side != self._side:
|
|
1034
|
+
self._side = new_side
|
|
1035
|
+
|
|
1036
|
+
if "tab_style" in kwargs:
|
|
1037
|
+
self._tab_style = self._check_style(kwargs.pop("tab_style"))
|
|
1038
|
+
|
|
1039
|
+
if "text_orientation" in kwargs:
|
|
1040
|
+
self._text_orientation = self._check_orientation(
|
|
1041
|
+
kwargs.pop("text_orientation"))
|
|
1042
|
+
|
|
1043
|
+
if "show_tab_separators" in kwargs:
|
|
1044
|
+
val = kwargs.pop("show_tab_separators")
|
|
1045
|
+
if isinstance(val, str):
|
|
1046
|
+
val = val.strip().lower() in ("true", "1", "yes", "on")
|
|
1047
|
+
self._show_tab_separators = bool(val)
|
|
1048
|
+
|
|
1049
|
+
if "show_page_border" in kwargs:
|
|
1050
|
+
val = kwargs.pop("show_page_border")
|
|
1051
|
+
if isinstance(val, str):
|
|
1052
|
+
val = val.strip().lower() in ("true", "1", "yes", "on")
|
|
1053
|
+
self._show_page_border = bool(val)
|
|
1054
|
+
|
|
1055
|
+
if "tab_width" in kwargs:
|
|
1056
|
+
self._tab_width = int(kwargs.pop("tab_width"))
|
|
1057
|
+
|
|
1058
|
+
# Keys this widget draws with are consumed here: they are not native
|
|
1059
|
+
# CTkFrame options, and forwarding one raises
|
|
1060
|
+
# "['tab_fg_color'] are not supported arguments".
|
|
1061
|
+
for key in list(kwargs):
|
|
1062
|
+
if key in self._local_defaults and key not in self._NATIVE_FRAME_KWARGS:
|
|
1063
|
+
kwargs.pop(key)
|
|
1064
|
+
|
|
1065
|
+
self._drop_cleared(kwargs)
|
|
1066
|
+
|
|
1067
|
+
result = super().configure(**kwargs) if kwargs else None
|
|
1068
|
+
self._relayout()
|
|
1069
|
+
return result
|
|
1070
|
+
|
|
1071
|
+
config = configure
|
|
1072
|
+
|
|
1073
|
+
def cget(self, attribute_name):
|
|
1074
|
+
if attribute_name == "state":
|
|
1075
|
+
return self._state
|
|
1076
|
+
if attribute_name == "side":
|
|
1077
|
+
return self._side
|
|
1078
|
+
if attribute_name == "tab_style":
|
|
1079
|
+
return self._tab_style
|
|
1080
|
+
if attribute_name == "text_orientation":
|
|
1081
|
+
return self._text_orientation
|
|
1082
|
+
if attribute_name == "show_tab_separators":
|
|
1083
|
+
return self._show_tab_separators
|
|
1084
|
+
if attribute_name == "show_page_border":
|
|
1085
|
+
return self._show_page_border
|
|
1086
|
+
if attribute_name == "tab_width":
|
|
1087
|
+
return self._tab_width
|
|
1088
|
+
if attribute_name in self._local_defaults and \
|
|
1089
|
+
attribute_name not in self._NATIVE_FRAME_KWARGS:
|
|
1090
|
+
return self._local_defaults.get(attribute_name)
|
|
1091
|
+
return super().cget(attribute_name)
|
|
1092
|
+
|
|
1093
|
+
def _set_appearance_mode(self, mode_string):
|
|
1094
|
+
if hasattr(super(), "_set_appearance_mode"):
|
|
1095
|
+
try:
|
|
1096
|
+
super()._set_appearance_mode(mode_string)
|
|
1097
|
+
except Exception:
|
|
1098
|
+
pass
|
|
1099
|
+
self._draw_notebook()
|
|
1100
|
+
|
|
1101
|
+
def _update_current_visual_state(self):
|
|
1102
|
+
"""Repaint hook, found by ThemeableWidget._repaint_after_override()."""
|
|
1103
|
+
self._draw_notebook()
|