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,636 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkFrequencyDisplay
|
|
4
|
+
|
|
5
|
+
A grouped numeric readout you can click a digit of.
|
|
6
|
+
|
|
7
|
+
WHAT IT IS FOR
|
|
8
|
+
|
|
9
|
+
A frequency, shown as x.xxx.xxx.xxx, where clicking a digit selects it and
|
|
10
|
+
the selection IS the tuning step: pick the hundreds digit and a dial moves
|
|
11
|
+
100 Hz per detent. That replaces a separate step control, and with it the
|
|
12
|
+
possibility of the two disagreeing about which digit is being tuned.
|
|
13
|
+
|
|
14
|
+
It also does signed offsets -- RIT, XIT, anything with a direction -- which
|
|
15
|
+
is why it is described as a grouped numeric readout rather than strictly a
|
|
16
|
+
frequency. Set `digits` and `signed` and the same drawing, selection and
|
|
17
|
+
theming serve both.
|
|
18
|
+
|
|
19
|
+
WHY A CANVAS, NOT A ROW OF LABELS
|
|
20
|
+
|
|
21
|
+
The first version assembled ten sCTkLabelPrimary widgets and coloured the
|
|
22
|
+
selected one's background. Three problems, all solved by drawing:
|
|
23
|
+
|
|
24
|
+
* The readout JITTERED. Proportional digits are not the same width, so the
|
|
25
|
+
row re-flowed as the numbers changed and the whole display shifted
|
|
26
|
+
sideways while tuning. Here every digit sits at a computed position and
|
|
27
|
+
nothing moves, whatever the font.
|
|
28
|
+
|
|
29
|
+
* Ten configure() calls per update, against one redraw.
|
|
30
|
+
|
|
31
|
+
* The selection highlight was a label background, so it could only be a
|
|
32
|
+
rectangle behind the glyph. Drawn, it can be anything -- and it takes its
|
|
33
|
+
colours from the theme rather than from hardcoded hex, which is what the
|
|
34
|
+
label version had resorted to.
|
|
35
|
+
|
|
36
|
+
A FIXED-WIDTH FONT IS THE DEFAULT for the same reason: even with computed
|
|
37
|
+
positions, a proportional font makes 1 and 8 different widths, so a digit
|
|
38
|
+
changing shifts the ink inside its own cell.
|
|
39
|
+
"""
|
|
40
|
+
import tkinter as tk
|
|
41
|
+
import tkinter.font as tkfont
|
|
42
|
+
|
|
43
|
+
import customtkinter as ctk
|
|
44
|
+
|
|
45
|
+
from . import themeable_widget as _tw
|
|
46
|
+
from .themeable_widget import ThemeableWidget
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class sCTkFrequencyDisplay(ctk.CTkFrame, ThemeableWidget):
|
|
50
|
+
"""A grouped numeric readout with per-digit selection."""
|
|
51
|
+
|
|
52
|
+
# Required at the TOP LEVEL of the theme block.
|
|
53
|
+
_REQUIRED_THEME_KEYS = (
|
|
54
|
+
"fg_color", "font", "text_color", "delimiter_color",
|
|
55
|
+
"selected_color", "selected_text_color",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Required inside disabled_map.
|
|
59
|
+
_REQUIRED_DISABLED_KEYS = ("text_color", "delimiter_color")
|
|
60
|
+
|
|
61
|
+
# Keys this widget draws with must not reach CTkFrame, which raises on
|
|
62
|
+
# anything it does not recognise.
|
|
63
|
+
_NATIVE_FRAME_KWARGS = frozenset({"width", "height", "bg_color"})
|
|
64
|
+
|
|
65
|
+
# Geometry, in UNSCALED pixels -- everything is put through
|
|
66
|
+
# _apply_widget_scaling() before it reaches the canvas.
|
|
67
|
+
PAD_X = 6 # left and right margin
|
|
68
|
+
PAD_Y = 4 # top and bottom margin
|
|
69
|
+
DIGIT_GAP = 0 # extra space between digits within a group
|
|
70
|
+
HIGHLIGHT_PAD = 2 # how far the selection box extends past a digit
|
|
71
|
+
HIGHLIGHT_RADIUS = 3
|
|
72
|
+
|
|
73
|
+
# Groups of three from the right, as a frequency is read.
|
|
74
|
+
GROUP = 3
|
|
75
|
+
|
|
76
|
+
def __init__(self, master=None, digits=10, signed=False, value=0,
|
|
77
|
+
delimiter=".", font_size=34, selectable=True,
|
|
78
|
+
min_digit=1, state="normal", command=None, **kw):
|
|
79
|
+
"""
|
|
80
|
+
Args:
|
|
81
|
+
master: Parent container.
|
|
82
|
+
digits: How many digit positions. Ten reaches into the GHz as
|
|
83
|
+
x.xxx.xxx.xxx; four suits a signed offset.
|
|
84
|
+
signed: Show a leading + or -, for an offset rather than a
|
|
85
|
+
frequency.
|
|
86
|
+
value: Initial value, in the units the caller uses -- Hz for a
|
|
87
|
+
frequency.
|
|
88
|
+
delimiter: What separates the groups. Settable at any time.
|
|
89
|
+
font_size: Point size of the digits. The FAMILY comes from the
|
|
90
|
+
theme and should stay fixed-width; see the module docstring.
|
|
91
|
+
selectable: Whether clicking a digit selects it.
|
|
92
|
+
min_digit: The smallest selectable digit, as a power of ten. The
|
|
93
|
+
uBITX tunes in whole 10 Hz steps, so 1 means the units digit
|
|
94
|
+
is shown but cannot be the thing you are moving.
|
|
95
|
+
state: "normal" or "disabled".
|
|
96
|
+
command: Called with the step in units when the selection
|
|
97
|
+
changes -- 100 for the hundreds digit.
|
|
98
|
+
"""
|
|
99
|
+
self._digit_count = int(digits)
|
|
100
|
+
self._signed = bool(signed)
|
|
101
|
+
self._value = int(value)
|
|
102
|
+
self._delimiter = str(delimiter)
|
|
103
|
+
self._font_size = int(font_size)
|
|
104
|
+
self._selectable = bool(selectable)
|
|
105
|
+
self._min_digit = max(0, int(min_digit))
|
|
106
|
+
self._command = command
|
|
107
|
+
|
|
108
|
+
ThemeableWidget.__init__(self, kw)
|
|
109
|
+
|
|
110
|
+
# Colours come from the RAW registry, not final_kw: ThemeableWidget
|
|
111
|
+
# strips its CUSTOM_VECTOR_KEYS out of final_kw, and reading them back
|
|
112
|
+
# from there finds nothing. See sCTkDial.md, "Reading theme colours".
|
|
113
|
+
raw_block = _tw.GLOBAL_THEME_REGISTRY.get(self.__class__.__name__) or {}
|
|
114
|
+
raw_colors = {k: v for k, v in raw_block.items()
|
|
115
|
+
if not isinstance(v, dict)}
|
|
116
|
+
self._local_defaults = ThemeableWidget._convert_lists_to_tuples(raw_colors)
|
|
117
|
+
self._local_defaults.update(self.final_kw)
|
|
118
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
119
|
+
self._validate_theme_keys()
|
|
120
|
+
|
|
121
|
+
native_kwargs = {k: v for k, v in self.final_kw.items()
|
|
122
|
+
if k in self._NATIVE_FRAME_KWARGS}
|
|
123
|
+
super().__init__(master, fg_color="transparent", border_width=0,
|
|
124
|
+
**native_kwargs)
|
|
125
|
+
|
|
126
|
+
self._state = "normal" if str(state).lower() == "normal" else "disabled"
|
|
127
|
+
|
|
128
|
+
# The selected digit, as a power of ten. Starts at the smallest
|
|
129
|
+
# selectable one, which is the least surprising default: a dial that
|
|
130
|
+
# arrives set to megahertz is a trap.
|
|
131
|
+
self._selected = self._min_digit
|
|
132
|
+
|
|
133
|
+
# canvas, NOT _canvas: CTkFrame keeps its own background canvas in
|
|
134
|
+
# _canvas, and assigning that name replaces the frame's reference to
|
|
135
|
+
# it. The dials and S-meters use the bare name for the same reason.
|
|
136
|
+
self.canvas = ctk.CTkCanvas(self, highlightthickness=0, bd=0)
|
|
137
|
+
self.canvas.pack(fill="both", expand=True)
|
|
138
|
+
|
|
139
|
+
self.canvas.bind("<Button-1>", self._on_click)
|
|
140
|
+
self.canvas.bind("<Configure>", lambda e: self._draw_display())
|
|
141
|
+
|
|
142
|
+
self._resize_to_fit()
|
|
143
|
+
self._finalize_themeable_lifecycle()
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Construction helpers
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
def _validate_theme_keys(self):
|
|
149
|
+
"""
|
|
150
|
+
Hard-fails at construction on an incomplete theme block, naming the
|
|
151
|
+
missing key and where it belongs. The library-wide pattern: a missing
|
|
152
|
+
key is an error, never a silently substituted colour.
|
|
153
|
+
"""
|
|
154
|
+
name = self.__class__.__name__
|
|
155
|
+
for key in self._REQUIRED_THEME_KEYS:
|
|
156
|
+
if self._local_defaults.get(key) is None:
|
|
157
|
+
raise KeyError(
|
|
158
|
+
f"'{name}' theme block is missing '{key}' at the top level "
|
|
159
|
+
f"of sCTkThemes.json.")
|
|
160
|
+
for key in self._REQUIRED_DISABLED_KEYS:
|
|
161
|
+
if self._custom_disabled_map.get(key) is None:
|
|
162
|
+
raise KeyError(
|
|
163
|
+
f"'{name}' theme block is missing '{key}' in disabled_map.")
|
|
164
|
+
|
|
165
|
+
def _sx(self, value):
|
|
166
|
+
"""Scales a dimension for drawing."""
|
|
167
|
+
return self._apply_widget_scaling(value)
|
|
168
|
+
|
|
169
|
+
def _scaled_font(self):
|
|
170
|
+
"""
|
|
171
|
+
The theme font at the configured size, scaled.
|
|
172
|
+
|
|
173
|
+
A canvas text item takes a plain tuple, which CustomTkinter's font
|
|
174
|
+
scaling never sees -- so without this the digits would stay put while
|
|
175
|
+
everything around them grew.
|
|
176
|
+
|
|
177
|
+
The FAMILY comes from the theme and the SIZE from this widget: one is
|
|
178
|
+
a house style, the other is how big this particular readout should be.
|
|
179
|
+
"""
|
|
180
|
+
family, _, weight = self._font_parts()
|
|
181
|
+
# THE WHOLE TUPLE, not the size.
|
|
182
|
+
#
|
|
183
|
+
# _apply_font_scaling takes a font and returns it with its size
|
|
184
|
+
# scaled; handed a bare integer it raises:
|
|
185
|
+
#
|
|
186
|
+
# ValueError: Can not scale font '38' of type <class 'int'>
|
|
187
|
+
#
|
|
188
|
+
# Which is easy to write and easy to miss, because a widget that
|
|
189
|
+
# wraps the call in try/except just silently stops scaling.
|
|
190
|
+
try:
|
|
191
|
+
return self._apply_font_scaling((family, self._font_size, weight))
|
|
192
|
+
except Exception:
|
|
193
|
+
return (family, self._font_size, weight)
|
|
194
|
+
|
|
195
|
+
#
|
|
196
|
+
# Tk guarantees exactly three family names on every platform -- Courier,
|
|
197
|
+
# Helvetica and Times -- and maps each to something local. Everything
|
|
198
|
+
# else is a gamble: Menlo is macOS only, Consolas is Windows only,
|
|
199
|
+
# DejaVu Sans Mono is usually Linux. A theme naming one of those looks
|
|
200
|
+
# right wherever it was written and silently falls back to a
|
|
201
|
+
# PROPORTIONAL default everywhere else, which is precisely the jitter
|
|
202
|
+
# this widget exists to avoid.
|
|
203
|
+
#
|
|
204
|
+
# So the theme may name whatever it likes, and this list is tried in turn
|
|
205
|
+
# when that family is not installed.
|
|
206
|
+
_FIXED_WIDTH_FALLBACKS = ("Menlo", "Consolas", "DejaVu Sans Mono",
|
|
207
|
+
"Liberation Mono", "Courier New", "Courier")
|
|
208
|
+
|
|
209
|
+
def _font_parts(self):
|
|
210
|
+
"""
|
|
211
|
+
Family, size and weight, with the family checked for existence.
|
|
212
|
+
|
|
213
|
+
Tk does not report a missing family -- it quietly substitutes its
|
|
214
|
+
default, which is proportional. Checking means a theme can name a
|
|
215
|
+
good local font without breaking the widget somewhere else.
|
|
216
|
+
"""
|
|
217
|
+
font = self._local_defaults.get("font")
|
|
218
|
+
family, size, weight = "Courier", 34, "bold"
|
|
219
|
+
if isinstance(font, (list, tuple)):
|
|
220
|
+
if len(font) >= 1:
|
|
221
|
+
family = font[0]
|
|
222
|
+
if len(font) >= 2:
|
|
223
|
+
size = font[1]
|
|
224
|
+
if len(font) >= 3:
|
|
225
|
+
weight = font[2]
|
|
226
|
+
return self._available_family(family), size, weight
|
|
227
|
+
|
|
228
|
+
def _available_family(self, preferred):
|
|
229
|
+
"""
|
|
230
|
+
The first installed family: the theme's, then the fallbacks.
|
|
231
|
+
|
|
232
|
+
Cached, because tkfont.families() is not cheap and the answer cannot
|
|
233
|
+
change while the application runs.
|
|
234
|
+
"""
|
|
235
|
+
cached = getattr(self.__class__, "_resolved_family", None)
|
|
236
|
+
if cached is not None and cached[0] == preferred:
|
|
237
|
+
return cached[1]
|
|
238
|
+
|
|
239
|
+
chosen = preferred
|
|
240
|
+
try:
|
|
241
|
+
installed = {f.lower() for f in tkfont.families()}
|
|
242
|
+
if preferred.lower() not in installed:
|
|
243
|
+
for candidate in self._FIXED_WIDTH_FALLBACKS:
|
|
244
|
+
if candidate.lower() in installed:
|
|
245
|
+
chosen = candidate
|
|
246
|
+
break
|
|
247
|
+
else:
|
|
248
|
+
# Tk guarantees this one maps to something monospace on
|
|
249
|
+
# every platform, even when the enumeration comes back
|
|
250
|
+
# unhelpful.
|
|
251
|
+
chosen = "Courier"
|
|
252
|
+
except Exception:
|
|
253
|
+
chosen = "Courier"
|
|
254
|
+
|
|
255
|
+
self.__class__._resolved_family = (preferred, chosen)
|
|
256
|
+
return chosen
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
# Colour
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
def _colour(self, key):
|
|
262
|
+
"""The current value for a key, honouring the disabled state."""
|
|
263
|
+
if self._state == "disabled":
|
|
264
|
+
val = self._custom_disabled_map.get(key)
|
|
265
|
+
if val is not None:
|
|
266
|
+
return self._resolve_color(val)
|
|
267
|
+
return self._resolve_color(self._local_defaults.get(key))
|
|
268
|
+
|
|
269
|
+
def _canvas_background(self):
|
|
270
|
+
"""
|
|
271
|
+
A colour the canvas can actually be set to.
|
|
272
|
+
|
|
273
|
+
"transparent" means "take the parent's", which a raw canvas cannot
|
|
274
|
+
render -- so it has to be turned into whatever is really behind the
|
|
275
|
+
widget. The same accommodation sCTkNotebook and sCTkDial make.
|
|
276
|
+
"""
|
|
277
|
+
colour = self._resolve_color(self._local_defaults.get("fg_color"))
|
|
278
|
+
if colour != "transparent":
|
|
279
|
+
return colour
|
|
280
|
+
|
|
281
|
+
parent = getattr(self, "master", None)
|
|
282
|
+
for _ in range(6):
|
|
283
|
+
if parent is None:
|
|
284
|
+
break
|
|
285
|
+
try:
|
|
286
|
+
candidate = self._resolve_color(parent.cget("fg_color"))
|
|
287
|
+
if candidate and candidate != "transparent":
|
|
288
|
+
return candidate
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
parent = getattr(parent, "master", None)
|
|
292
|
+
return ("#1C1C1C" if str(ctk.get_appearance_mode()).lower() == "dark"
|
|
293
|
+
else "#F1F5F9")
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
# Layout: where each character sits
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
def _cells(self):
|
|
299
|
+
"""
|
|
300
|
+
Every character to draw, left to right, as (kind, text, power).
|
|
301
|
+
|
|
302
|
+
kind is "sign", "digit" or "delimiter". power is the digit's place
|
|
303
|
+
value as a power of ten, or None -- that is what makes a click
|
|
304
|
+
translate into a step without counting positions.
|
|
305
|
+
|
|
306
|
+
LEADING GROUPS ARE SUPPRESSED until they hold something, so an HF
|
|
307
|
+
frequency does not sit behind two empty groups on a ten-digit
|
|
308
|
+
display. The positions do not move when a group appears: the layout
|
|
309
|
+
is computed for the full width and the leading cells are simply not
|
|
310
|
+
drawn.
|
|
311
|
+
"""
|
|
312
|
+
text = f"{abs(self._value):0{self._digit_count}d}"[-self._digit_count:]
|
|
313
|
+
cells = []
|
|
314
|
+
|
|
315
|
+
if self._signed:
|
|
316
|
+
cells.append(("sign", "-" if self._value < 0 else "+", None))
|
|
317
|
+
|
|
318
|
+
for index, char in enumerate(text):
|
|
319
|
+
power = self._digit_count - 1 - index
|
|
320
|
+
cells.append(("digit", char, power))
|
|
321
|
+
# A delimiter after every third digit from the right, except at
|
|
322
|
+
# the very end.
|
|
323
|
+
if power % self.GROUP == 0 and power != 0:
|
|
324
|
+
cells.append(("delimiter", self._delimiter, None))
|
|
325
|
+
return cells
|
|
326
|
+
|
|
327
|
+
def _first_significant(self):
|
|
328
|
+
"""
|
|
329
|
+
The index of the first cell worth drawing.
|
|
330
|
+
|
|
331
|
+
Anything before the first non-zero digit is a leading zero, and a
|
|
332
|
+
frequency does not show them. The last GROUP digits always draw, so a
|
|
333
|
+
value of zero reads as 0.000 rather than as nothing.
|
|
334
|
+
"""
|
|
335
|
+
cells = self._cells()
|
|
336
|
+
for index, (kind, char, power) in enumerate(cells):
|
|
337
|
+
if kind == "digit" and char != "0":
|
|
338
|
+
return index
|
|
339
|
+
if kind == "digit" and power is not None and power < self.GROUP:
|
|
340
|
+
return index
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
def _metrics(self):
|
|
344
|
+
"""Digit width, delimiter width and line height, measured once."""
|
|
345
|
+
try:
|
|
346
|
+
probe = tkfont.Font(font=self._scaled_font())
|
|
347
|
+
return (probe.measure("0"), probe.measure(self._delimiter),
|
|
348
|
+
probe.metrics("linespace"))
|
|
349
|
+
except Exception:
|
|
350
|
+
size = self._sx(self._font_size)
|
|
351
|
+
return int(size * 0.6), int(size * 0.3), int(size * 1.3)
|
|
352
|
+
|
|
353
|
+
def _positions(self):
|
|
354
|
+
"""
|
|
355
|
+
(x, kind, text, power) for every cell, and the total width.
|
|
356
|
+
|
|
357
|
+
Computed from the metrics rather than from where the previous glyph
|
|
358
|
+
happened to end, so a digit changing cannot move its neighbours. This
|
|
359
|
+
is the whole reason the widget draws instead of packing labels.
|
|
360
|
+
"""
|
|
361
|
+
digit_w, delim_w, _ = self._metrics()
|
|
362
|
+
gap = self._sx(self.DIGIT_GAP)
|
|
363
|
+
x = self._sx(self.PAD_X)
|
|
364
|
+
placed = []
|
|
365
|
+
for kind, char, power in self._cells():
|
|
366
|
+
width = delim_w if kind == "delimiter" else digit_w
|
|
367
|
+
placed.append((x, kind, char, power, width))
|
|
368
|
+
x += width + (gap if kind == "digit" else 0)
|
|
369
|
+
return placed, x + self._sx(self.PAD_X)
|
|
370
|
+
|
|
371
|
+
def _resize_to_fit(self):
|
|
372
|
+
"""
|
|
373
|
+
Asks for exactly the room the readout needs.
|
|
374
|
+
|
|
375
|
+
Requested rather than imposed: a caller that wants a different size
|
|
376
|
+
can still say so, and the geometry manager decides. But the default
|
|
377
|
+
should fit, because a frequency clipped at the left edge is worse
|
|
378
|
+
than one that is too small.
|
|
379
|
+
"""
|
|
380
|
+
_, width = self._positions()
|
|
381
|
+
_, _, line = self._metrics()
|
|
382
|
+
height = line + (2 * self._sx(self.PAD_Y))
|
|
383
|
+
try:
|
|
384
|
+
self.canvas.configure(width=int(width), height=int(height))
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
# Drawing
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
def _draw_display(self):
|
|
392
|
+
"""
|
|
393
|
+
Repaints the whole readout.
|
|
394
|
+
|
|
395
|
+
NOT named _draw(): CTkFrame has its own _draw(no_color_updates=...)
|
|
396
|
+
and calls it from its constructor, so a method of that name here
|
|
397
|
+
overrides it and the widget cannot be built.
|
|
398
|
+
"""
|
|
399
|
+
if not hasattr(self, "canvas"):
|
|
400
|
+
return
|
|
401
|
+
try:
|
|
402
|
+
if not self.canvas.winfo_exists():
|
|
403
|
+
return
|
|
404
|
+
except Exception:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self.canvas.delete("all")
|
|
408
|
+
self.canvas.configure(bg=self._canvas_background())
|
|
409
|
+
|
|
410
|
+
font = self._scaled_font()
|
|
411
|
+
placed, _ = self._positions()
|
|
412
|
+
_, _, line = self._metrics()
|
|
413
|
+
y = self._sx(self.PAD_Y)
|
|
414
|
+
first = self._first_significant()
|
|
415
|
+
|
|
416
|
+
text_colour = self._colour("text_color")
|
|
417
|
+
delim_colour = self._colour("delimiter_color")
|
|
418
|
+
|
|
419
|
+
for index, (x, kind, char, power, width) in enumerate(placed):
|
|
420
|
+
# The SIGN always draws. It sits before the first significant
|
|
421
|
+
# digit, so the leading-zero rule would suppress it -- and an
|
|
422
|
+
# offset shown without its direction is worse than useless.
|
|
423
|
+
if index < first and kind != "sign":
|
|
424
|
+
continue # a leading zero, or the delimiter before one
|
|
425
|
+
|
|
426
|
+
if (kind == "digit" and power == self._selected
|
|
427
|
+
and self._selectable and self._state != "disabled"):
|
|
428
|
+
pad = self._sx(self.HIGHLIGHT_PAD)
|
|
429
|
+
self._rounded_box(x - pad, y - pad, x + width + pad,
|
|
430
|
+
y + line + pad,
|
|
431
|
+
self._colour("selected_color"))
|
|
432
|
+
colour = self._colour("selected_text_color")
|
|
433
|
+
elif kind == "delimiter":
|
|
434
|
+
colour = delim_colour
|
|
435
|
+
else:
|
|
436
|
+
colour = text_colour
|
|
437
|
+
|
|
438
|
+
self.canvas.create_text(x, y, text=char, anchor="nw",
|
|
439
|
+
fill=colour, font=font)
|
|
440
|
+
|
|
441
|
+
def _rounded_box(self, x0, y0, x1, y1, fill):
|
|
442
|
+
"""The selection highlight, with its corners taken off."""
|
|
443
|
+
r = self._sx(self.HIGHLIGHT_RADIUS)
|
|
444
|
+
points = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r,
|
|
445
|
+
x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1,
|
|
446
|
+
x0, y1, x0, y1 - r, x0, y0 + r, x0, y0]
|
|
447
|
+
self.canvas.create_polygon(points, fill=fill, outline=fill,
|
|
448
|
+
smooth=True)
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
# Interaction
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
def _on_click(self, event):
|
|
454
|
+
"""Selects the digit under the pointer, if it is selectable."""
|
|
455
|
+
if not self._selectable or self._state == "disabled":
|
|
456
|
+
return "break"
|
|
457
|
+
|
|
458
|
+
placed, _ = self._positions()
|
|
459
|
+
for x, kind, char, power, width in placed:
|
|
460
|
+
if kind != "digit" or power is None:
|
|
461
|
+
continue
|
|
462
|
+
if power < self._min_digit:
|
|
463
|
+
continue
|
|
464
|
+
if x <= event.x <= x + width:
|
|
465
|
+
self.select_digit(power)
|
|
466
|
+
break
|
|
467
|
+
return "break"
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Public API
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
def set(self, value):
|
|
473
|
+
"""Sets the value. Does NOT fire the command -- see select_digit."""
|
|
474
|
+
self._value = int(value)
|
|
475
|
+
self._draw_display()
|
|
476
|
+
|
|
477
|
+
def get(self):
|
|
478
|
+
"""The current value, as an integer."""
|
|
479
|
+
return self._value
|
|
480
|
+
|
|
481
|
+
def select_digit(self, power):
|
|
482
|
+
"""
|
|
483
|
+
Selects a digit by its place value, and reports the new step.
|
|
484
|
+
|
|
485
|
+
Fires `command` because this is a USER action -- the whole point of
|
|
486
|
+
the widget is that choosing a digit chooses the step. set() stays
|
|
487
|
+
quiet, matching CustomTkinter and everything else here.
|
|
488
|
+
"""
|
|
489
|
+
power = max(self._min_digit, min(self._digit_count - 1, int(power)))
|
|
490
|
+
if power == self._selected:
|
|
491
|
+
return
|
|
492
|
+
self._selected = power
|
|
493
|
+
self._draw_display()
|
|
494
|
+
if callable(self._command):
|
|
495
|
+
try:
|
|
496
|
+
self._command(self.step())
|
|
497
|
+
except TypeError:
|
|
498
|
+
self._command()
|
|
499
|
+
|
|
500
|
+
def step(self):
|
|
501
|
+
"""The selected digit's place value, in units. 100 means 100 Hz."""
|
|
502
|
+
return 10 ** self._selected
|
|
503
|
+
|
|
504
|
+
def selected_digit(self):
|
|
505
|
+
"""The selected digit as a power of ten."""
|
|
506
|
+
return self._selected
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
# State
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
def get_state(self):
|
|
512
|
+
"""Equivalent to state() with no argument."""
|
|
513
|
+
return self.state()
|
|
514
|
+
|
|
515
|
+
def state(self, mode=None):
|
|
516
|
+
"""
|
|
517
|
+
Gets or sets "normal"/"disabled".
|
|
518
|
+
|
|
519
|
+
Disabling dims the digits and stops selection. The value keeps
|
|
520
|
+
updating: this is a readout as well as a control, and a display that
|
|
521
|
+
froze while greyed would be indistinguishable from one showing a
|
|
522
|
+
current value -- which on a radio panel is actively misleading. Same
|
|
523
|
+
reasoning as the S-meters.
|
|
524
|
+
"""
|
|
525
|
+
if mode is None:
|
|
526
|
+
return self._state
|
|
527
|
+
mode = str(mode).lower()
|
|
528
|
+
self._state = "disabled" if mode == "disabled" else "normal"
|
|
529
|
+
self._draw_display()
|
|
530
|
+
return self._state
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
# Configuration
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
def configure(self, *args, **kwargs):
|
|
536
|
+
"""Standard configuration, plus Pygubu's single-argument query form."""
|
|
537
|
+
if len(args) == 1:
|
|
538
|
+
if isinstance(args[0], dict):
|
|
539
|
+
kwargs = {**args[0], **kwargs}
|
|
540
|
+
else:
|
|
541
|
+
pname = args[0]
|
|
542
|
+
simple = {
|
|
543
|
+
"state": ("normal", self._state),
|
|
544
|
+
"digits": (10, self._digit_count),
|
|
545
|
+
"signed": ("False", str(self._signed)),
|
|
546
|
+
"delimiter": (".", self._delimiter),
|
|
547
|
+
"font_size": (34, self._font_size),
|
|
548
|
+
"selectable": ("True", str(self._selectable)),
|
|
549
|
+
"min_digit": (1, self._min_digit),
|
|
550
|
+
"value": (0, self._value),
|
|
551
|
+
}
|
|
552
|
+
if pname in simple:
|
|
553
|
+
default, current = simple[pname]
|
|
554
|
+
return (pname, pname, pname, default, current)
|
|
555
|
+
return self._configure_query(pname)
|
|
556
|
+
|
|
557
|
+
self._record_theme_overrides(kwargs)
|
|
558
|
+
|
|
559
|
+
resize = False
|
|
560
|
+
if "state" in kwargs:
|
|
561
|
+
self.state(kwargs.pop("state"))
|
|
562
|
+
if "value" in kwargs:
|
|
563
|
+
self._value = int(kwargs.pop("value"))
|
|
564
|
+
if "digits" in kwargs:
|
|
565
|
+
self._digit_count = int(kwargs.pop("digits"))
|
|
566
|
+
resize = True
|
|
567
|
+
if "signed" in kwargs:
|
|
568
|
+
self._signed = self._as_bool(kwargs.pop("signed"))
|
|
569
|
+
resize = True
|
|
570
|
+
if "delimiter" in kwargs:
|
|
571
|
+
# Settable mid-stream: it is a display preference several windows
|
|
572
|
+
# share, and a readout that kept the delimiter it was born with
|
|
573
|
+
# would disagree with the rest of the interface the moment the
|
|
574
|
+
# setting changed.
|
|
575
|
+
self._delimiter = str(kwargs.pop("delimiter"))
|
|
576
|
+
resize = True
|
|
577
|
+
if "font_size" in kwargs:
|
|
578
|
+
self._font_size = int(kwargs.pop("font_size"))
|
|
579
|
+
resize = True
|
|
580
|
+
if "selectable" in kwargs:
|
|
581
|
+
self._selectable = self._as_bool(kwargs.pop("selectable"))
|
|
582
|
+
if "min_digit" in kwargs:
|
|
583
|
+
self._min_digit = max(0, int(kwargs.pop("min_digit")))
|
|
584
|
+
if self._selected < self._min_digit:
|
|
585
|
+
self._selected = self._min_digit
|
|
586
|
+
if "command" in kwargs:
|
|
587
|
+
self._command = kwargs.pop("command")
|
|
588
|
+
|
|
589
|
+
# Keys this widget draws with are consumed here: they are not native
|
|
590
|
+
# CTkFrame options, and forwarding one raises.
|
|
591
|
+
for key in list(kwargs):
|
|
592
|
+
if key in self._local_defaults and key not in self._NATIVE_FRAME_KWARGS:
|
|
593
|
+
kwargs.pop(key)
|
|
594
|
+
|
|
595
|
+
self._drop_cleared(kwargs)
|
|
596
|
+
|
|
597
|
+
result = super().configure(**kwargs) if kwargs else None
|
|
598
|
+
if resize:
|
|
599
|
+
self._resize_to_fit()
|
|
600
|
+
self._draw_display()
|
|
601
|
+
return result
|
|
602
|
+
|
|
603
|
+
config = configure
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _as_bool(value):
|
|
607
|
+
"""bool("False") is True, and the Designer sends strings."""
|
|
608
|
+
if isinstance(value, str):
|
|
609
|
+
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
610
|
+
return bool(value)
|
|
611
|
+
|
|
612
|
+
def cget(self, attribute_name):
|
|
613
|
+
simple = {
|
|
614
|
+
"state": self._state, "digits": self._digit_count,
|
|
615
|
+
"signed": self._signed, "delimiter": self._delimiter,
|
|
616
|
+
"font_size": self._font_size, "selectable": self._selectable,
|
|
617
|
+
"min_digit": self._min_digit, "value": self._value,
|
|
618
|
+
}
|
|
619
|
+
if attribute_name in simple:
|
|
620
|
+
return simple[attribute_name]
|
|
621
|
+
if attribute_name in self._local_defaults and \
|
|
622
|
+
attribute_name not in self._NATIVE_FRAME_KWARGS:
|
|
623
|
+
return self._local_defaults.get(attribute_name)
|
|
624
|
+
return super().cget(attribute_name)
|
|
625
|
+
|
|
626
|
+
def _set_appearance_mode(self, mode_string):
|
|
627
|
+
if hasattr(super(), "_set_appearance_mode"):
|
|
628
|
+
try:
|
|
629
|
+
super()._set_appearance_mode(mode_string)
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
self._draw_display()
|
|
633
|
+
|
|
634
|
+
def _update_current_visual_state(self):
|
|
635
|
+
"""Repaint hook, found by ThemeableWidget._repaint_after_override()."""
|
|
636
|
+
self._draw_display()
|