kspl 1.1.0__py3-none-any.whl → 1.2.0__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.
kspl/gui.py CHANGED
@@ -4,8 +4,8 @@ from argparse import ArgumentParser, Namespace
4
4
  from dataclasses import dataclass, field
5
5
  from enum import auto
6
6
  from pathlib import Path
7
- from tkinter import simpledialog, ttk
8
- from typing import Any, Dict, List, Optional
7
+ from tkinter import font, simpledialog, ttk
8
+ from typing import Any
9
9
 
10
10
  import customtkinter
11
11
  from mashumaro import DataClassDictMixin
@@ -40,56 +40,79 @@ class MainView(CTkView):
40
40
  def __init__(
41
41
  self,
42
42
  event_manager: EventManager,
43
- elements: List[EditableConfigElement],
44
- variants: List[VariantViewData],
43
+ elements: list[EditableConfigElement],
44
+ variants: list[VariantViewData],
45
45
  ) -> None:
46
46
  self.event_manager = event_manager
47
47
  self.elements = elements
48
48
  self.elements_dict = {elem.name: elem for elem in elements}
49
49
  self.variants = variants
50
+ self.all_columns = [v.name for v in self.variants]
51
+ self.visible_columns = list(self.all_columns)
50
52
 
51
53
  self.logger = logger.bind()
52
- self.edit_event_data: Optional[EditEventData] = None
53
- self.trigger_edit_event = self.event_manager.create_event_trigger(
54
- KSplEvents.EDIT
55
- )
54
+ self.edit_event_data: EditEventData | None = None
55
+ self.trigger_edit_event = self.event_manager.create_event_trigger(KSplEvents.EDIT)
56
56
  self.root = customtkinter.CTk()
57
57
 
58
58
  # Configure the main window
59
59
  self.root.title("K-SPL")
60
60
  self.root.geometry(f"{1080}x{580}")
61
61
 
62
+ # Frame for controls
63
+ control_frame = customtkinter.CTkFrame(self.root)
64
+ control_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
65
+
66
+ self.column_select_button = customtkinter.CTkButton(
67
+ master=control_frame,
68
+ text="Select variants",
69
+ command=self.open_column_selection_dialog,
70
+ )
71
+ self.column_select_button.pack(side="left", padx=5)
72
+
62
73
  # ========================================================
63
- # create tabview and populate with frames
64
- tabview = customtkinter.CTkTabview(self.root)
65
- self.tree = self.create_tree_view(tabview.add("Configuration"))
74
+ # create main content frame
75
+ main_frame = customtkinter.CTkFrame(self.root)
76
+ self.tree = self.create_tree_view(main_frame)
66
77
  self.tree["columns"] = tuple(variant.name for variant in self.variants)
78
+ self.tree["displaycolumns"] = self.visible_columns
67
79
  self.tree.heading("#0", text="Configuration")
80
+ self.header_texts: dict[str, str] = {}
68
81
  for variant in self.variants:
69
82
  self.tree.heading(variant.name, text=variant.name)
83
+ self.header_texts[variant.name] = variant.name
70
84
  # Keep track of the mapping between the tree view items and the config elements
71
85
  self.tree_view_items_mapping = self.populate_tree_view()
72
- self.tree.pack(fill="both", expand=True)
86
+ self.adjust_column_width()
87
+ self.selected_column_id: str | None = None
88
+ self.tree.bind("<Button-1>", self.on_tree_click)
73
89
  # TODO: make the tree view editable
74
90
  # self.tree.bind("<Double-1>", self.double_click_handler)
75
91
 
76
92
  # ========================================================
77
93
  # put all together
78
94
  self.root.grid_columnconfigure(0, weight=1)
79
- self.root.grid_rowconfigure(0, weight=1)
80
- tabview.grid(row=0, column=0, sticky="nsew")
95
+ self.root.grid_rowconfigure(0, weight=0)
96
+ self.root.grid_rowconfigure(1, weight=1)
97
+ main_frame.grid(row=1, column=0, sticky="nsew", padx=10, pady=(0, 10))
81
98
 
