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.
Files changed (101) hide show
  1. scustomtkinter/__init__.py +81 -0
  2. scustomtkinter/assets/images/error.png +0 -0
  3. scustomtkinter/assets/images/info.png +0 -0
  4. scustomtkinter/assets/images/warning.png +0 -0
  5. scustomtkinter/assets/sCTkThemes.json +1963 -0
  6. scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  7. scustomtkinter/sctk_button_primary.py +366 -0
  8. scustomtkinter/sctk_button_secondary.py +354 -0
  9. scustomtkinter/sctk_button_tertiary.py +375 -0
  10. scustomtkinter/sctk_checkbox.py +270 -0
  11. scustomtkinter/sctk_combobox.py +155 -0
  12. scustomtkinter/sctk_core.py +15 -0
  13. scustomtkinter/sctk_dial.py +1472 -0
  14. scustomtkinter/sctk_dialog.py +701 -0
  15. scustomtkinter/sctk_dialog_toplevel.py +268 -0
  16. scustomtkinter/sctk_entry_primary.py +400 -0
  17. scustomtkinter/sctk_entry_secondary.py +342 -0
  18. scustomtkinter/sctk_file_explorer.py +879 -0
  19. scustomtkinter/sctk_frame.py +210 -0
  20. scustomtkinter/sctk_frame_labeled_primary.py +667 -0
  21. scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
  22. scustomtkinter/sctk_frequency_display.py +636 -0
  23. scustomtkinter/sctk_label_primary.py +283 -0
  24. scustomtkinter/sctk_label_secondary.py +275 -0
  25. scustomtkinter/sctk_label_tertiary.py +278 -0
  26. scustomtkinter/sctk_messagebox.py +275 -0
  27. scustomtkinter/sctk_notebook.py +1103 -0
  28. scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
  29. scustomtkinter/sctk_optionmenu_primary.py +378 -0
  30. scustomtkinter/sctk_optionmenu_secondary.py +346 -0
  31. scustomtkinter/sctk_path_chooser.py +516 -0
  32. scustomtkinter/sctk_progress_bar.py +274 -0
  33. scustomtkinter/sctk_radiobutton.py +360 -0
  34. scustomtkinter/sctk_scroll_mixin.py +546 -0
  35. scustomtkinter/sctk_scrollable_frame.py +795 -0
  36. scustomtkinter/sctk_scrollbar.py +313 -0
  37. scustomtkinter/sctk_segmentedbutton.py +424 -0
  38. scustomtkinter/sctk_selector.py +581 -0
  39. scustomtkinter/sctk_separator.py +308 -0
  40. scustomtkinter/sctk_slider.py +264 -0
  41. scustomtkinter/sctk_smeter.py +405 -0
  42. scustomtkinter/sctk_smeter_bar.py +557 -0
  43. scustomtkinter/sctk_spinbox.py +664 -0
  44. scustomtkinter/sctk_switch.py +463 -0
  45. scustomtkinter/sctk_tableview.py +869 -0
  46. scustomtkinter/sctk_tabview.py +409 -0
  47. scustomtkinter/sctk_textbox_primary.py +291 -0
  48. scustomtkinter/sctk_textbox_secondary.py +225 -0
  49. scustomtkinter/sctk_toplevel.py +153 -0
  50. scustomtkinter/themeable_widget.py +938 -0
  51. scustomtkinter-0.2.1.dist-info/METADATA +336 -0
  52. scustomtkinter-0.2.1.dist-info/RECORD +101 -0
  53. scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
  54. scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
  55. scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
  56. scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  57. scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
  58. scustomtkinter_pygubu/__init__.py +1 -0
  59. scustomtkinter_pygubu/designer/__init__.py +0 -0
  60. scustomtkinter_pygubu/designer/plugin.py +1854 -0
  61. scustomtkinter_pygubu/designer/properties.py +247 -0
  62. scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
  63. scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
  64. scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
  65. scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
  66. scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
  67. scustomtkinter_pygubu/sCTkCorebo.py +120 -0
  68. scustomtkinter_pygubu/sCTkDialbo.py +371 -0
  69. scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
  70. scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
  71. scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
  72. scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
  73. scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
  74. scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
  75. scustomtkinter_pygubu/sCTkFramebo.py +46 -0
  76. scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
  77. scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
  78. scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
  79. scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
  80. scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
  81. scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
  82. scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
  83. scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
  84. scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
  85. scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
  86. scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
  87. scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
  88. scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
  89. scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
  90. scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
  91. scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
  92. scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
  93. scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
  94. scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
  95. scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
  96. scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
  97. scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
  98. scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
  99. scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
  100. scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
  101. scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkSMeterBar
