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,869 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkTableview
4
+
5
+ A theme-compliant custom data grid component wrapper.
6
+ Inherits cleanly from sCTkScrollableFrame and ThemeableWidget to manage
7
+ dense telemetry spreadsheets safely with full live theme repaint loops.
8
+ """
9
+ import customtkinter as ctk
10
+ from .themeable_widget import ThemeableWidget, parse_list_property, defer, cancel_deferred
11
+
12
+ from typing import List, Optional, Callable, Any, Literal
13
+
14
+ from .sctk_scrollable_frame import sCTkScrollableFrame
15
+ from .sctk_label_primary import sCTkLabelPrimary
16
+ from .sctk_label_secondary import sCTkLabelSecondary
17
+ from .sctk_entry_primary import sCTkEntryPrimary
18
+ from .sctk_optionmenu_primary import sCTkOptionMenuPrimary
19
+
20
+
21
+
22
+ class sCTkTableview(sCTkScrollableFrame, ThemeableWidget):
23
+ def __init__(self, master: any, columns: Optional[Any] = None, width: int = 500, height: int = 300,
24
+ grid_mode: Literal["zebra", "grid", "none"] = "zebra", header_line_width: int = 2,
25
+ outline_width: float = 1.0, outline_radius: int = 4, state: Literal["normal", "disabled"] = "normal",
26
+ num_columns: int = 3, num_rows: int = 1, show_headers: Any = True,
27
+ cell_bg_color: Optional[Any] = None, cell_alt_bg_color: Optional[Any] = None,
28
+ editable_columns: Optional[Any] = None,
29
+ edit_trigger: Literal["double", "select"] = "double",
30
+ select_rows: Any = False, column_choices: Optional[dict] = None,
31
+ *args, **kwargs):
32
+
33
+ # 1. Run shared mixin logic first to parse master themes.json data maps safely
34
+ ThemeableWidget.__init__(self, kwargs)
35
+
36
+ # 2. Resolve theme profiles FIRST, before extracting individual
37
+ # values, so required keys can be validated before anything reads
38
+ # them.
39
+ self._switch_theme_profile = dict(self.final_kw)
40
+ # FIX: an earlier version read "disabled_map" out of
41
+ # self._switch_theme_profile (== dict(self.final_kw)), but
42
+ # ThemeableWidget.__init__ deliberately excludes "disabled_map" from
43
+ # final_kw -- this always evaluated to the empty-dict default, meaning
44
+ # EVERY disabled-state color lookup in _apply_state_and_theme_updates()
45
+ # silently fell back to its hardcoded literal instead of the real
46
+ # theme. Confirmed identical bug, same fix, as sCTkSwitch and
47
+ # sCTkSpinbox elsewhere in this project.
48
+ self._custom_disabled_map = dict(self._widget_disabled_map)
49
+
50
+ # FIX: an earlier version used .get(key, hardcoded_literal) for every
51
+ # color/font below, both here and in _apply_state_and_theme_updates(),
52
+ # silently substituting a guessed value whenever the real theme was
53
+ # incomplete rather than surfacing the gap. Matches the same hard-fail
54
+ # principle established for sCTkSwitch and the label family elsewhere
55
+ # in this project: required keys must be present in both the
56
+ # top-level theme block and disabled_map, or construction raises
57
+ # immediately with a clear message naming exactly what's missing.
58
+ #
59
+ # Colors need a disabled_map entry too, since they visually change
60
+ # when disabled. Fonts don't -- BUG, confirmed by direct testing: an
61
+ # earlier version of this validation incorrectly bundled
62
+ # header_font/cell_font into this same loop, requiring them in
63
+ # disabled_map too, even though no widget in this project uses a
64
+ # disabled-state font variant (fonts are always top-level-only).
65
+ # Split into two separate loops below to fix this.
66
+ for required_key in ("header_bg_color", "header_text_color",
67
+ "cell_text_color", "grid_line_color"):
68
+ if self._switch_theme_profile.get(required_key) is None:
69
+ raise KeyError(
70
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
71
+ f"at the top level of sCTkThemes.json."
72
+ )
73
+ if self._custom_disabled_map.get(required_key) is None:
74
+ raise KeyError(
75
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' in disabled_map."
76
+ )
77
+
78
+ # Fonts only need to exist at the top level.
79
+ for required_key in ("header_font", "cell_font"):
80
+ if self._switch_theme_profile.get(required_key) is None:
81
+ raise KeyError(
82
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
83
+ f"at the top level of sCTkThemes.json."
84
+ )
85
+
86
+ # cell_bg_color/cell_alt_bg_color are different: they have a genuine
87
+ # constructor-kwarg override, so it's not automatically a theme gap
88
+ # if the theme itself doesn't define them -- only a problem if
89
+ # NEITHER the kwarg NOR the theme provides a value. disabled_map is
90
+ # still required either way, since there's no disabled-state kwarg
91
+ # override.
92
+ for ctor_key, ctor_val in (("cell_bg_color", cell_bg_color), ("cell_alt_bg_color", cell_alt_bg_color)):
93
+ if ctor_val is None and self._switch_theme_profile.get(ctor_key) is None:
94
+ raise KeyError(
95
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}': '{ctor_key}' must be provided either as a "
96
+ f"constructor argument or in the theme's top-level block."
97
+ )
98
+ if self._custom_disabled_map.get(ctor_key) is None:
99
+ raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{ctor_key}' in disabled_map.")
100
+
101
+ # 3. Extract theme-driven values -- safe to read directly via .get()
102
+ # now, without a fallback, since the validation above guarantees each
103
+ # key exists.
104
+ self._header_bg = self._switch_theme_profile.get("header_bg_color")
105
+ self._header_fg = self._switch_theme_profile.get("header_text_color")
106
+ self._header_font = self._switch_theme_profile.get("header_font")
107
+
108
+ self._cell_bg = cell_bg_color if cell_bg_color is not None else self._switch_theme_profile.get("cell_bg_color")
109
+ self._cell_alt_bg = cell_alt_bg_color if cell_alt_bg_color is not None else self._switch_theme_profile.get("cell_alt_bg_color")
110
+ self._cell_fg = self._switch_theme_profile.get("cell_text_color")
111
+ self._cell_font = self._switch_theme_profile.get("cell_font")
112
+ self._grid_line_color = self._switch_theme_profile.get("grid_line_color")
113
+
114
+ # OPTIONAL, unlike the colours above: a theme written before rows
115
+ # could be selected has no such key, and requiring it would break
116
+ # every one of them. Absent, the grid-line colour stands in -- still
117
+ # a theme value, never a hardcoded one.
118
+ #
119
+ # NOT the header colour, which this first used. In the library's own
120
+ # dark theme it differs from the rows by 6 (on a 0-765 scale), so the
121
+ # selected row was invisible. The grid-line colour stands clear of the
122
+ # rows in both modes. A theme should still set cell_selected_color:
123
+ # no existing colour was chosen to be a selection colour.
124
+ self._selected_bg = (self._switch_theme_profile.get("cell_selected_color")
125
+ or self._grid_line_color)
126
+
127
+ # FIX (related bug found while eliminating the fallback above): an
128
+ # earlier version of _apply_state_and_theme_updates() always reverted
129
+ # cell_bg_color/cell_alt_bg_color to the theme's value when
130
+ # transitioning back to "normal", silently discarding a constructor
131
+ # kwarg override (e.g. sCTkTableview(master, cell_bg_color="red"))
132
+ # after a disable/enable cycle. Caching the actually-resolved values
133
+ # here lets that method correctly restore whichever one this
134
+ # constructor call actually resolved to, not just the theme default.
135
+ self._resolved_normal_cell_bg = self._cell_bg
136
+ self._resolved_normal_cell_alt_bg = self._cell_alt_bg
137
+
138
+ # 3. Initialize specialized layout tracking fields
139
+ self._grid_mode = str(grid_mode).replace("'", "").replace('"', "").strip().lower()
140
+ self._header_line_width = int(header_line_width) if header_line_width is not None else 2
141
+ self._outline_width = float(outline_width) if outline_width else 1.0
142
+ self._outline_radius = int(outline_radius) if outline_radius else 4
143
+ self._state = state
144
+
145
+ self._num_columns = int(num_columns)
146
+ # COLUMNS SETS THE COLUMN COUNT, as the documentation always said. It
147
+ # did not: only the Designer's builder and configure() applied it, so
148
+ # a table built in code with five column names showed three -- the
149
+ # default count -- and silently dropped every column after the third,
150
+ # headers, cells and set_column_properties() alike. Worked out here,
151
+ # before the per-column widths below are sized from it.
152
+ if isinstance(columns, str):
153
+ columns = parse_list_property(columns)
154
+ if columns and isinstance(columns, (list, tuple)):
155
+ self._num_columns = len(columns)
156
+ self._num_rows = int(num_rows)
157
+ self._show_headers = str(show_headers).replace("'", "").replace('"', "").strip().lower() in ("true", "1", "yes")
158
+
159
+ # 🔑 FIXED LIFECYCLE REORDER: Pre-hydrate layout property variables completely
160
+ # BEFORE running super().__init__ to eliminate asynchronous AttributeError gaps!
161
+ self._column_widths = [120] * self._num_columns
162
+ self._column_anchors = ["center"] * self._num_columns
163
+ self._click_callback, self._edit_callback, self._validation_callback = None, None, None
164
+
165
+ # --- editing and selection --------------------------------------
166
+ # All three default to what the table always did -- every column
167
+ # editable, by double-click, and no row highlighted -- so an existing
168
+ # table is unchanged unless it asks for something else.
169
+ #
170
+ # editable_columns: None for all, else the column indices that may be
171
+ # edited. The rest are read-only, which a column such as a row
172
+ # number or a computed note needs.
173
+ # edit_trigger: "double" opens the editor on a double-click, as ever.
174
+ # "select" opens it on a click in the row that is ALREADY selected
175
+ # -- the spreadsheet and file-manager convention -- which frees the
176
+ # double-click to mean something else; see bind_activate_callback.
177
+ # select_rows: highlight the row last clicked. Implied by "select",
178
+ # which cannot work without a selection.
179
+ self._editable_columns = self._parse_columns(editable_columns)
180
+ self._edit_trigger = ("select" if str(edit_trigger).strip().lower() == "select"
181
+ else "double")
182
+ self._select_rows = (self._edit_trigger == "select"
183
+ or str(select_rows).strip().lower() in ("true", "1", "yes"))
184
+ self._selected_row = None
185
+ self._activate_callback = None
186
+ self._cell_editable_callback = None
187
+ # column index -> the values it may hold. Such a column is edited with
188
+ # a dropdown rather than typed: a fixed set of choices typed by hand
189
+ # invites typos, and each needs a rule for what counts as valid.
190
+ self._column_choices = {int(c): list(v) for c, v in (column_choices or {}).items()}
191
+ self._validation_with_row = False
192
+ self._editor = None
193
+ self._pending_edit = None
194
+ self._data_matrix, self._cell_widgets, self._header_widgets = [], [], []
195
+
196
+ self.columns_list = (list(columns) if (columns and isinstance(columns, (list, tuple)))
197
+ else [""] * self._num_columns)
198
+
199
+ # FIX: an earlier version temporarily overwrote self.__class__.__name__
200
+ # to "sCTkScrollableFrame" here, then restored it immediately after --
201
+ # a workaround for two problems that are now solved properly upstream:
202
+ # (1) sCTkScrollableFrame.__init__'s own internal ThemeableWidget.__init__
203
+ # call would otherwise run a second time on this instance, using
204
+ # self.__class__.__name__ (always "sCTkTableview", regardless of which
205
+ # class's code is executing) to look up the wrong theme block and
206
+ # overwrite this widget's correctly-built final_kw -- now prevented by
207
+ # ThemeableWidget's own run-once guard (see themeable_widget.py). (2)
208
+ # even with final_kw correctly preserved, sCTkScrollableFrame's own
209
+ # super().__init__() call would forward Tableview-specific keys like
210
+ # header_bg_color to the native CTkScrollableFrame constructor, which
211
+ # doesn't recognize them and has no **kwargs catch-all to fall back on
212
+ # -- now prevented by sCTkScrollableFrame's own whitelist filtering of
213
+ # its inbound kwargs before that call. Confirmed safe to call directly.
214
+ # The height asked for is a CEILING, not a suggestion: see load_dataset.
215
+ self._max_height = int(height)
216
+ super().__init__(master=master, width=width, height=height, *args)
217
+
218
+ super().configure(border_width=0, corner_radius=0, fg_color=self._cell_bg)
219
+ self.table_outline_frame = ctk.CTkFrame(self, fg_color=self._grid_line_color, border_width=self._outline_width, border_color=self._grid_line_color, corner_radius=self._outline_radius)
220
+ self.table_outline_frame.grid(row=0, column=0, sticky="nw", padx=1, pady=1)
221
+
222
+ self._create_header_bar()
223
+ self.load_dataset([[""] * self._num_columns for _ in range(self._num_rows)])
224
+ self.configure(state=state)
225
+ self._finalize_themeable_lifecycle()
226
+
227
+ def _create_header_bar(self):
228
+ for w in self._header_widgets:
229
+ try: w.destroy()
230
+ except Exception: pass
231
+ self._header_widgets = []
232
+ if hasattr(self, "header_separator") and self.header_separator:
233
+ try: self.header_separator.destroy()
234
+ except Exception: pass
235
+ self.header_separator = None
236
+
237
+ if not self._show_headers: return
238
+ is_none_mode = (self._grid_mode == "none")
239
+ gap_size, edge_size = (0 if is_none_mode else 1), (0 if is_none_mode else 2)
240
+
241
+ if self._header_line_width > 0:
242
+ self.header_separator = ctk.CTkFrame(self.table_outline_frame, height=self._header_line_width, fg_color=self._grid_line_color, corner_radius=0)
243
+ self.header_separator.grid(row=1, column=0, columnspan=self._num_columns, sticky="ew", padx=(edge_size, edge_size + gap_size), pady=(0, edge_size))
244
+
245
+ render_labels = list(self.columns_list)
246
+ if len(render_labels) < self._num_columns: render_labels += [""] * (self._num_columns - len(render_labels))
247
+ elif len(render_labels) > self._num_columns: render_labels = render_labels[:self._num_columns]
248
+
249
+ for col_idx, col_name in enumerate(render_labels):
250
+ w_limit = self._column_widths[col_idx] if col_idx < len(self._column_widths) else 120
251
+ header_cell = sCTkLabelPrimary(self.table_outline_frame, text=col_name, font=self._header_font, text_color=self._header_fg, fg_color=self._header_bg, corner_radius=0, height=28, width=w_limit)
252
+ left_pad, right_pad = (edge_size if col_idx == 0 else gap_size), (edge_size if col_idx == len(render_labels) - 1 else 0)
253
+ header_cell.grid(row=0, column=col_idx, sticky="ew", padx=(left_pad, right_pad), pady=(edge_size, 0))
254
+ self.table_outline_frame.grid_columnconfigure(col_idx, weight=0)
255
+ try: header_cell.lift()
256
+ except Exception: pass
257
+ self._header_widgets.append(header_cell)
258
+ def load_dataset(self, dataset: List[List[Any]]):
259
+ # An editor open over the old cells would be left floating over the
260
+ # new ones. Cancelled, not saved: the data it was editing is being
261
+ # replaced.
262
+ self._cancel_pending_edit()
263
+ self._close_editor(save=False)
264
+ for cell in [c for row in self._cell_widgets for c in row]: cell.destroy()
265
+ self._data_matrix, self._cell_widgets = [list(row) for row in dataset], []
266
+ super().configure(width=0, height=0)
267
+
268
+ row_offset = 1 if (self._show_headers and self._grid_mode == "none" and self._header_line_width == 0) else (2 if self._show_headers else 0)
269
+ is_none_mode = (self._grid_mode == "none")
270
+ gap_size, edge_size = (0 if is_none_mode else 1), (0 if is_none_mode else 2)
271
+
272
+ for r_idx, r_data in enumerate(self._data_matrix):
273
+ if len(r_data) < self._num_columns: r_data += [""] * (self._num_columns - len(r_data)); self._data_matrix[r_idx] = r_data
274
+ elif len(r_data) > self._num_columns: r_data = r_data[:self._num_columns]; self._data_matrix[r_idx] = r_data
275
+
276
+ current_row_bg = self._row_bg(r_idx)
277
+ r_cells = []
278
+ for c_idx in range(self._num_columns):
279
+ val = r_data[c_idx]
280
+ w_limit = self._column_widths[c_idx] if c_idx < len(self._column_widths) else 120
281
+ txt_anchor = self._column_anchors[c_idx] if c_idx < len(self._column_anchors) else "center"
282
+ display_val = " " + str(val) if txt_anchor == "w" else (str(val) + " " if txt_anchor == "e" else str(val))
283
+
284
+ cell_label = sCTkLabelSecondary(self.table_outline_frame, text=display_val, font=self._cell_font, text_color=self._cell_fg, width=w_limit, height=26, corner_radius=0, anchor=txt_anchor, fg_color="transparent")
285
+ cell_label.configure(fg_color=current_row_bg)
286
+
287
+ if self._state == "disabled": cell_label.configure(state="disabled")
288
+
289
+ top_pad, bot_pad = (edge_size if r_idx == 0 else gap_size), (edge_size if r_idx == len(self._data_matrix) - 1 else 0)
290
+ left_pad, right_pad = (edge_size if c_idx == 0 else gap_size), (edge_size if c_idx == self._num_columns - 1 else 0)
291
+ cell_label.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=(left_pad, right_pad), pady=(top_pad, bot_pad))
292
+
293
+ cell_label.bind("<Button-1>", lambda e, r=r_idx, c=c_idx: self._on_cell_click(r, c))
294
+ cell_label.bind("<Double-Button-1>", lambda e, r=r_idx, c=c_idx: self._on_cell_double(r, c))
295
+ r_cells.append(cell_label)
296
+ self._cell_widgets.append(r_cells)
297
+
298
+ # A selection beyond the new rows is dropped; one within them stays.
299
+ if self._selected_row is not None and self._selected_row >= len(self._data_matrix):
300
+ self._selected_row = None
301
+
302
+ for hw in self._header_widgets:
303
+ try: hw.lift()
304
+ except Exception: pass
305
+ if hasattr(self, "header_separator") and self.header_separator:
306
+ try: self.header_separator.lift()
307
+ except Exception: pass
308
+
309
+ self.update_idletasks()
310
+ # Fits its WIDTH to the columns, and its height to the rows -- but no
311
+ # taller than the height it was given. Beyond that it scrolls, which
312
+ # is what a scrolling frame is for. It used to fit the height to every
313
+ # row, so a long table asked for enough room never to scroll and
314
+ # pushed whatever else shared its parent out of the window.
315
+ super().configure(width=self.table_outline_frame.winfo_reqwidth() + 14,
316
+ height=min(self.table_outline_frame.winfo_reqheight() + 18,
317
+ self._max_height))
318
+
319
+ # ------------------------------------------------------------------
320
+ # Clicks
321
+ # ------------------------------------------------------------------
322
+ # How long a click on the selected row waits before opening the editor.
323
+ # A double-click begins with an ordinary click, so without this pause a
324
+ # double-click on the selected row would open the editor AND activate the
325
+ # row. The click waits; a double-click arriving in the meantime cancels
326
+ # it. The same pause file managers use before renaming.
327
+ EDIT_DELAY_MS = 500
328
+
329
+ # Where a dropdown editor's list opens, relative to the cell it edits:
330
+ # "below" -- just under the cell, leaving the value visible
331
+ # "over" -- starting at the cell's top edge, as a native menu does
332
+ # Settable per table, or for every table on the class.
333
+ CHOICE_LIST_POSITION = "below"
334
+
335
+ def _on_cell_click(self, r_idx: int, c_idx: int):
336
+ if self._state != "normal":
337
+ return
338
+ # A text editor closes itself when it loses focus; a dropdown does
339
+ # not, so a click on any cell closes one left open -- keeping nothing
340
+ # unless a value was chosen.
341
+ if self._editor and getattr(self._editor[0], "_is_choice", False):
342
+ self._close_editor(save=True)
343
+ was_selected = (self._selected_row == r_idx)
344
+ if self._select_rows:
345
+ self._set_selection(r_idx)
346
+ if self._click_callback:
347
+ self._click_callback(r_idx, self._data_matrix[r_idx])
348
+ if (self._edit_trigger == "select" and was_selected
349
+ and self._is_editable(r_idx, c_idx)):
350
+ self._cancel_pending_edit()
351
+ self._pending_edit = defer(
352
+ self, self.EDIT_DELAY_MS, lambda: self._begin_pending_edit(r_idx, c_idx))
353
+
354
+ def _on_cell_double(self, r_idx: int, c_idx: int):
355
+ if self._state != "normal":
356
+ return
357
+ self._cancel_pending_edit()
358
+ if self._edit_trigger == "double" and self._is_editable(r_idx, c_idx):
359
+ self._spawn_editor(r_idx, c_idx)
360
+ elif self._activate_callback:
361
+ self._activate_callback(r_idx, self._data_matrix[r_idx])
362
+
363
+ def _begin_pending_edit(self, r_idx: int, c_idx: int):
364
+ self._pending_edit = None
365
+ if self._state == "normal" and r_idx < len(self._data_matrix):
366
+ self._spawn_editor(r_idx, c_idx)
367
+
368
+ def _cancel_pending_edit(self):
369
+ if self._pending_edit is not None:
370
+ cancel_deferred(self, self._pending_edit)
371
+ self._pending_edit = None
372
+
373
+ def _is_editable(self, r_idx: int, c_idx: int) -> bool:
374
+ """
375
+ The column must be editable, and -- if a cell check is bound -- so
376
+ must this particular cell. A cell that may not be edited never opens
377
+ an editor, rather than opening one and then throwing the typing away.
378
+ """
379
+ if self._editable_columns is not None and c_idx not in self._editable_columns:
380
+ return False
381
+ if self._cell_editable_callback is not None:
382
+ return bool(self._cell_editable_callback(r_idx, c_idx))
383
+ return True
384
+
385
+ @staticmethod
386
+ def _parse_columns(value):
387
+ """None for every column, else a set of column indices."""
388
+ if value is None:
389
+ return None
390
+ if isinstance(value, str):
391
+ value = parse_list_property(value) if value.strip() else []
392
+ return {int(v) for v in value}
393
+
394
+ # ------------------------------------------------------------------
395
+ # Editing a cell
396
+ # ------------------------------------------------------------------
397
+ def _spawn_editor(self, r_idx: int, c_idx: int):
398
+ # One editor at a time. A second one is only opened by a deliberate
399
+ # edit elsewhere, so the first is kept, as a spreadsheet would.
400
+ self._close_editor(save=True)
401
+ row_offset = 1 if (self._show_headers and self._grid_mode == "none" and self._header_line_width == 0) else (
402
+ 2 if self._show_headers else 0)
403
+ choices = self._column_choices.get(c_idx)
404
+ if choices:
405
+ self._spawn_choice_editor(r_idx, c_idx, choices, row_offset)
406
+ return
407
+
408
+ # sCTkEntryPrimary, not a bare CTkEntry, so the editor follows the
409
+ # theme like every other control in the library.
410
+ entry = sCTkEntryPrimary(self.table_outline_frame, font=self._cell_font,
411
+ width=self._column_widths[c_idx], height=24,
412
+ corner_radius=0)
413
+ entry.insert(0, str(self._data_matrix[r_idx][c_idx]))
414
+ entry.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=1, pady=1)
415
+ entry.focus_set()
416
+ entry.select_range(0, "end")
417
+ entry.bind("<Return>", lambda e: self._save_edit(r_idx, c_idx, entry))
418
+ entry.bind("<FocusOut>", lambda e: self._save_edit(r_idx, c_idx, entry))
419
+ # ESCAPE CANCELS. Without it, the only way out of an editor opened by
420
+ # mistake was to retype the old value -- Return and a click away both
421
+ # save.
422
+ entry.bind("<Escape>", lambda e: self._cancel_editor(entry))
423
+ self._editor = (entry, r_idx, c_idx)
424
+
425
+ def _spawn_choice_editor(self, r_idx, c_idx, choices, row_offset):
426
+ """
427
+ A dropdown over the cell, open straight away.
428
+
429
+ ONLY A CHOICE IS KEPT. The menu has to show something when it opens,
430
+ and for a cell holding none of the choices -- empty, say -- that is
431
+ the first one. Closing it without choosing must not store that first
432
+ choice as though it had been picked, so the value is saved from the
433
+ menu's command, and nowhere else.
434
+ """
435
+ current = str(self._data_matrix[r_idx][c_idx])
436
+ # The cell's font, for the box and its list alike, so the value does
437
+ # not jump in size the moment it becomes editable -- the option menu's
438
+ # own theme font was noticeably larger than the table's text.
439
+ menu = sCTkOptionMenuPrimary(self.table_outline_frame, values=list(choices),
440
+ width=self._column_widths[c_idx], height=24,
441
+ font=self._cell_font, dropdown_font=self._cell_font)
442
+ menu.set(current if current in choices else choices[0])
443
+ menu._is_choice = True
444
+ menu.configure(command=lambda value: self._choose(r_idx, c_idx, menu))
445
+ menu.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=1, pady=1)
446
+ menu.bind("<Escape>", lambda e: self._cancel_editor(menu))
447
+ self._editor = (menu, r_idx, c_idx)
448
+ # Open the list at once, so choosing takes one click, like typing.
449
+ defer(self, "idle", lambda: self._open_choice_list(menu, r_idx, c_idx))
450
+
451
+ def _open_choice_list(self, menu, r_idx, c_idx):
452
+ """
453
+ Opens a dropdown editor's list, placed against its CELL.
454
+
455
+ CustomTkinter places the list from the menu widget's own position on
456
+ screen -- but this menu was only just created, and has not been laid
457
+ out yet, so that position is not known and the list could open well
458
+ away from the table. The cell under it has been on screen all along,
459
+ so its position is right from the start.
460
+
461
+ Uses CTkOptionMenu's own dropdown, which is private. If a later
462
+ version renames it, this falls back to the menu's own opener -- the
463
+ list then opens where CustomTkinter puts it -- or, failing that, the
464
+ menu simply opens on a click.
465
+ """
466
+ if not menu.winfo_exists():
467
+ return
468
+ cell = self._cell_widgets[r_idx][c_idx]
469
+ cell.update_idletasks()
470
+ x = cell.winfo_rootx()
471
+ y = cell.winfo_rooty()
472
+ if str(self.CHOICE_LIST_POSITION).lower() != "over":
473
+ y += cell.winfo_height()
474
+
475
+ dropdown = getattr(menu, "_dropdown_menu", None)
476
+ if dropdown is not None and callable(getattr(dropdown, "open", None)):
477
+ dropdown.open(x, y)
478
+ return
479
+ opener = getattr(menu, "_open_dropdown_menu", None)
480
+ if callable(opener):
481
+ opener()
482
+
483
+ def _choose(self, r_idx, c_idx, menu):
484
+ """A value was chosen from a dropdown editor: keep it."""
485
+ menu._chosen = True
486
+ self._save_edit(r_idx, c_idx, menu)
487
+
488
+ def _cancel_editor(self, entry):
489
+ entry._cancelled = True
490
+ if self._editor and self._editor[0] is entry:
491
+ self._editor = None
492
+ if entry.winfo_exists():
493
+ entry.destroy()
494
+
495
+ def _close_editor(self, save: bool):
496
+ """Closes the open editor, if there is one, saving or not."""
497
+ if not self._editor:
498
+ return
499
+ entry, r_idx, c_idx = self._editor
500
+ # A dropdown is kept only if something was chosen from it.
501
+ if getattr(entry, "_is_choice", False) and not getattr(entry, "_chosen", False):
502
+ save = False
503
+ if save:
504
+ self._save_edit(r_idx, c_idx, entry)
505
+ else:
506
+ self._cancel_editor(entry)
507
+
508
+ def _save_edit(self, r_idx: int, c_idx: int, entry):
509
+ # Cancelled by Escape: the FocusOut that follows the editor's removal
510
+ # must not save what was cancelled.
511
+ if getattr(entry, "_cancelled", False) or not entry.winfo_exists(): return
512
+ val = entry.get()
513
+ entry._cancelled = True # nothing after this saves it twice
514
+ if self._editor and self._editor[0] is entry:
515
+ self._editor = None
516
+ entry.destroy()
517
+ if r_idx >= len(self._data_matrix): return
518
+
519
+ # VALIDATION. The callback may answer:
520
+ # a string -- accept, but store THIS instead (a value tidied up,
521
+ # such as a frequency typed "14.074" shown as
522
+ # "14.074.000")
523
+ # truthy -- accept as typed
524
+ # falsy -- reject; the cell keeps its old value
525
+ # A plain True/False callback behaves exactly as it always did.
526
+ if self._validation_callback:
527
+ verdict = (self._validation_callback(r_idx, c_idx, val) if self._validation_with_row
528
+ else self._validation_callback(c_idx, val))
529
+ if isinstance(verdict, str):
530
+ val = verdict
531
+ elif not verdict:
532
+ val = self._data_matrix[r_idx][c_idx]
533
+
534
+ # FIX: an earlier version compared self._data_matrix[r_idx][c_idx]
535
+ # against val AFTER the assignment below had already written val into
536
+ # that exact cell -- a tautology that was always true, so the edit
537
+ # callback fired on every save regardless of whether anything had
538
+ # actually changed. The previous value has to be captured BEFORE the
539
+ # write. A rejected edit reverts val to the old value above, so the
540
+ # callback correctly stays silent for it too.
541
+ old_val = self._data_matrix[r_idx][c_idx]
542
+
543
+ self._data_matrix[r_idx][c_idx] = val
544
+ self._cell_widgets[r_idx][c_idx].configure(
545
+ text=self._display(val, self._column_anchors[c_idx]))
546
+ if self._edit_callback and old_val != val: self._edit_callback(r_idx, c_idx, val)
547
+
548
+ @staticmethod
549
+ def _display(val, anchor):
550
+ """A cell's text, padded away from the edge it is anchored to."""
551
+ return (" " + str(val) if anchor == "w"
552
+ else (str(val) + " " if anchor == "e" else str(val)))
553
+
554
+ # ------------------------------------------------------------------
555
+ # Selection
556
+ # ------------------------------------------------------------------
557
+ def _row_bg(self, r_idx: int):
558
+ if self._select_rows and r_idx == self._selected_row:
559
+ return self._selected_bg
560
+ return self._cell_alt_bg if (self._grid_mode == "zebra" and r_idx % 2 != 0) else self._cell_bg
561
+
562
+ def _paint_row(self, r_idx):
563
+ if r_idx is None or not 0 <= r_idx < len(self._cell_widgets):
564
+ return
565
+ bg = self._row_bg(r_idx)
566
+ for cell in self._cell_widgets[r_idx]:
567
+ cell.configure(fg_color=bg)
568
+
569
+ def _set_selection(self, r_idx):
570
+ previous, self._selected_row = self._selected_row, r_idx
571
+ if previous != r_idx:
572
+ self._paint_row(previous)
573
+ self._paint_row(r_idx)
574
+
575
+ def select_row(self, r_idx: Optional[int]):
576
+ """
577
+ Selects a row from code -- or clears the selection, given None.
578
+
579
+ Does NOT call the selection callback: selecting from code is the
580
+ application saying what it already knows. The same rule as set() on
581
+ every other widget in the library.
582
+ """
583
+ if r_idx is not None and not 0 <= r_idx < len(self._data_matrix):
584
+ r_idx = None
585
+ self._set_selection(r_idx)
586
+
587
+ def get_selected_row(self) -> Optional[int]:
588
+ """The selected row's index, or None."""
589
+ return self._selected_row
590
+
591
+ def clear_selection(self):
592
+ self.select_row(None)
593
+
594
+ def set_row(self, r_idx: int, values: List[Any]):
595
+ """
596
+ Replaces one row's values in place, without rebuilding the table.
597
+
598
+ load_dataset() destroys and recreates every cell. For a table that
599
+ changes often -- one row at a time, as data arrives -- that is a
600
+ great deal of work and a visible flicker; this updates only the text.
601
+ """
602
+ if not 0 <= r_idx < len(self._data_matrix):
603
+ return
604
+ values = list(values)[:self._num_columns]
605
+ values += [""] * (self._num_columns - len(values))
606
+ self._data_matrix[r_idx] = values
607
+ for c_idx, val in enumerate(values):
608
+ anchor = self._column_anchors[c_idx] if c_idx < len(self._column_anchors) else "center"
609
+ self._cell_widgets[r_idx][c_idx].configure(text=self._display(val, anchor))
610
+
611
+ # Native constructor defaults, for the single-argument query below.
612
+ # Anything not listed reports its current value as its default.
613
+ _NATIVE_QUERY_DEFAULTS = {"width": 500, "height": 300}
614
+
615
+ def configure(self, require_redraw=False, **kwargs):
616
+ if require_redraw is not None and not kwargs and isinstance(require_redraw, str):
617
+ mapping = {
618
+ "state": ("state", "state", "state", "normal", str(getattr(self, "_state", "normal"))),
619
+ "grid_mode": ("grid_mode", "grid_mode", "grid_mode", "zebra",
620
+ str(getattr(self, "_grid_mode", "zebra"))),
621
+ "show_headers": ("show_headers", "show_headers", "show_headers", "True",
622
+ str(getattr(self, "_show_headers", True)))
623
+ }
624
+ if require_redraw in mapping: return mapping[require_redraw]
625
+ if require_redraw in ["num_columns", "num_rows", "header_line_width"]: return (require_redraw,
626
+ require_redraw,
627
+ require_redraw, "0",
628
+ str(getattr(self,
629
+ f"_{require_redraw}",
630
+ 0)))
631
+ # Falls through to sCTkScrollableFrame.configure()'s own
632
+ # single-argument branch, which returns a Tkinter-style 5-tuple for
633
+ # any name it doesn't recognise rather than forwarding the string to
634
+ # native CTkScrollableFrame.configure() -- that signature takes no
635
+ # positional argument and would raise TypeError. Reached from Pygubu
636
+ # when a property is blanked in the inspector.
637
+ # FIX: this used to be `return super().configure(require_redraw)`.
638
+ #
639
+ # The native configure() is declared
640
+ # configure(self, require_redraw=False, **kwargs), so the property
641
+ # NAME was passed as require_redraw and the call returned None.
642
+ # Pygubu's _get_default_value() expects a five-element tuple, got
643
+ # None, and handed that None back to _set_property() -- where
644
+ # CustomTkinter raised "float() argument must be a string or a real
645
+ # number, not 'NoneType'".
646
+ #
647
+ # Reached whenever a field is blanked in the Designer inspector,
648
+ # which calls configure(name) to read the property's default.
649
+ #
650
+ # A proper Tkinter-style tuple is returned instead, built from
651
+ # cget() so the reported current value is real. Properties with no
652
+ # known native default report their current value as the default,
653
+ # making a blank a no-op rather than a jump to a guessed value.
654
+ try:
655
+ _current = self.cget(require_redraw)
656
+ except Exception:
657
+ _current = None
658
+ # FIX: the default came from _NATIVE_QUERY_DEFAULTS or, failing
659
+ # that, from cget() -- the CURRENT value. So clearing a themed
660
+ # property in the Designer reported whatever override was in place
661
+ # as its own default and put it straight back.
662
+ #
663
+ # The THEME's value is the right answer, taken from an untouched
664
+ # copy so a runtime override cannot corrupt it -- see
665
+ # ThemeableWidget._theme_default(). The native table still wins
666
+ # where it has an entry, for properties the theme says nothing
667
+ # about.
668
+ _default = self._NATIVE_QUERY_DEFAULTS.get(require_redraw)
669
+ if _default is None:
670
+ _default = self._theme_default(require_redraw)
671
+ if _default is None:
672
+ _default = _current
673
+ return (require_redraw, require_redraw, require_redraw,
674
+ self._query_value(_default, require_redraw),
675
+ self._query_value(_current, require_redraw))
676
+
677
+ if isinstance(require_redraw, dict): kwargs.update(require_redraw); require_redraw = False
678
+
679
+ # Runtime overrides have to reach the map a repaint reads, or the
680
+ # repaint puts the theme value straight back -- see
681
+ # ThemeableWidget._record_theme_overrides(). Placed after the dict form
682
+ # is merged, so a value passed that way is recorded too.
683
+ self._record_theme_overrides(kwargs)
684
+
685
+ rebuild_layout = False
686
+
687
+ # A new height is a new ceiling -- and is still passed on below.
688
+ if "height" in kwargs:
689
+ self._max_height = int(kwargs["height"])
690
+
691
+ if "editable_columns" in kwargs:
692
+ self._editable_columns = self._parse_columns(kwargs.pop("editable_columns"))
693
+ if "edit_trigger" in kwargs:
694
+ self._edit_trigger = ("select" if str(kwargs.pop("edit_trigger")).strip().lower()
695
+ == "select" else "double")
696
+ self._select_rows = self._select_rows or self._edit_trigger == "select"
697
+ if "select_rows" in kwargs:
698
+ self._select_rows = (self._edit_trigger == "select"
699
+ or str(kwargs.pop("select_rows")).strip().lower()
700
+ in ("true", "1", "yes"))
701
+ for r in range(len(self._cell_widgets)):
702
+ self._paint_row(r)
703
+
704
+ for k in ["cell_bg_color", "cell_alt_bg_color", "num_columns", "num_rows", "header_line_width", "grid_mode",
705
+ "show_headers", "outline_width", "outline_radius", "columns"]:
706
+ if k in kwargs:
707
+ v = kwargs.pop(k)
708
+ if k == "cell_bg_color":
709
+ self._cell_bg = v
710
+ elif k == "cell_alt_bg_color":
711
+ self._cell_alt_bg = v
712
+ elif k == "num_columns":
713
+ self._num_columns = int(v)
714
+ elif k == "num_rows":
715
+ self._num_rows = int(v)
716
+ elif k == "header_line_width":
717
+ self._header_line_width = int(v)
718
+ elif k == "grid_mode":
719
+ self._grid_mode = str(v).replace("'", "").replace('"', "").strip().lower()
720
+ elif k == "show_headers":
721
+ self._show_headers = v if isinstance(v, bool) else (str(v).lower() in ("true", "1", "yes"))
722
+ elif k == "outline_width":
723
+ self._outline_width = float(v); self.table_outline_frame.configure(border_width=self._outline_width)
724
+ elif k == "outline_radius":
725
+ self._outline_radius = int(v); self.table_outline_frame.configure(
726
+ corner_radius=self._outline_radius)
727
+ elif k == "columns":
728
+ # FIX: `columns` was accepted by __init__ and registered as
729
+ # a Pygubu Designer property, but configure() never popped
730
+ # it -- so editing the column list at runtime fell straight
731
+ # through to native CTkScrollableFrame.configure(), whose
732
+ # check_kwargs_empty() raised
733
+ # "['columns'] are not supported arguments". Construction
734
+ # worked; reconfiguration did not, which is exactly what
735
+ # the Designer does when the field is edited.
736
+ #
737
+ # Accepts a comma-separated string as well as a list, since
738
+ # __init__ does and Pygubu's property editor supplies a
739
+ # string.
740
+ parsed = parse_list_property(v)
741
+ self.columns_list = parsed if parsed else [""] * self._num_columns
742
+ # Column count follows the labels, matching how the Designer
743
+ # BO derives num_columns from the columns string.
744
+ self._num_columns = len(self.columns_list)
745
+ rebuild_layout = True
746
+
747
+ if rebuild_layout:
748
+ if len(self._column_widths) < self._num_columns:
749
+ self._column_widths += [120] * (self._num_columns - len(self._column_widths))
750
+ self._column_anchors += ["center"] * (self._num_columns - len(self._column_anchors))
751
+ self._create_header_bar()
752
+ self.load_dataset([[""] * self._num_columns for _ in range(max(self._num_rows, len(self._data_matrix)))])
753
+
754
+ self._apply_state_and_theme_updates(kwargs)
755
+ if kwargs: super().configure(**kwargs)
756
+
757
+ def _apply_state_and_theme_updates(self, kwargs):
758
+ if "state" not in kwargs: return
759
+ self._state = str(kwargs.pop("state")).lower()
760
+ dis_map = self._custom_disabled_map
761
+ normal_map = self._switch_theme_profile
762
+ is_disabled = self._state == "disabled"
763
+
764
+ # FIX: an earlier version repeated the same hardcoded fallback
765
+ # literals here as __init__ (see that method's comments) -- removed
766
+ # now that required keys are validated once, at construction. Also
767
+ # fixes a related bug: cell_bg_color/cell_alt_bg_color now correctly
768
+ # restore whatever this instance actually resolved to when
769
+ # constructed (theme value or constructor kwarg override), instead
770
+ # of always reverting to the theme's value and silently discarding a
771
+ # constructor override on every return to "normal".
772
+ self._header_bg = dis_map.get("header_bg_color") if is_disabled else normal_map.get("header_bg_color")
773
+ self._header_fg = dis_map.get("header_text_color") if is_disabled else normal_map.get("header_text_color")
774
+ self._cell_bg = dis_map.get("cell_bg_color") if is_disabled else self._resolved_normal_cell_bg
775
+ self._cell_alt_bg = dis_map.get("cell_alt_bg_color") if is_disabled else self._resolved_normal_cell_alt_bg
776
+ self._cell_fg = dis_map.get("cell_text_color") if is_disabled else normal_map.get("cell_text_color")
777
+ self._grid_line_color = dis_map.get("grid_line_color") if is_disabled else normal_map.get("grid_line_color")
778
+ # The selection colour follows the state too -- optional in both maps,
779
+ # falling back to the grid-line colour, as at construction.
780
+ self._selected_bg = ((dis_map if is_disabled else normal_map).get("cell_selected_color")
781
+ or self._grid_line_color)
782
+
783
+ if hasattr(self, "table_outline_frame") and self.table_outline_frame:
784
+ self.table_outline_frame.configure(fg_color=self._grid_line_color, border_color=self._grid_line_color)
785
+
786
+ for header_cell in self._header_widgets:
787
+ header_cell.configure(fg_color=self._header_bg, text_color=self._header_fg)
788
+
789
+ for r_idx, row in enumerate(self._cell_widgets):
790
+ row_bg = self._row_bg(r_idx)
791
+ for cell in row:
792
+ cell.configure(fg_color=row_bg, text_color=self._cell_fg, state=self._state)
793
+
794
+ # 🔑 THE CORE DESIGN PATTERN GATEWAY: Aligns fully with all other repository widgets!
795
+ def state(self, mode: str = None) -> str:
796
+ """Unified state tracker gateway. Acts as getter if mode is None, otherwise configures state."""
797
+ if mode is None:
798
+ return str(getattr(self, "_state", "normal")).lower()
799
+ self.configure(state=mode)
800
+ return mode
801
+
802
+ # 🔑 API CONTINUITY PASS-THROUGHS
803
+ def get_state(self) -> str:
804
+ return self.state()
805
+
806
+ config = configure
807
+
808
+ def get_num_rows(self) -> int:
809
+ return len(self._cell_widgets) if self._cell_widgets else self._num_rows
810
+
811
+ def get_num_columns(self) -> int:
812
+ return len(self._cell_widgets) if (self._cell_widgets and self._cell_widgets) else self._num_columns
813
+
814
+ def set_column_properties(self, column_index: int, width: int, anchor: Literal["w", "center", "e"] = "center"):
815
+ if 0 <= column_index < len(self._column_widths):
816
+ self._column_widths[column_index], self._column_anchors[column_index] = width, anchor
817
+ if column_index < len(self._header_widgets) and self._show_headers:
818
+ txt = self.columns_list[column_index] if column_index < len(self.columns_list) else ""
819
+ self._header_widgets[column_index].configure(width=width, anchor=anchor,
820
+ text=" " + txt if anchor == "w" else (
821
+ txt + " " if anchor == "e" else txt))
822
+
823
+ def bind_selection_callback(self, callback: Callable):
824
+ self._click_callback = callback
825
+
826
+ def bind_edit_callback(self, callback: Callable):
827
+ self._edit_callback = callback
828
+
829
+ def set_column_choices(self, column_index: int, choices: Optional[List[str]]):
830
+ """
831
+ Makes a column edited by choosing from a list rather than typing --
832
+ or, given None, typed again.
833
+ """
834
+ if choices:
835
+ self._column_choices[int(column_index)] = list(choices)
836
+ else:
837
+ self._column_choices.pop(int(column_index), None)
838
+
839
+ def bind_cell_editable_callback(self, callback: Callable):
840
+ """
841
+ callback(row_index, column_index) -> bool: whether one cell may be
842
+ edited, for the cases a column rule cannot express -- a column
843
+ editable in some rows and not others. Consulted after
844
+ editable_columns: a column left out there stays read-only whatever
845
+ this says. None removes the check.
846
+ """
847
+ self._cell_editable_callback = callback
848
+
849
+ def bind_activate_callback(self, callback: Callable):
850
+ """
851
+ callback(row_index, row_values), on a double-click that does not open
852
+ an editor -- every double-click when edit_trigger is "select", or one
853
+ on a read-only column otherwise. The natural "open this row" action.
854
+ """
855
+ self._activate_callback = callback
856
+
857
+ def bind_validation_callback(self, callback: Callable, with_row: bool = False):
858
+ """
859
+ callback(column_index, value) -- or, with with_row=True,
860
+ callback(row_index, column_index, value) -- checks an edit before it
861
+ is stored. Return a string to store that instead, anything truthy to
862
+ accept the edit as typed, or anything falsy to reject it.
863
+
864
+ with_row exists because some checks depend on the row: which rows
865
+ may carry a value at all, say. It is a separate switch rather than a
866
+ change of signature, so existing two-argument callbacks go on working.
867
+ """
868
+ self._validation_callback = callback
869
+ self._validation_with_row = bool(with_row)