82
99
  def mainloop(self) -> None:
83
100
  self.root.mainloop()
84
101
 
85
102
  def create_tree_view(self, frame: customtkinter.CTkFrame) -> ttk.Treeview:
86
- frame.grid_rowconfigure(0, weight=10)
87
- frame.grid_rowconfigure(1, weight=1)
103
+ frame.grid_rowconfigure(0, weight=1)
88
104
  frame.grid_columnconfigure(0, weight=1)
89
105
 
90
106
  columns = [var.name for var in self.variants]
91
107
 
92
108
  style = ttk.Style()
109
+ # From: https://stackoverflow.com/a/56684731
110
+ # This gives the selection a transparent look
111
+ style.map(
112
+ "mystyle.Treeview",
113
+ background=[("selected", "#a6d5f7")],
114
+ foreground=[("selected", "black")],
115
+ )
93
116
  style.configure(
94
117
  "mystyle.Treeview",
95
118
  highlightthickness=0,
@@ -97,9 +120,26 @@ class MainView(CTkView):
97
120
  font=("Calibri", 14),
98
121
  rowheight=30,
99
122
  ) # Modify the font of the body
100
- style.configure(
101
- "mystyle.Treeview.Heading", font=("Calibri", 14, "bold")
102
- ) # Modify the font of the headings
123
+ style.configure("mystyle.Treeview.Heading", font=("Calibri", 14, "bold")) # Modify the font of the headings
124
+
125
+ # Add a separator to the right of the heading
126
+ MainView.vline_img = tkinter.PhotoImage("vline", data="R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=")
127
+ style.element_create("vline", "image", "vline")
128
+ style.layout(
129
+ "mystyle.Treeview.Heading",
130
+ [
131
+ (
132
+ "mystyle.Treeview.heading.cell",
133
+ {
134
+ "sticky": "nswe",
135
+ "children": [
136
+ ("mystyle.Treeview.heading.text", {"sticky": "we"}),
137
+ ("vline", {"side": "right", "sticky": "ns"}),
138
+ ],
139
+ },
140
+ )
141
+ ],
142
+ )
103
143
 
104
144
  # create a Treeview widget
105
145
  config_treeview = ttk.Treeview(
@@ -108,17 +148,25 @@ class MainView(CTkView):
108
148
  show="tree headings",
109
149
  style="mystyle.Treeview",
110
150
  )
111
- config_treeview.grid(row=0, column=0, sticky="nsew")
151
+
152
+ scrollbar_y = ttk.Scrollbar(frame, command=config_treeview.yview)
153
+ scrollbar_x = ttk.Scrollbar(frame, command=config_treeview.xview, orient=tkinter.HORIZONTAL)
154
+ config_treeview.config(xscrollcommand=scrollbar_x.set, yscrollcommand=scrollbar_y.set)
155
+ scrollbar_y.pack(fill=tkinter.Y, side=tkinter.RIGHT)
156
+ scrollbar_x.pack(fill=tkinter.X, side=tkinter.BOTTOM)
157
+ config_treeview.pack(fill=tkinter.BOTH, expand=True)
158
+
112
159
  return config_treeview
113
160
 
114
- def populate_tree_view(self) -> Dict[str, str]:
161
+ def populate_tree_view(self) -> dict[str, str]:
115
162
  """
116
163
  Populates the tree view with the configuration elements.
164
+
117
165
  :return: a mapping between the tree view items and the configuration elements
118
166
  """
119
167
  stack = [] # To keep track of the parent items
120
168
  last_level = -1
121
- mapping: Dict[str, str] = {}
169
+ mapping: dict[str, str] = {}
122
170
 
123
171
  for element in self.elements:
124
172
  values = self.collect_values_for_element(element)
@@ -128,45 +176,28 @@ class MainView(CTkView):
128
176
  stack = [item_id] # Reset the stack with the root item
129
177
  elif element.level > last_level:
130
178
  # Insert as a child of the last inserted item