4
+
5
+ A standalone, low-profile horizontal discrete LED segment bar widget displaying
6
+ simultaneous, independent tracks for incoming S-Units and transmitter SWR ratio levels.
7
+ Inherits directly from ctk.CTkFrame to bypass nested framework validation bugs,
8
+ and integrates with ThemeableWidget to read centralized style sheets safely.
9
+ """
10
+ import os
11
+ import math
12
+
13
+ import customtkinter as ctk
14
+ from .themeable_widget import ThemeableWidget, defer
15
+
16
+ class sCTkSMeterBar(ctk.CTkFrame, ThemeableWidget):
17
+ # Required at the TOP LEVEL of the theme block. inactive_color is distinct
18
+ # from the disabled state: it greys the SWR/PWR labels when those ROWS are
19
+ # switched off via configure_visibility(), which is a visibility flag, not
20
+ # a widget state. Both can apply at once.
21
+ _REQUIRED_THEME_KEYS = ("fg_color", "text_color", "alarm_color",
22
+ "led_on_color", "led_off_color",
23
+ "font", "scale_font", "inactive_color")
24
+
25
+ # Required inside disabled_map. fg_color deliberately excluded -- the
26
+ # background stays put when disabled and the face carries the signal.
27
+ _REQUIRED_DISABLED_KEYS = ("text_color", "alarm_color",
28
+ "led_on_color", "led_off_color")
29
+
30
+ def __init__(self, master=None, swr_max_value=5.0, swr_visible=True, pwr_visible=True, hide_lower_row=False, hide_sig_row=False, width=320, height=110, state="normal", **kw):
31
+ # 1. Initialize our Themeable mixin tracker cleanly
32
+ ThemeableWidget.__init__(self, kw)
33
+ self._local_defaults = dict(self.final_kw)
34
+
35
+ # Snapshot of the THEME's fonts, taken before any per-instance
36
+ # override. Clearing the font field in the Designer restores these
37
+ # rather than leaving the override in place, matching what generated
38
+ # code produces when the property is omitted.
39
+ self._theme_font_defaults = {
40
+ "font": self._local_defaults.get("font"),
41
+ "scale_font": self._local_defaults.get("scale_font"),
42
+ }
43
+ self._custom_disabled_map = dict(self._widget_disabled_map)
44
+ self._state = "normal" if str(state).lower() == "normal" else "disabled"
45
+ self._validate_theme_keys()
46
+
47
+ # 2. Background track. Popped because super().__init__() takes it as a
48
+ # separate argument -- but RETAINED on the instance, which is the fix
49
+ # for a real bug: _draw_meter() and _update_theme_colors() both did
50
+ # self._local_defaults.get("fg_color", hardcoded) AFTER this pop
51
+ # removed the key, so the background always rendered the hardcoded
52
+ # fallback and the configured fg_color never applied.
53
+ theme_bg_raw = self._local_defaults.pop("fg_color")
54
+ self._theme_bg_raw = theme_bg_raw
55
+ # Untouched copy, so clearing fg_color in the Designer can return to
56
+ # the theme rather than to whatever was last set.
57
+ self._theme_bg_default = theme_bg_raw
58
+
59
+ # 🔑 THE STRUCTURAL CLEAN PURGE: Clear custom analog gauge properties explicitly out of self.final_kw
60
+ # This completely guarantees the native CustomTkinter constructor won't throw a ValueError!
61
+ for led_custom_key in ["fg_color", "text_color", "alarm_color", "led_on_color",
62
+ "led_off_color", "font", "scale_font", "inactive_color"]:
63
+ self.final_kw.pop(led_custom_key, None)
64
+
65
+ # 3. Pass dimensions and sanitized kwargs down to the native parent frame engine safely
66
+ super().__init__(master, width=width, height=height, fg_color=theme_bg_raw, **self.final_kw)
67
+
68
+ self.swr_max_value = float(swr_max_value)
69
+ self._default_swr_max_value = 5.0
70
+ self._default_swr_visible = True
71
+ self._default_pwr_visible = True
72
+ self._default_hide_lower_row = False
73
+ self._default_hide_sig_row = False
74
+ self._default_width = 320
75
+ self._default_height = 110
76
+
77
+ self._swr_visible = bool(swr_visible)
78
+ self._pwr_visible = bool(pwr_visible)
79
+ self._hide_lower_row = bool(hide_lower_row)
80
+ # Hides the S row, leaving SWR and PWR.
81
+ #
82
+ # The mirror of hide_lower_row, and it exists for the split-meter
83
+ # layout: an S bar under the receive frequency and a PWR/SWR bar under
84
+ # the transmit one, each half the instrument rather than two full ones
85
+ # with a dead half each.
86
+ self._hide_sig_row = bool(hide_sig_row)
87
+
88
+ self._current_s_value = 0.0
89
+ self._current_swr_value = 1.0
90
+ self._current_pwr_value = 0.0
91
+ # A reading in words, drawn at the right end of the S row's caption
92
+ # line -- "-63 dBFS", say. Empty draws nothing. See set().
93
+ self._current_sig_text = ""
94
+
95
+ # 4. Resolve the frame backplane color string and build the Tkinter drawing canvas
96
+ bg_resolved = self._resolve_color(theme_bg_raw)
97
+ self.canvas = ctk.CTkCanvas(self, highlightthickness=0, bd=0, bg=bg_resolved)
98
+ self.canvas.pack(fill="both", expand=True, padx=0, pady=0)
99
+
100
+ self.pack_propagate(False)
101
+ self.grid_propagate(False)
102
+
103
+ self.canvas.bind("<Configure>", lambda e: self._draw_meter())
104
+ self._finalize_themeable_lifecycle()
105
+
106
+ def _validate_theme_keys(self) -> None:
107
+ """
108
+ Hard-fails at construction on an incomplete theme block, naming the
109
+ missing key and where it belongs.
110
+
111
+ Raises:
112
+ KeyError: naming the first missing key found.
113
+ """
114
+ # Resolved the way ThemeableWidget resolves it, so the message names
115
+ # the block actually read rather than a subclass's own name -- the
116
+ # Designer's preview classes are subclasses.
117
+ name = getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__
118
+ for key in self._REQUIRED_THEME_KEYS:
119
+ if self._local_defaults.get(key) is None:
120
+ raise KeyError(
121
+ f"'{name}' theme block is missing '{key}' at the top level "
122
+ f"of sCTkThemes.json."
123
+ )
124
+ for key in self._REQUIRED_DISABLED_KEYS:
125
+ if self._custom_disabled_map.get(key) is None:
126
+ raise KeyError(
127
+ f"'{name}' theme block is missing '{key}' in disabled_map."
128
+ )
129
+
130
+ def _themed(self, key: str):
131
+ """
132
+ Returns the disabled-state value for a key when disabled and one
133
+ exists, otherwise the normal value.
134
+
135
+ Distinct from inactive_color, which greys the SWR/PWR labels when
136
+ those rows are switched off. A row can be hidden on an enabled widget,
137
+ and a disabled widget still shows whichever rows are visible.
138
+ """
139
+ if self._state == "disabled" and self._custom_disabled_map.get(key) is not None:
140
+ return self._custom_disabled_map[key]
141
+ return self._local_defaults.get(key)
142
+
143
+ def get_state(self) -> str:
144
+ """Returns the current state, "normal" or "disabled"."""
145
+ return self._state
146
+
147
+ def state(self, mode: str = None) -> str:
148
+ """
149
+ Gets or sets the widget state.
150
+
151
+ This is an output-only instrument -- there is nothing to lock out --
152
+ so disabling dims the face rather than blocking interaction. It exists
153
+ for consistency with the rest of the library, so a panel can disable
154
+ every widget it contains uniformly.
155
+
156
+ Args:
157
+ mode: None to query. "normal"/"enabled"/"active" or "disabled" to set.
158
+
159
+ Returns:
160
+ The resulting state.
161
+ """
162
+ if mode is None:
163
+ return self._state
164
+ target = str(mode).lower()
165
+ self._state = "normal" if target in ("normal", "enabled", "active") else "disabled"
166
+ if hasattr(self, "canvas") and self.canvas.winfo_exists():
167
+ self._draw_meter()
168
+ return self._state
169
+
170
+ def configure_visibility(self, swr_visible=None, pwr_visible=None, hide_lower_row=None, hide_sig_row=None):
171
+ """Public configuration mapping hook to alter the lower row layout matrix states live."""
172
+ if swr_visible is not None: self._swr_visible = bool(swr_visible)
173
+ if pwr_visible is not None: self._pwr_visible = bool(pwr_visible)
174
+ if hide_lower_row is not None: self._hide_lower_row = bool(hide_lower_row)
175
+ if hide_sig_row is not None: self._hide_sig_row = bool(hide_sig_row)
176
+ if self.canvas.winfo_exists(): self._draw_meter()
177
+
178
+ def configure(self, *args, **kwargs):
179
+ """Handles Pygubu layout inspector dictionary merges and parameter modifications safely."""
180
+ if args and len(args) == 1:
181
+ # FIX: was `pname = args`, leaving pname as a TUPLE -- every
182
+ # comparison below failed, so all three single-argument queries
183
+ # were dead and fell through to super(). Pygubu could not read any
184
+ # of them. Same bug fixed in sCTkFileExplorer.
185
+ pname = args[0]
186
+ if pname == "state":
187
+ return ("state", "state", "state", "normal", self._state)
188
+ if pname in ("swr_visible", "pwr_visible", "hide_lower_row", "hide_sig_row"):
189
+ defaults = {"swr_visible": self._default_swr_visible,
190
+ "pwr_visible": self._default_pwr_visible,
191
+ "hide_lower_row": self._default_hide_lower_row,
192
+ "hide_sig_row": self._default_hide_sig_row}
193
+ currents = {"swr_visible": self._swr_visible,
194
+ "pwr_visible": self._pwr_visible,
195
+ "hide_lower_row": self._hide_lower_row,
196
+ "hide_sig_row": self._hide_sig_row}
197
+ return (pname, pname, pname,
198
+ str(defaults[pname]), str(currents[pname]))
199
+
200
+ if pname in ["width", "height", "swr_max_value"]:
201
+ fallback = self._default_width if pname == "width" else (self._default_height if pname == "height" else self._default_swr_max_value)
202
+ current = super().cget("width") if pname == "width" else (super().cget("height") if pname == "height" else self.swr_max_value)
203
+ return (pname, pname, pname, self._query_value(fallback), self._query_value(current))
204
+ # FIX: was `return super().configure(*args, **kwargs)`.
205
+ #
206
+ # Native CTkFrame.configure() is declared
207
+ # configure(self, require_redraw=False, **kwargs), so the property
208
+ # NAME was passed as require_redraw and the call returned None.
209
+ # Pygubu's _get_default_value() expects a five-element tuple, got
210
+ # None, and handed that None straight back to _set_property() --
211
+ # where CustomTkinter raised "float() argument must be a string or
212
+ # a real number, not 'NoneType'".
213
+ #
214
+ # Reached whenever a field is blanked in the Designer inspector.
215
+ # The library-wide sweep that fixed this matched on `pname` and
216
+ # `require_redraw` as the argument name and missed the *args form
217
+ # used here.
218
+ if pname == "fg_color":
219
+ return (pname, pname, pname,
220
+ self._query_value(self._theme_bg_default, pname),
221
+ self._query_value(self._theme_bg_raw, pname))
222
+
223
+ if pname in ("font", "scale_font"):
224
+ return (pname, pname, pname,
225
+ self._query_value(self._theme_font_defaults.get(pname), pname),
226
+ self._query_value(self._local_defaults.get(pname), pname))
227
+
228
+ return self._configure_query(pname)
229
+
230
+ # FIX: was `if args and isinstance(args, dict)`. args is ALWAYS a
231
+ # tuple, so this never fired and the dict form was dead code.
232
+ if len(args) == 1 and isinstance(args[0], dict): kwargs = {**args[0], **kwargs}
233
+ # font and scale_font are per-instance overrides of the theme keys of
234
+ # the same name. An empty value restores the theme's, matching what
235
+ # generated code does when the property is omitted -- and matching the
236
+ # default the query branch above reports.
237
+ for _font_key in ("font", "scale_font"):
238
+ if _font_key in kwargs:
239
+ _new = kwargs.pop(_font_key)
240
+ if _new:
241
+ self._local_defaults[_font_key] = _new
242
+ else:
243
+ self._local_defaults[_font_key] = \
244
+ self._theme_font_defaults.get(_font_key)
245
+ self._draw_meter()
246
+
247
+ # state is this library's own property, not a native CTkFrame one.
248
+ # Runtime overrides have to reach the map a repaint reads, or the
249
+ # repaint puts the theme value straight back -- see
250
+ # ThemeableWidget._record_theme_overrides().
251
+ self._record_theme_overrides(kwargs)
252
+
253
+ # fg_color paints the CANVAS, which covers the frame entirely.
254
+ #
255
+ # It is popped out of _local_defaults at construction into
256
+ # _theme_bg_raw, so _record_theme_overrides() never sees it -- and
257
+ # forwarding it to the frame underneath changes a surface nobody can
258
+ # see. Handled here instead: an empty value restores the theme's.
259
+ if "fg_color" in kwargs:
260
+ _bg = kwargs.pop("fg_color")
261
+ self._theme_bg_raw = _bg if _bg else self._theme_bg_default
262
+ if hasattr(self, "canvas") and self.canvas.winfo_exists():
263
+ self.canvas.configure(bg=self._resolve_color(self._theme_bg_raw))
264
+
265
+ if "state" in kwargs: self.state(kwargs.pop("state"))
266
+ if "width" in kwargs:
267
+ w = kwargs["width"]
268
+ kwargs["width"] = int(w) if (w and str(w).strip()) else self._default_width
269
+ if "height" in kwargs:
270
+ h = kwargs["height"]
271
+ kwargs["height"] = int(h) if (h and str(h).strip()) else self._default_height
272
+
273
+ # FIX: the three visibility flags were constructor arguments with a
274
+ # configure_visibility() method and cget() support, but no configure()
275
+ # branch -- so a Designer edit reached native CTkFrame.configure() and
276
+ # raised:
277
+ #
278
+ # ['pwr_visible'] are not supported arguments.
279
+ #
280
+ # swr_max_value had a branch, which is why only these three failed.
281
+ #
282
+ # An empty value restores the constructor default, matching the query
283
+ # branch above and what generated code does when the property is
284
+ # omitted.
285
+ _visibility = {
286
+ "swr_visible": ("_swr_visible", self._default_swr_visible),
287
+ "pwr_visible": ("_pwr_visible", self._default_pwr_visible),
288
+ "hide_lower_row": ("_hide_lower_row", self._default_hide_lower_row),
289
+ "hide_sig_row": ("_hide_sig_row", self._default_hide_sig_row),
290
+ }
291
+ _visibility_changed = False
292
+ for _key, (_attr, _fallback) in _visibility.items():
293
+ if _key not in kwargs:
294
+ continue
295
+ _val = kwargs.pop(_key)
296
+ if _val == "" or _val is None:
297
+ setattr(self, _attr, _fallback)
298
+ elif isinstance(_val, str):
299
+ setattr(self, _attr, _val.strip().lower() in ("true", "1", "yes"))
300
+ else:
301
+ setattr(self, _attr, bool(_val))
302
+ _visibility_changed = True
303
+ if _visibility_changed:
304
+ self._draw_meter()
305
+
306
+ if "swr_max_value" in kwargs:
307
+ val = kwargs.pop("swr_max_value")
308
+ self.swr_max_value = self._default_swr_max_value if (val == "" or not str(val).strip()) else float(val)
309
+
310
+ if kwargs: super().configure(**kwargs)
311
+ if self.canvas.winfo_exists(): self._draw_meter()
312
+
313
+ config = configure
314
+ def cget(self, attribute_name):
315
+ """Public register parameter property getter lookup."""
316
+ if attribute_name == "state": return self._state
317
+ if attribute_name == "swr_max_value": return self.swr_max_value
318
+ if attribute_name == "swr_visible": return self._swr_visible
319
+ if attribute_name == "pwr_visible": return self._pwr_visible
320
+ if attribute_name == "hide_lower_row": return self._hide_lower_row
321
+ if attribute_name == "hide_sig_row": return self._hide_sig_row
322
+ return super().cget(attribute_name)
323
+
324
+ def _set_appearance_mode(self, mode_string: str):
325
+ """Native look catcher ensuring canvas backplanes repaint fluidly on theme skin shifts."""
326
+ if hasattr(super(), "_set_appearance_mode"):
327
+ try: super()._set_appearance_mode(mode_string)
328
+ except Exception: pass
329
+ if hasattr(self, "canvas") and self.canvas.winfo_exists():
330
+ defer(self, 15, self._update_theme_colors)
331
+
332
+ def _update_theme_colors(self):
333
+ """Refreshes the canvas widget background color directly from your asset maps."""
334
+ bg_color = self._resolve_color(self._theme_bg_raw)
335
+ self.canvas.configure(bg=bg_color)
336
+ self._draw_meter()
337
+
338
+ def _scaled_font(self, font):
339
+ """
340
+ A theme font with its size scaled for the display.
341
+
342
+ A canvas text item takes a plain font tuple, which CustomTkinter's own
343
+ font scaling never sees -- so without this the meter's captions and
344
+ scale stayed the same size while everything around them grew on a
345
+ scaled display. Same as sCTkNotebook and sCTkFrequencyDisplay.
346
+
347
+ THE WHOLE TUPLE GOES IN, not the size: _apply_font_scaling raises on
348
+ a bare integer, and a try/except around that mistake would quietly
349
+ stop scaling. A named font (a string) is left as it is.
350
+ """
351
+ if not isinstance(font, (list, tuple)):
352
+ return font
353
+ try:
354
+ return self._apply_font_scaling(tuple(font))
355
+ except Exception:
356
+ return tuple(font)
357
+
358
+ def _set_scaling(self, *args, **kwargs):
359
+ """
360
+ Redraws when the scaling changes (CustomTkinter calls this on every
361
+ widget when the window moves to a display with another scale, or the
362
+ app sets one): the canvas text is drawn, so nothing else would
363
+ update it.
364
+ """
365
+ super()._set_scaling(*args, **kwargs)
366
+ if getattr(self, "canvas", None) is not None and self.canvas.winfo_exists():
367
+ self._draw_meter()
368
+
369
+ @staticmethod
370
+ def _font_metrics(font):
371
+ """
372
+ Height and a width-measuring callable for a font.
373
+
374
+ Every vertical offset in _draw_meter() was a hardcoded pixel count
375
+ tuned for the default font -- captions sat 6px below the bar, tick
376
+ labels 14px above it, SWR and PWR 20px up. A larger font grew past
377
+ those gaps and overlapped the scale it labelled.
378
+
379
+ Returns:
380
+ (line_height, measure) where measure(text) gives a pixel width.
381
+ Falls back to a rough estimate if Tk cannot supply a font object,
382
+ which keeps the meter drawing rather than raising.
383
+ """
384
+ try:
385
+ import tkinter.font as tkfont
386
+ f = tkfont.Font(font=font)
387
+ return f.metrics("linespace"), f.measure
388
+ except Exception:
389
+ size = 10
390
+ try:
391
+ # abs(): a scaled font's size is negative (pixels).
392
+ size = abs(int(font[1])) or 10
393
+ except Exception:
394
+ pass
395
+ return int(size * 1.4), (lambda text: int(len(str(text)) * size * 0.6))
396
+
397
+ def _draw_meter(self):
398
+ """Wipes and paints fresh discrete LED lines, scale calibrations, and labels using adaptive look tokens."""
399
+ self.canvas.delete("all")
400
+ width, height = self.canvas.winfo_width(), self.canvas.winfo_height()
401
+ if width < 10 or height < 10: return
402
+
403
+ # No fallbacks: _validate_theme_keys() hard-failed at construction if
404
+ # any of these were missing, so every lookup is guaranteed to resolve.
405
+ bg_color = self._resolve_color(self._theme_bg_raw)
406
+ amber_color = self._resolve_color(self._themed("text_color"))
407
+ red_color = self._resolve_color(self._themed("alarm_color"))
408
+ led_on_color = self._resolve_color(self._themed("led_on_color"))
409
+ led_off_color = self._resolve_color(self._themed("led_off_color"))
410
+ label_font = self._scaled_font(self._local_defaults.get("font"))
411
+ scale_font = self._scaled_font(self._local_defaults.get("scale_font"))
412
+ # FIX: was a fully hardcoded self._resolve_color(("#94A3B8", "#334155"))
413
+ # with no theme lookup at all -- the only colour in this widget the
414
+ # theme could not reach.
415
+ disabled_color = self._resolve_color(self._local_defaults.get("inactive_color"))
416
+ self.canvas.configure(bg=bg_color)
417
+
418
+ num_led_segments = 30
419
+ # Right margin sized for the LAST top-scale label rather than a fixed
420
+ # 30px. That label is centred on the bar's end, so half of it sits
421
+ # beyond -- and it is "+60 dB", the widest string on the scale, so a
422
+ # larger scale_font ran the "B" off the canvas.
423
+ #
424
+ # Measured from bar_scale_mappings below rather than from a guessed
425
+ # list: an earlier version measured "dB" and reserved room for a string
426
+ # a third of the real width, which held until about 14pt and then
427
+ # clipped again.
428
+ scale_h, scale_measure = self._font_metrics(scale_font)
429
+ label_h, _ = self._font_metrics(label_font)
430
+ last_label = "+60 dB"
431
+ start_x, end_x = 10, width - max(30, int(scale_measure(last_label) / 2) + 12)
432
+ total_length = end_x - start_x
433
+ # Row positions follow which rows are actually drawn, so a
434
+ # single-row meter is centred rather than sitting where it would
435
+ # have been in a two-row one.
436
+ show_sig = not self._hide_sig_row
437
+ show_lower = not self._hide_lower_row
438
+ if show_sig and show_lower:
439
+ sig_y, lower_y = int(height * 0.28), int(height * 0.70)
440
+ elif show_sig:
441
+ sig_y, lower_y = int(height * 0.50), int(height * 0.70)
442
+ else:
443
+ sig_y, lower_y = int(height * 0.28), int(height * 0.50)
444
+ segment_width = (total_length / num_led_segments) - 1.5
445
+
446
+ if show_sig:
447
+ val = self._current_s_value
448
+ s_fraction = (max(0.0, val) / 9.0) * 0.60 if val <= 9.0 else 0.60 + ((min(69.0, val) - 9.0) / 60.0) * 0.40
449
+ active_sig_segments = int(num_led_segments * max(0.0, min(1.0, s_fraction)))
450
+
451
+ for i in range(num_led_segments):
452
+ seg_start_x = start_x + (i * (total_length / num_led_segments))
453
+ is_sig_redzone = i >= int(num_led_segments * 0.60)
454
+ fill = (red_color if is_sig_redzone else led_on_color) if i < active_sig_segments else led_off_color
455
+ self.canvas.create_rectangle(seg_start_x, sig_y - 4, seg_start_x + segment_width, sig_y + 1, fill=fill, outline="")
456
+
457
+ bar_scale_mappings = [(0.0, ""), (0.066, "1"), (0.20, "3"), (0.333, "5"), (0.466, "7"), (0.60, "9"), (0.733, "+20"), (0.866, "+40"), (1.0, "+60 dB")]
458
+ for pct, label_str in bar_scale_mappings:
459
+ tx = start_x + (total_length * pct)
460
+ color = red_color if pct >= 0.60 else amber_color
461
+ self.canvas.create_line(tx, sig_y, tx, sig_y - 6, fill=color, width=1)
462
+ # Above the tick, clear of it by the label's own height.
463
+ if label_str: self.canvas.create_text(tx, sig_y - 8 - (scale_h / 2), text=label_str, fill=color, font=scale_font, anchor="center")
464
+
465
+ self.canvas.create_text(start_x, sig_y - 8 - (scale_h / 2), text="S", fill=amber_color, font=scale_font, anchor="center")
466
+ self.canvas.create_text(start_x + (total_length * 0.5), sig_y + 5, text="SIG", fill=amber_color, font=label_font, anchor="n")
467
+ # THE READING, IN WORDS, at the right end of the caption line --
468
+ # level with "SIG", aligned to the end of the bar. The bar shows
469
+ # roughly where a signal is; this says exactly, in whatever units
470
+ # the caller has (the meter does not know what they are).
471
+ if self._current_sig_text:
472
+ self.canvas.create_text(end_x, sig_y + 5, text=self._current_sig_text,
473
+ fill=amber_color, font=label_font, anchor="ne")
474
+
475
+
476
+ if not show_lower:
477
+ return
478
+
479
+ mid_gap_start, mid_gap_end = 13, 17
480
+ def get_swr_fraction(swr_val):
481
+ if swr_val <= 1.0: return 0.0
482
+ if swr_val >= self.swr_max_value: return 1.0
483
+ return (math.log10(swr_val + 0.5) - math.log10(1.5)) / (math.log10(self.swr_max_value + 0.5) - math.log10(1.5))
484
+
485
+ active_swr_segments = int(mid_gap_start * get_swr_fraction(self._current_swr_value))
486
+ pwr_total_segments = num_led_segments - mid_gap_end
487
+ active_pwr_segments = int(pwr_total_segments * (max(0.0, min(100.0, self._current_pwr_value)) / 100.0))
488
+
489
+ for i in range(num_led_segments):
490
+ seg_start_x = start_x + (i * (total_length / num_led_segments))
491
+ if mid_gap_start <= i < mid_gap_end:
492
+ fill = bg_color
493
+ elif i < mid_gap_start:
494
+ is_swr_redzone = (i / mid_gap_start) >= get_swr_fraction(2.0)
495
+ fill = (red_color if is_swr_redzone else led_on_color) if (i < active_swr_segments and self._swr_visible) else led_off_color
496
+ else:
497
+ pwr_index = i - mid_gap_end
498
+ is_pwr_redzone = pwr_index >= int(pwr_total_segments * 0.8)
499
+ fill = (red_color if is_pwr_redzone else led_on_color) if (pwr_index < active_pwr_segments and self._pwr_visible) else led_off_color
500
+ self.canvas.create_rectangle(seg_start_x, lower_y - 4, seg_start_x + segment_width, lower_y + 1, fill=fill, outline="")
501
+
502
+ swr_label_color = amber_color if self._swr_visible else disabled_color
503
+ pwr_label_color = amber_color if self._pwr_visible else disabled_color
504
+
505
+ # THE ROW FLIPS WHEN IT IS ALONE.
506
+ #
507
+ # With both rows drawn these two are mirror images on purpose: the S
508
+ # row puts its scale above and its caption below, this one puts its
509
+ # captions above and its scale below, so the captions meet in the
510
+ # middle and the two scales face outward. That only works as a pair.
511
+ #
512
+ # Hide the S row and this one is left upside down relative to a
513
+ # single-row S meter -- caption at the top, numbers at the bottom.
514
+ # Alone, it takes the same arrangement the S row uses.
515
+ caption_above = show_sig
516
+ caption_y = (lower_y - 6 - label_h) if caption_above else (lower_y + 5)
517
+ caption_anchor = "n"
518
+ scale_side = 1 if caption_above else -1
519
+
520
+ self.canvas.create_text(start_x + (total_length * ((mid_gap_start / num_led_segments) * 0.5)), caption_y, text="SWR", fill=swr_label_color, font=label_font, anchor=caption_anchor)
521
+ self.canvas.create_text(start_x + (total_length * ((mid_gap_end / num_led_segments) + ((1.0 - (mid_gap_end / num_led_segments)) * 0.5))), caption_y, text="PWR", fill=pwr_label_color, font=label_font, anchor=caption_anchor)
522
+
523
+ swr_ticks = [1.0, 1.5, 2.0]
524
+ if self.swr_max_value > 2.0:
525
+ swr_ticks.extend([round(2.0 + ((self.swr_max_value - 2.0) / 2.0), 1), round(self.swr_max_value, 1)])
526
+
527
+ for val in swr_ticks:
528
+ tx = start_x + (total_length * (get_swr_fraction(val) * (mid_gap_start / num_led_segments)))
529
+ color = (red_color if val >= 2.0 else amber_color) if self._swr_visible else disabled_color
530
+ label = (str(int(val)) if val.is_integer() else str(val)) + ("+" if val == self.swr_max_value else "")
531
+ self.canvas.create_line(tx, lower_y, tx, lower_y + (6 * scale_side), fill=color, width=1)
532
+ if caption_above:
533
+ self.canvas.create_text(tx, lower_y + 8, text=label, fill=color, font=scale_font, anchor="n")
534
+ else:
535
+ self.canvas.create_text(tx, lower_y - 8 - (scale_h / 2), text=label, fill=color, font=scale_font, anchor="center")
536
+
537
+ for val, label in [(0, "0"), (50, "50"), (100, "100%")]:
538
+ tx = start_x + (total_length * ((mid_gap_end / num_led_segments) + ((val / 100.0) * (1.0 - (mid_gap_end / num_led_segments)))))
539
+ color = (red_color if val >= 80 else amber_color) if self._pwr_visible else disabled_color
540
+ self.canvas.create_line(tx, lower_y, tx, lower_y + (6 * scale_side), fill=color, width=1)
541
+ if caption_above:
542
+ self.canvas.create_text(tx - 4 if val == 100 else tx, lower_y + 8, text=label, fill=color, font=scale_font, anchor="n")
543
+ else:
544
+ self.canvas.create_text(tx - 4 if val == 100 else tx, lower_y - 8 - (scale_h / 2), text=label, fill=color, font=scale_font, anchor="center")
545
+ def set(self, s_value=None, swr_value=None, pwr_value=None, sig_text=None):
546
+ """
547
+ Update any telemetry channel row independently.
548
+
549
+ sig_text: the S reading in words -- "-63 dBFS" -- drawn at the right
550
+ end of the S row's caption line. "" removes it; None leaves it as
551
+ it is. Runtime state like the values, not a property.
552
+ """
553
+ if sig_text is not None: self._current_sig_text = str(sig_text)
554
+ if s_value is not None: self._current_s_value = float(s_value)
555
+ if swr_value is not None: self._current_swr_value = float(swr_value)
556
+ if pwr_value is not None: self._current_pwr_value = float(pwr_value)
557
+ if self.canvas.winfo_exists(): self._draw_meter()