ctk_segmented_button.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import tkinter
  2. import copy
  3. from typing import Union, Tuple, List, Dict, Callable, Optional, Any
  4. try:
  5. from typing import Literal
  6. except ImportError:
  7. from typing_extensions import Literal
  8. from .theme import ThemeManager
  9. from .font import CTkFont
  10. from .ctk_button import CTkButton
  11. from .ctk_frame import CTkFrame
  12. from .utility import check_kwargs_empty
  13. class CTkSegmentedButton(CTkFrame):
  14. """
  15. Segmented button with corner radius, border width, variable support.
  16. For detailed information check out the documentation.
  17. """
  18. def __init__(self,
  19. master: Any,
  20. width: int = 140,
  21. height: int = 28,
  22. corner_radius: Optional[int] = None,
  23. border_width: int = 3,
  24. bg_color: Union[str, Tuple[str, str]] = "transparent",
  25. fg_color: Optional[Union[str, Tuple[str, str]]] = None,
  26. selected_color: Optional[Union[str, Tuple[str, str]]] = None,
  27. selected_hover_color: Optional[Union[str, Tuple[str, str]]] = None,
  28. unselected_color: Optional[Union[str, Tuple[str, str]]] = None,
  29. unselected_hover_color: Optional[Union[str, Tuple[str, str]]] = None,
  30. text_color: Optional[Union[str, Tuple[str, str]]] = None,
  31. text_color_disabled: Optional[Union[str, Tuple[str, str]]] = None,
  32. background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None,
  33. font: Optional[Union[tuple, CTkFont]] = None,
  34. values: Optional[list] = None,
  35. variable: Union[tkinter.Variable, None] = None,
  36. dynamic_resizing: bool = True,
  37. command: Union[Callable[[str], Any], None] = None,
  38. state: str = "normal"):
  39. super().__init__(master=master, bg_color=bg_color, width=width, height=height)
  40. self._sb_fg_color = ThemeManager.theme["CTkSegmentedButton"]["fg_color"] if fg_color is None else self._check_color_type(fg_color)
  41. self._sb_selected_color = ThemeManager.theme["CTkSegmentedButton"]["selected_color"] if selected_color is None else self._check_color_type(selected_color)
  42. self._sb_selected_hover_color = ThemeManager.theme["CTkSegmentedButton"]["selected_hover_color"] if selected_hover_color is None else self._check_color_type(selected_hover_color)
  43. self._sb_unselected_color = ThemeManager.theme["CTkSegmentedButton"]["unselected_color"] if unselected_color is None else self._check_color_type(unselected_color)
  44. self._sb_unselected_hover_color = ThemeManager.theme["CTkSegmentedButton"]["unselected_hover_color"] if unselected_hover_color is None else self._check_color_type(unselected_hover_color)
  45. self._sb_text_color = ThemeManager.theme["CTkSegmentedButton"]["text_color"] if text_color is None else self._check_color_type(text_color)
  46. self._sb_text_color_disabled = ThemeManager.theme["CTkSegmentedButton"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled)
  47. self._sb_corner_radius = ThemeManager.theme["CTkSegmentedButton"]["corner_radius"] if corner_radius is None else corner_radius
  48. self._sb_border_width = ThemeManager.theme["CTkSegmentedButton"]["border_width"] if border_width is None else border_width
  49. self._background_corner_colors = background_corner_colors # rendering options for DrawEngine
  50. self._command: Callable[[str], None] = command
  51. self._font = CTkFont() if font is None else font
  52. self._state = state
  53. self._buttons_dict: Dict[str, CTkButton] = {} # mapped from value to button object
  54. if values is None:
  55. self._value_list: List[str] = ["CTkSegmentedButton"]
  56. else:
  57. self._value_list: List[str] = values # Values ordered like buttons rendered on widget
  58. self._dynamic_resizing = dynamic_resizing
  59. if not self._dynamic_resizing:
  60. self.grid_propagate(False)
  61. self._check_unique_values(self._value_list)
  62. self._current_value: str = ""
  63. if len(self._value_list) > 0:
  64. self._create_buttons_from_values()
  65. self._create_button_grid()
  66. self._variable = variable
  67. self._variable_callback_blocked: bool = False
  68. self._variable_callback_name: Union[str, None] = None
  69. if self._variable is not None:
  70. self._variable_callback_name = self._variable.trace_add("write", self._variable_callback)
  71. self.set(self._variable.get(), from_variable_callback=True)
  72. super().configure(corner_radius=self._sb_corner_radius, fg_color="transparent")
  73. def destroy(self):
  74. if self._variable is not None: # remove old callback
  75. self._variable.trace_remove("write", self._variable_callback_name)
  76. super().destroy()
  77. def _set_dimensions(self, width: int = None, height: int = None):
  78. super()._set_dimensions(width, height)
  79. for button in self._buttons_dict.values():
  80. button.configure(height=height)
  81. def _variable_callback(self, var_name, index, mode):
  82. if not self._variable_callback_blocked:
  83. self.set(self._variable.get(), from_variable_callback=True)
  84. def _get_index_by_value(self, value: str):
  85. for index, value_from_list in enumerate(self._value_list):
  86. if value_from_list == value:
  87. return index
  88. raise ValueError(f"CTkSegmentedButton does not contain value '{value}'")
  89. def _configure_button_corners_for_index(self, index: int):
  90. if index == 0 and len(self._value_list) == 1:
  91. if self._background_corner_colors is None:
  92. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._bg_color, self._bg_color, self._bg_color, self._bg_color))
  93. else:
  94. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=self._background_corner_colors)
  95. elif index == 0:
  96. if self._background_corner_colors is None:
  97. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._bg_color, self._sb_fg_color, self._sb_fg_color, self._bg_color))
  98. else:
  99. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._background_corner_colors[0], self._sb_fg_color, self._sb_fg_color, self._background_corner_colors[3]))
  100. elif index == len(self._value_list) - 1:
  101. if self._background_corner_colors is None:
  102. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._bg_color, self._bg_color, self._sb_fg_color))
  103. else:
  104. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._background_corner_colors[1], self._background_corner_colors[2], self._sb_fg_color))
  105. else:
  106. self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._sb_fg_color, self._sb_fg_color, self._sb_fg_color))
  107. def _unselect_button_by_value(self, value: str):
  108. if value in self._buttons_dict:
  109. self._buttons_dict[value].configure(fg_color=self._sb_unselected_color,
  110. hover_color=self._sb_unselected_hover_color)
  111. def _select_button_by_value(self, value: str):
  112. if self._current_value is not None and self._current_value != "":
  113. self._unselect_button_by_value(self._current_value)
  114. self._current_value = value
  115. self._buttons_dict[value].configure(fg_color=self._sb_selected_color,
  116. hover_color=self._sb_selected_hover_color)
  117. def _create_button(self, index: int, value: str) -> CTkButton:
  118. new_button = CTkButton(self,
  119. width=0,
  120. height=self._current_height,
  121. corner_radius=self._sb_corner_radius,
  122. border_width=self._sb_border_width,
  123. fg_color=self._sb_unselected_color,
  124. border_color=self._sb_fg_color,
  125. hover_color=self._sb_unselected_hover_color,
  126. text_color=self._sb_text_color,
  127. text_color_disabled=self._sb_text_color_disabled,
  128. text=value,
  129. font=self._font,
  130. state=self._state,
  131. command=lambda v=value: self.set(v, from_button_callback=True),
  132. background_corner_colors=None,
  133. round_width_to_even_numbers=False,
  134. round_height_to_even_numbers=False) # DrawEngine rendering option (so that theres no gap between buttons)
  135. return new_button
  136. @staticmethod
  137. def _check_unique_values(values: List[str]):
  138. """ raises exception if values are not unique """
  139. if len(values) != len(set(values)):
  140. raise ValueError("CTkSegmentedButton values are not unique")
  141. def _create_button_grid(self):
  142. # remove minsize from every grid cell in the first row
  143. number_of_columns, _ = self.grid_size()
  144. for n in range(number_of_columns):
  145. self.grid_columnconfigure(n, weight=1, minsize=0)
  146. self.grid_rowconfigure(0, weight=1)
  147. for index, value in enumerate(self._value_list):
  148. self.grid_columnconfigure(index, weight=1, minsize=self._current_height)
  149. self._buttons_dict[value].grid(row=0, column=index, sticky="nsew")
  150. def _create_buttons_from_values(self):
  151. assert len(self._buttons_dict) == 0
  152. assert len(self._value_list) > 0
  153. for index, value in enumerate(self._value_list):
  154. self._buttons_dict[value] = self._create_button(index, value)
  155. self._configure_button_corners_for_index(index)
  156. def configure(self, **kwargs):
  157. if "width" in kwargs:
  158. super().configure(width=kwargs.pop("width"))
  159. if "height" in kwargs:
  160. super().configure(height=kwargs.pop("height"))
  161. if "corner_radius" in kwargs:
  162. self._sb_corner_radius = kwargs.pop("corner_radius")
  163. super().configure(corner_radius=self._sb_corner_radius)
  164. for button in self._buttons_dict.values():
  165. button.configure(corner_radius=self._sb_corner_radius)
  166. if "border_width" in kwargs:
  167. self._sb_border_width = kwargs.pop("border_width")
  168. for button in self._buttons_dict.values():
  169. button.configure(border_width=self._sb_border_width)
  170. if "bg_color" in kwargs:
  171. super().configure(bg_color=kwargs.pop("bg_color"))
  172. if len(self._buttons_dict) > 0:
  173. self._configure_button_corners_for_index(0)
  174. if len(self._buttons_dict) > 1:
  175. max_index = len(self._buttons_dict) - 1
  176. self._configure_button_corners_for_index(max_index)
  177. if "fg_color" in kwargs:
  178. self._sb_fg_color = self._check_color_type(kwargs.pop("fg_color"))
  179. for index, button in enumerate(self._buttons_dict.values()):
  180. button.configure(border_color=self._sb_fg_color)
  181. self._configure_button_corners_for_index(index)
  182. if "selected_color" in kwargs:
  183. self._sb_selected_color = self._check_color_type(kwargs.pop("selected_color"))
  184. if self._current_value in self._buttons_dict:
  185. self._buttons_dict[self._current_value].configure(fg_color=self._sb_selected_color)
  186. if "selected_hover_color" in kwargs:
  187. self._sb_selected_hover_color = self._check_color_type(kwargs.pop("selected_hover_color"))
  188. if self._current_value in self._buttons_dict:
  189. self._buttons_dict[self._current_value].configure(hover_color=self._sb_selected_hover_color)
  190. if "unselected_color" in kwargs:
  191. self._sb_unselected_color = self._check_color_type(kwargs.pop("unselected_color"))
  192. for value, button in self._buttons_dict.items():
  193. if value != self._current_value:
  194. button.configure(fg_color=self._sb_unselected_color)
  195. if "unselected_hover_color" in kwargs:
  196. self._sb_unselected_hover_color = self._check_color_type(kwargs.pop("unselected_hover_color"))
  197. for value, button in self._buttons_dict.items():
  198. if value != self._current_value:
  199. button.configure(hover_color=self._sb_unselected_hover_color)
  200. if "text_color" in kwargs:
  201. self._sb_text_color = self._check_color_type(kwargs.pop("text_color"))
  202. for button in self._buttons_dict.values():
  203. button.configure(text_color=self._sb_text_color)
  204. if "text_color_disabled" in kwargs:
  205. self._sb_text_color_disabled = self._check_color_type(kwargs.pop("text_color_disabled"))
  206. for button in self._buttons_dict.values():
  207. button.configure(text_color_disabled=self._sb_text_color_disabled)
  208. if "background_corner_colors" in kwargs:
  209. self._background_corner_colors = kwargs.pop("background_corner_colors")
  210. for i in range(len(self._buttons_dict)):
  211. self._configure_button_corners_for_index(i)
  212. if "font" in kwargs:
  213. self._font = kwargs.pop("font")
  214. for button in self._buttons_dict.values():
  215. button.configure(font=self._font)
  216. if "values" in kwargs:
  217. for button in self._buttons_dict.values():
  218. button.destroy()
  219. self._buttons_dict.clear()
  220. self._value_list = kwargs.pop("values")
  221. self._check_unique_values(self._value_list)
  222. if len(self._value_list) > 0:
  223. self._create_buttons_from_values()
  224. self._create_button_grid()
  225. if self._current_value in self._value_list:
  226. self._select_button_by_value(self._current_value)
  227. if "variable" in kwargs:
  228. if self._variable is not None: # remove old callback
  229. self._variable.trace_remove("write", self._variable_callback_name)
  230. self._variable = kwargs.pop("variable")
  231. if self._variable is not None and self._variable != "":
  232. self._variable_callback_name = self._variable.trace_add("write", self._variable_callback)
  233. self.set(self._variable.get(), from_variable_callback=True)
  234. else:
  235. self._variable = None
  236. if "dynamic_resizing" in kwargs:
  237. self._dynamic_resizing = kwargs.pop("dynamic_resizing")
  238. if not self._dynamic_resizing:
  239. self.grid_propagate(False)
  240. else:
  241. self.grid_propagate(True)
  242. if "command" in kwargs:
  243. self._command = kwargs.pop("command")
  244. if "state" in kwargs:
  245. self._state = kwargs.pop("state")
  246. for button in self._buttons_dict.values():
  247. button.configure(state=self._state)
  248. check_kwargs_empty(kwargs, raise_error=True)
  249. def cget(self, attribute_name: str) -> any:
  250. if attribute_name == "width":
  251. return super().cget(attribute_name)
  252. elif attribute_name == "height":
  253. return super().cget(attribute_name)
  254. elif attribute_name == "corner_radius":
  255. return self._sb_corner_radius
  256. elif attribute_name == "border_width":
  257. return self._sb_border_width
  258. elif attribute_name == "bg_color":
  259. return super().cget(attribute_name)
  260. elif attribute_name == "fg_color":
  261. return self._sb_fg_color
  262. elif attribute_name == "selected_color":
  263. return self._sb_selected_color
  264. elif attribute_name == "selected_hover_color":
  265. return self._sb_selected_hover_color
  266. elif attribute_name == "unselected_color":
  267. return self._sb_unselected_color
  268. elif attribute_name == "unselected_hover_color":
  269. return self._sb_unselected_hover_color
  270. elif attribute_name == "text_color":
  271. return self._sb_text_color
  272. elif attribute_name == "text_color_disabled":
  273. return self._sb_text_color_disabled
  274. elif attribute_name == "font":
  275. return self._font
  276. elif attribute_name == "values":
  277. return copy.copy(self._value_list)
  278. elif attribute_name == "variable":
  279. return self._variable
  280. elif attribute_name == "dynamic_resizing":
  281. return self._dynamic_resizing
  282. elif attribute_name == "command":
  283. return self._command
  284. else:
  285. raise ValueError(f"'{attribute_name}' is not a supported argument. Look at the documentation for supported arguments.")
  286. def set(self, value: str, from_variable_callback: bool = False, from_button_callback: bool = False):
  287. if value == self._current_value:
  288. return
  289. elif value in self._buttons_dict:
  290. self._select_button_by_value(value)
  291. if self._variable is not None and not from_variable_callback:
  292. self._variable_callback_blocked = True
  293. self._variable.set(value)
  294. self._variable_callback_blocked = False
  295. else:
  296. if self._current_value in self._buttons_dict:
  297. self._unselect_button_by_value(self._current_value)
  298. self._current_value = value
  299. if self._variable is not None and not from_variable_callback:
  300. self._variable_callback_blocked = True
  301. self._variable.set(value)
  302. self._variable_callback_blocked = False
  303. if from_button_callback:
  304. if self._command is not None:
  305. self._command(self._current_value)
  306. def get(self) -> str:
  307. return self._current_value
  308. def index(self, value: str) -> int:
  309. return self._value_list.index(value)
  310. def insert(self, index: int, value: str):
  311. if value not in self._buttons_dict:
  312. if value != "":
  313. self._value_list.insert(index, value)
  314. self._buttons_dict[value] = self._create_button(index, value)
  315. self._configure_button_corners_for_index(index)
  316. if index > 0:
  317. self._configure_button_corners_for_index(index - 1)
  318. if index < len(self._buttons_dict) - 1:
  319. self._configure_button_corners_for_index(index + 1)
  320. self._create_button_grid()
  321. if value == self._current_value:
  322. self._select_button_by_value(self._current_value)
  323. else:
  324. raise ValueError(f"CTkSegmentedButton can not insert value ''")
  325. else:
  326. raise ValueError(f"CTkSegmentedButton can not insert value '{value}', already part of the values")
  327. def move(self, new_index: int, value: str):
  328. if 0 <= new_index < len(self._value_list):
  329. if value in self._buttons_dict:
  330. self.delete(value)
  331. self.insert(new_index, value)
  332. else:
  333. raise ValueError(f"CTkSegmentedButton has no value named '{value}'")
  334. else:
  335. raise ValueError(f"CTkSegmentedButton new_index {new_index} not in range of value list with len {len(self._value_list)}")
  336. def delete(self, value: str):
  337. if value in self._buttons_dict:
  338. self._buttons_dict[value].destroy()
  339. self._buttons_dict.pop(value)
  340. index_to_remove = self._get_index_by_value(value)
  341. self._value_list.pop(index_to_remove)
  342. # removed index was outer right element
  343. if index_to_remove == len(self._buttons_dict) and len(self._buttons_dict) > 0:
  344. self._configure_button_corners_for_index(index_to_remove - 1)
  345. # removed index was outer left element
  346. if index_to_remove == 0 and len(self._buttons_dict) > 0:
  347. self._configure_button_corners_for_index(0)
  348. #if index_to_remove <= len(self._buttons_dict) - 1:
  349. # self._configure_button_corners_for_index(index_to_remove)
  350. self._create_button_grid()
  351. else:
  352. raise ValueError(f"CTkSegmentedButton does not contain value '{value}'")
  353. def bind(self, sequence=None, command=None, add=None):
  354. raise NotImplementedError
  355. def unbind(self, sequence=None, funcid=None):
  356. raise NotImplementedError