131
- item_id = self.tree.insert(
132
- stack[-1], "end", text=element.name, values=values
133
- )
179
+ item_id = self.tree.insert(stack[-1], "end", text=element.name, values=values)
134
180
  stack.append(item_id)
135
181
  elif element.level == last_level:
136
182
  # Insert at the same level as the last item
137
- item_id = self.tree.insert(
138
- stack[-2], "end", text=element.name, values=values
139
- )
183
+ item_id = self.tree.insert(stack[-2], "end", text=element.name, values=values)
140
184
  stack[-1] = item_id # Replace the top item in the stack
141
185
  else:
142
186
  # Go up in the hierarchy and insert at the appropriate level
143
- item_id = self.tree.insert(
144
- stack[element.level - 1], "end", text=element.name, values=values
145
- )
146
- stack = stack[: element.level] + [item_id]
187
+ item_id = self.tree.insert(stack[element.level - 1], "end", text=element.name, values=values)
188
+ stack = [*stack[: element.level], item_id]
147
189
 
148
190
  last_level = element.level
149
191
  mapping[item_id] = element.name
150
192
  return mapping
151
193
 
152
- def collect_values_for_element(
153
- self, element: EditableConfigElement
154
- ) -> List[int | str]:
155
- return (
156
- [
157
- self.prepare_value_to_be_displayed(
158
- element.type, variant.config_dict.get(element.name, None)
159
- )
160
- for variant in self.variants
161
- ]
162
- if not element.is_menu
163
- else []
164
- )
194
+ def collect_values_for_element(self, element: EditableConfigElement) -> list[int | str]:
195
+ return [self.prepare_value_to_be_displayed(element.type, variant.config_dict.get(element.name, None)) for variant in self.variants] if not element.is_menu else []
165
196
 
166
- def prepare_value_to_be_displayed(
167
- self, element_type: ConfigElementType, value: Any
168
- ) -> str:
197
+ def prepare_value_to_be_displayed(self, element_type: ConfigElementType, value: Any) -> str:
169
198
  """
199
+ Prepare the value to be displayed in the tree view based on the element type.
200
+
170
201
  UNKNOWN - N/A
171
202
  BOOL - ✅ ⛔
172
203
  TRISTATE - str
@@ -182,7 +213,62 @@ class MainView(CTkView):
182
213
  else:
183
214
  return str(value)
184
215
 
185
- def double_click_handler(self, event: tkinter.Event) -> None: # type: ignore
216
+ def adjust_column_width(self) -> None:
217
+ """Adjust the column widths to fit the header text, preserving manual resizing."""
218
+ heading_font = font.Font(font=("Calibri", 14, "bold"))
219
+ padding = 60
220
+ for col in self.tree["columns"]:
221
+ text = self.tree.heading(col, "text")
222
+ min_width = heading_font.measure(text) + padding
223
+ # Get current width to preserve manual resizing
224
+ current_width = self.tree.column(col, "width")
225
+ # Use the larger of current width or minimum required width
226
+ final_width = max(current_width, min_width)
227
+ self.tree.column(col, minwidth=min_width, width=final_width, stretch=False)
228
+ # First column (#0)
229
+ text = self.tree.heading("#0", "text")
230
+ min_width = heading_font.measure(text) + padding
231
+ current_width = self.tree.column("#0", "width")
232
+ final_width = max(current_width, min_width)
233
+ self.tree.column("#0", minwidth=min_width, width=final_width, stretch=False)
234
+
235
+ def on_tree_click(self, event: Any) -> None:
236
+ """Handle click events on the treeview to highlight the column header."""
237
+ column_id_str = self.tree.identify_column(event.x)
238
+ if not column_id_str or column_id_str == "#0":
239
+ # Click was on the tree part or outside columns, so reset if anything was selected
240
+ if self.selected_column_id:
241
+ original_text = self.header_texts.get(self.selected_column_id)
242
+ if original_text:
243
+ self.tree.heading(self.selected_column_id, text=original_text)
244
+ self.selected_column_id = None
245
+ return
246
+
247
+ col_idx = int(column_id_str.replace("#", "")) - 1
248
+ if col_idx < 0:
249
+ return
250
+ # Use displaycolumns instead of columns to account for hidden columns
251
+ visible_columns = self.tree["displaycolumns"]
252
+ if col_idx >= len(visible_columns):
253
+ return
254
+ column_name = visible_columns[col_idx]
255
+
256
+ if column_name == self.selected_column_id:
257
+ return
258
+
259
+ # Reset the previously selected column header
260
+ if self.selected_column_id:
261
+ original_text = self.header_texts.get(self.selected_column_id)
262
+ if original_text:
263
+ self.tree.heading(self.selected_column_id, text=original_text)
264
+
265
+ # Highlight the new column header
266
+ original_text = self.header_texts.get(column_name)
267
+ if original_text:
268
+ self.tree.heading(column_name, text=f"✅{original_text}")
269
+ self.selected_column_id = column_name
270
+
271
+ def double_click_handler(self, event: Any) -> None:
186
272
  current_selection = self.tree.selection()
187
273
  if not current_selection:
188
274
  return
@@ -191,9 +277,7 @@ class MainView(CTkView):
191
277
  selected_element_name = self.tree_view_items_mapping[selected_item]
192
278
 
193
279
  variant_idx_str = self.tree.identify_column(event.x) # Get the clicked column
194
- variant_idx = (
195
- int(variant_idx_str.split("#")[-1]) - 1
196
- ) # Convert to 0-based index
280
+ variant_idx = int(variant_idx_str.split("#")[-1]) - 1 # Convert to 0-based index
197
281
 
198
282
  if variant_idx < 0 or variant_idx >= len(self.variants):
199
283
  return
@@ -207,9 +291,7 @@ class MainView(CTkView):
207
291
  new_value: Any = None
208
292
  if selected_element.type == ConfigElementType.BOOL:
209
293
  # Toggle the boolean value
210
- new_value = (
211
- TriState.N if selected_element_value == TriState.Y else TriState.Y
212
- )
294
+ new_value = TriState.N if selected_element_value == TriState.Y else TriState.Y
213
295
  elif selected_element.type == ConfigElementType.INT:
214
296
  tmp_int_value = simpledialog.askinteger(
215
297
  "Enter new value",
@@ -231,21 +313,76 @@ class MainView(CTkView):
231
313
  # Check if the value has changed
232
314
  if new_value:
233
315
  # Trigger the EDIT event
234
- self.create_edit_event_trigger(
235
- selected_variant, selected_element_name, new_value
236
- )
316
+ self.create_edit_event_trigger(selected_variant, selected_element_name, new_value)
237
317
 
238
- def create_edit_event_trigger(
239
- self, variant: VariantViewData, element_name: str, new_value: Any
240
- ) -> None:
318
+ def create_edit_event_trigger(self, variant: VariantViewData, element_name: str, new_value: Any) -> None:
241
319
  self.edit_event_data = EditEventData(variant, element_name, new_value)
242
320
  self.trigger_edit_event()
243
321
 
244
- def pop_edit_event_data(self) -> Optional[EditEventData]:
322
+ def pop_edit_event_data(self) -> EditEventData | None:
245
323
  result = self.edit_event_data
246
324
  self.edit_event_data = None
247
325
  return result
248
326
 
327
+ def update_visible_columns(self) -> None:
328
+ """Update the visible columns based on the state of the checkboxes."""
329
+ self.visible_columns = [col_name for col_name, var in self.column_vars.items() if var.get()]
330
+ self.tree["displaycolumns"] = self.visible_columns
331
+ self.adjust_column_width()
332
+
333
+ def open_column_selection_dialog(self) -> None:
334
+ """Open a dialog to select which columns to display."""
335
+ # Create a new top-level window
336
+ dialog = customtkinter.CTkToplevel(self.root)
337
+ dialog.title("Select variants")
338
+ dialog.geometry("400x300")
339
+
340
+ # Create a frame for the checkboxes
341
+ checkbox_frame = customtkinter.CTkFrame(dialog)
342
+ checkbox_frame.pack(padx=10, pady=10, fill="both", expand=True)
343
+
344
+ # Create a variable for each column
345
+ self.column_vars = {}
346
+ for column_name in self.all_columns:
347
+ # Set the initial value based on whether the column is currently visible
348
+ is_visible = column_name in self.visible_columns
349
+ var = tkinter.BooleanVar(value=is_visible)
350
+ checkbox = customtkinter.CTkCheckBox(
351
+ master=checkbox_frame,
352
+ text=column_name,
353
+ command=self.update_visible_columns,
354
+ variable=var,
355
+ )
356
+ checkbox.pack(anchor="w", padx=5, pady=2)
357
+ self.column_vars[column_name] = var
358
+
359
+ # Add OK and Cancel buttons
360
+ button_frame = customtkinter.CTkFrame(dialog)
361
+ button_frame.pack(padx=10, pady=10)
362
+
363
+ ok_button = customtkinter.CTkButton(
364
+ master=button_frame,
365
+ text="OK",
366
+ command=dialog.destroy,
367
+ )
368
+ ok_button.pack(side="right", padx=5)
369
+
370
+ cancel_button = customtkinter.CTkButton(
371
+ master=button_frame,
372
+ text="Cancel",
373
+ command=dialog.destroy,
374
+ )
375
+ cancel_button.pack(side="right", padx=5)
376
+
377
+ # Center the dialog on the screen
378
+ dialog.update_idletasks()
379
+ x = (self.root.winfo_width() - dialog.winfo_width()) // 2
380
+ y = (self.root.winfo_height() - dialog.winfo_height()) // 2
381
+ dialog.geometry(f"+{self.root.winfo_x() + x}+{self.root.winfo_y() + y}")
382
+
383
+ dialog.transient(self.root) # Keep the dialog above the main window
384
+ dialog.grab_set() # Make the dialog modal
385
+
249
386
 
250
387
  class KSPL(Presenter):
251
388
  def __init__(self, event_manager: EventManager, project_dir: Path) -> None:
@@ -264,23 +401,14 @@ class KSPL(Presenter):
264
401
  if edit_event_data is None:
265
402
  self.logger.error("Edit event received but event data is missing!")
266
403
  else:
267
- self.logger.debug(
268
- "Edit event received: "
269
- f"'{edit_event_data.variant.name}:{edit_event_data.config_element_name} = {edit_event_data.new_value}'"
270
- )
404
+ self.logger.debug(f"Edit event received: '{edit_event_data.variant.name}:{edit_event_data.config_element_name} = {edit_event_data.new_value}'")
271
405
  # Update the variant configuration data with the new value
272
- variant = self.kconfig_data.find_variant_config(
273
- edit_event_data.variant.name
274
- )
406
+ variant = self.kconfig_data.find_variant_config(edit_event_data.variant.name)
275
407
  if variant is None:
276
- raise ValueError(
277
- f"Could not find variant '{edit_event_data.variant.name}'"
278
- )
408
+ raise ValueError(f"Could not find variant '{edit_event_data.variant.name}'")
279
409
  config_element = variant.find_element(edit_event_data.config_element_name)
280
410
  if config_element is None:
281
- raise ValueError(
282
- f"Could not find config element '{edit_event_data.config_element_name}'"
283
- )
411
+ raise ValueError(f"Could not find config element '{edit_event_data.config_element_name}'")
284
412
  config_element.value = edit_event_data.new_value
285
413
 
286
414
  def run(self) -> None:
@@ -291,10 +419,7 @@ class KSPL(Presenter):
291
419
  class GuiCommandConfig(DataClassDictMixin):
292
420
  project_dir: Path = field(
293
421
  default=Path(".").absolute(),
294
- metadata={
295
- "help": "Project root directory. "
296
- "Defaults to the current directory if not specified."
297
- },
422
+ metadata={"help": "Project root directory. Defaults to the current directory if not specified."},
298
423
  )
299
424
 
300
425
  @classmethod