ctk_textbox.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import tkinter
  2. from typing import Union, Tuple, Optional, Callable, Any
  3. from .core_rendering import CTkCanvas
  4. from .ctk_scrollbar import CTkScrollbar
  5. from .theme import ThemeManager
  6. from .core_rendering import DrawEngine
  7. from .core_widget_classes import CTkBaseClass
  8. from .font import CTkFont
  9. from .utility import pop_from_dict_by_set, check_kwargs_empty
  10. class CTkTextbox(CTkBaseClass):
  11. """
  12. Textbox with x and y scrollbars, rounded corners, and all text features of tkinter.Text widget.
  13. Scrollbars only appear when they are needed. Text is wrapped on line end by default,
  14. set wrap='none' to disable automatic line wrapping.
  15. For detailed information check out the documentation.
  16. Detailed methods and parameters of the underlaying tkinter.Text widget can be found here:
  17. https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/text.html
  18. (most of them are implemented here too)
  19. """
  20. _scrollbar_update_time = 200 # interval in ms, to check if scrollbars are needed
  21. # attributes that are passed to and managed by the tkinter textbox only:
  22. _valid_tk_text_attributes = {"autoseparators", "cursor", "exportselection",
  23. "insertborderwidth", "insertofftime", "insertontime", "insertwidth",
  24. "maxundo", "padx", "pady", "selectborderwidth", "spacing1",
  25. "spacing2", "spacing3", "state", "tabs", "takefocus", "undo", "wrap",
  26. "xscrollcommand", "yscrollcommand"}
  27. def __init__(self,
  28. master: any,
  29. width: int = 200,
  30. height: int = 200,
  31. corner_radius: Optional[int] = None,
  32. border_width: Optional[int] = None,
  33. border_spacing: int = 3,
  34. bg_color: Union[str, Tuple[str, str]] = "transparent",
  35. fg_color: Optional[Union[str, Tuple[str, str]]] = None,
  36. border_color: Optional[Union[str, Tuple[str, str]]] = None,
  37. text_color: Optional[Union[str, str]] = None,
  38. scrollbar_button_color: Optional[Union[str, Tuple[str, str]]] = None,
  39. scrollbar_button_hover_color: Optional[Union[str, Tuple[str, str]]] = None,
  40. font: Optional[Union[tuple, CTkFont]] = None,
  41. activate_scrollbars: bool = True,
  42. **kwargs):
  43. # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass
  44. super().__init__(master=master, bg_color=bg_color, width=width, height=height)
  45. # color
  46. self._fg_color = ThemeManager.theme["CTkTextbox"]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True)
  47. self._border_color = ThemeManager.theme["CTkTextbox"]["border_color"] if border_color is None else self._check_color_type(border_color)
  48. self._text_color = ThemeManager.theme["CTkTextbox"]["text_color"] if text_color is None else self._check_color_type(text_color)
  49. self._scrollbar_button_color = ThemeManager.theme["CTkTextbox"]["scrollbar_button_color"] if scrollbar_button_color is None else self._check_color_type(scrollbar_button_color)
  50. self._scrollbar_button_hover_color = ThemeManager.theme["CTkTextbox"]["scrollbar_button_hover_color"] if scrollbar_button_hover_color is None else self._check_color_type(scrollbar_button_hover_color)
  51. # shape
  52. self._corner_radius = ThemeManager.theme["CTkTextbox"]["corner_radius"] if corner_radius is None else corner_radius
  53. self._border_width = ThemeManager.theme["CTkTextbox"]["border_width"] if border_width is None else border_width
  54. self._border_spacing = border_spacing
  55. # font
  56. self._font = CTkFont() if font is None else self._check_font_type(font)
  57. if isinstance(self._font, CTkFont):
  58. self._font.add_size_configure_callback(self._update_font)
  59. self._canvas = CTkCanvas(master=self,
  60. highlightthickness=0,
  61. width=self._apply_widget_scaling(self._desired_width),
  62. height=self._apply_widget_scaling(self._desired_height))
  63. self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew")
  64. self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color))
  65. self._draw_engine = DrawEngine(self._canvas)
  66. self._textbox = tkinter.Text(self,
  67. fg=self._apply_appearance_mode(self._text_color),
  68. width=0,
  69. height=0,
  70. font=self._apply_font_scaling(self._font),
  71. highlightthickness=0,
  72. relief="flat",
  73. insertbackground=self._apply_appearance_mode(self._text_color),
  74. **pop_from_dict_by_set(kwargs, self._valid_tk_text_attributes))
  75. check_kwargs_empty(kwargs, raise_error=True)
  76. # scrollbars
  77. self._scrollbars_activated = activate_scrollbars
  78. self._hide_x_scrollbar = True
  79. self._hide_y_scrollbar = True
  80. self._y_scrollbar = CTkScrollbar(self,
  81. width=8,
  82. height=0,
  83. border_spacing=0,
  84. fg_color=self._fg_color,
  85. button_color=self._scrollbar_button_color,
  86. button_hover_color=self._scrollbar_button_hover_color,
  87. orientation="vertical",
  88. command=self._textbox.yview)
  89. self._textbox.configure(yscrollcommand=self._y_scrollbar.set)
  90. self._x_scrollbar = CTkScrollbar(self,
  91. height=8,
  92. width=0,
  93. border_spacing=0,
  94. fg_color=self._fg_color,
  95. button_color=self._scrollbar_button_color,
  96. button_hover_color=self._scrollbar_button_hover_color,
  97. orientation="horizontal",
  98. command=self._textbox.xview)
  99. self._textbox.configure(xscrollcommand=self._x_scrollbar.set)
  100. self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True)
  101. self.after(50, self._check_if_scrollbars_needed, None, True)
  102. self._draw()
  103. def _create_grid_for_text_and_scrollbars(self, re_grid_textbox=False, re_grid_x_scrollbar=False, re_grid_y_scrollbar=False):
  104. # configure 2x2 grid
  105. self.grid_rowconfigure(0, weight=1)
  106. self.grid_rowconfigure(1, weight=0, minsize=self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)))
  107. self.grid_columnconfigure(0, weight=1)
  108. self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)))
  109. if re_grid_textbox:
  110. self._textbox.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew",
  111. padx=(self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)), 0),
  112. pady=(self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)), 0))
  113. if re_grid_x_scrollbar:
  114. if not self._hide_x_scrollbar and self._scrollbars_activated:
  115. self._x_scrollbar.grid(row=1, column=0, rowspan=1, columnspan=1, sticky="ewn",
  116. pady=(3, self._border_spacing + self._border_width),
  117. padx=(max(self._corner_radius, self._border_width + self._border_spacing), 0)) # scrollbar grid method without scaling
  118. else:
  119. self._x_scrollbar.grid_forget()
  120. if re_grid_y_scrollbar:
  121. if not self._hide_y_scrollbar and self._scrollbars_activated:
  122. self._y_scrollbar.grid(row=0, column=1, rowspan=1, columnspan=1, sticky="nsw",
  123. padx=(3, self._border_spacing + self._border_width),
  124. pady=(max(self._corner_radius, self._border_width + self._border_spacing), 0)) # scrollbar grid method without scaling
  125. else:
  126. self._y_scrollbar.grid_forget()
  127. def _check_if_scrollbars_needed(self, event=None, continue_loop: bool = False):
  128. """ Method hides or places the scrollbars if they are needed on key release event of tkinter.text widget """
  129. if self._scrollbars_activated:
  130. if self._textbox.xview() != (0.0, 1.0) and not self._x_scrollbar.winfo_ismapped(): # x scrollbar needed
  131. self._hide_x_scrollbar = False
  132. self._create_grid_for_text_and_scrollbars(re_grid_x_scrollbar=True)
  133. elif self._textbox.xview() == (0.0, 1.0) and self._x_scrollbar.winfo_ismapped(): # x scrollbar not needed
  134. self._hide_x_scrollbar = True
  135. self._create_grid_for_text_and_scrollbars(re_grid_x_scrollbar=True)
  136. if self._textbox.yview() != (0.0, 1.0) and not self._y_scrollbar.winfo_ismapped(): # y scrollbar needed
  137. self._hide_y_scrollbar = False
  138. self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True)
  139. elif self._textbox.yview() == (0.0, 1.0) and self._y_scrollbar.winfo_ismapped(): # y scrollbar not needed
  140. self._hide_y_scrollbar = True
  141. self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True)
  142. else:
  143. self._hide_x_scrollbar = False
  144. self._hide_x_scrollbar = False
  145. self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True)
  146. if self._textbox.winfo_exists() and continue_loop is True:
  147. self.after(self._scrollbar_update_time, lambda: self._check_if_scrollbars_needed(continue_loop=True))
  148. def _set_scaling(self, *args, **kwargs):
  149. super()._set_scaling(*args, **kwargs)
  150. self._textbox.configure(font=self._apply_font_scaling(self._font))
  151. self._canvas.configure(width=self._apply_widget_scaling(self._desired_width),
  152. height=self._apply_widget_scaling(self._desired_height))
  153. self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True)
  154. self._draw(no_color_updates=True)
  155. def _set_dimensions(self, width=None, height=None):
  156. super()._set_dimensions(width, height)
  157. self._canvas.configure(width=self._apply_widget_scaling(self._desired_width),
  158. height=self._apply_widget_scaling(self._desired_height))
  159. self._draw()
  160. def _update_font(self):
  161. """ pass font to tkinter widgets with applied font scaling and update grid with workaround """
  162. self._textbox.configure(font=self._apply_font_scaling(self._font))
  163. # Workaround to force grid to be resized when text changes size.
  164. # Otherwise grid will lag and only resizes if other mouse action occurs.
  165. self._canvas.grid_forget()
  166. self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew")
  167. def destroy(self):
  168. if isinstance(self._font, CTkFont):
  169. self._font.remove_size_configure_callback(self._update_font)
  170. super().destroy()
  171. def _draw(self, no_color_updates=False):
  172. super()._draw(no_color_updates)
  173. if not self._canvas.winfo_exists():
  174. return
  175. requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width),
  176. self._apply_widget_scaling(self._current_height),
  177. self._apply_widget_scaling(self._corner_radius),
  178. self._apply_widget_scaling(self._border_width))
  179. if no_color_updates is False or requires_recoloring:
  180. if self._fg_color == "transparent":
  181. self._canvas.itemconfig("inner_parts",
  182. fill=self._apply_appearance_mode(self._bg_color),
  183. outline=self._apply_appearance_mode(self._bg_color))
  184. self._textbox.configure(fg=self._apply_appearance_mode(self._text_color),
  185. bg=self._apply_appearance_mode(self._bg_color),
  186. insertbackground=self._apply_appearance_mode(self._text_color))
  187. self._x_scrollbar.configure(fg_color=self._bg_color, button_color=self._scrollbar_button_color,
  188. button_hover_color=self._scrollbar_button_hover_color)
  189. self._y_scrollbar.configure(fg_color=self._bg_color, button_color=self._scrollbar_button_color,
  190. button_hover_color=self._scrollbar_button_hover_color)
  191. else:
  192. self._canvas.itemconfig("inner_parts",
  193. fill=self._apply_appearance_mode(self._fg_color),
  194. outline=self._apply_appearance_mode(self._fg_color))
  195. self._textbox.configure(fg=self._apply_appearance_mode(self._text_color),
  196. bg=self._apply_appearance_mode(self._fg_color),
  197. insertbackground=self._apply_appearance_mode(self._text_color))
  198. self._x_scrollbar.configure(fg_color=self._fg_color, button_color=self._scrollbar_button_color,
  199. button_hover_color=self._scrollbar_button_hover_color)
  200. self._y_scrollbar.configure(fg_color=self._fg_color, button_color=self._scrollbar_button_color,
  201. button_hover_color=self._scrollbar_button_hover_color)
  202. self._canvas.itemconfig("border_parts",
  203. fill=self._apply_appearance_mode(self._border_color),
  204. outline=self._apply_appearance_mode(self._border_color))
  205. self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color))
  206. self._canvas.tag_lower("inner_parts")
  207. self._canvas.tag_lower("border_parts")
  208. def configure(self, require_redraw=False, **kwargs):
  209. if "fg_color" in kwargs:
  210. self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True)
  211. require_redraw = True
  212. # check if CTk widgets are children of the frame and change their _bg_color to new frame fg_color
  213. for child in self.winfo_children():
  214. if isinstance(child, CTkBaseClass) and hasattr(child, "_fg_color"):
  215. child.configure(bg_color=self._fg_color)
  216. if "border_color" in kwargs:
  217. self._border_color = self._check_color_type(kwargs.pop("border_color"))
  218. require_redraw = True
  219. if "text_color" in kwargs:
  220. self._text_color = self._check_color_type(kwargs.pop("text_color"))
  221. require_redraw = True
  222. if "scrollbar_button_color" in kwargs:
  223. self._scrollbar_button_color = self._check_color_type(kwargs.pop("scrollbar_button_color"))
  224. self._x_scrollbar.configure(button_color=self._scrollbar_button_color)
  225. self._y_scrollbar.configure(button_color=self._scrollbar_button_color)
  226. if "scrollbar_button_hover_color" in kwargs:
  227. self._scrollbar_button_hover_color = self._check_color_type(kwargs.pop("scrollbar_button_hover_color"))
  228. self._x_scrollbar.configure(button_hover_color=self._scrollbar_button_hover_color)
  229. self._y_scrollbar.configure(button_hover_color=self._scrollbar_button_hover_color)
  230. if "corner_radius" in kwargs:
  231. self._corner_radius = kwargs.pop("corner_radius")
  232. self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True)
  233. require_redraw = True
  234. if "border_width" in kwargs:
  235. self._border_width = kwargs.pop("border_width")
  236. self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True)
  237. require_redraw = True
  238. if "border_spacing" in kwargs:
  239. self._border_spacing = kwargs.pop("border_spacing")
  240. self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True)
  241. require_redraw = True
  242. if "font" in kwargs:
  243. if isinstance(self._font, CTkFont):
  244. self._font.remove_size_configure_callback(self._update_font)
  245. self._font = self._check_font_type(kwargs.pop("font"))
  246. if isinstance(self._font, CTkFont):
  247. self._font.add_size_configure_callback(self._update_font)
  248. self._update_font()
  249. self._textbox.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_text_attributes))
  250. super().configure(require_redraw=require_redraw, **kwargs)
  251. def cget(self, attribute_name: str) -> any:
  252. if attribute_name == "corner_radius":
  253. return self._corner_radius
  254. elif attribute_name == "border_width":
  255. return self._border_width
  256. elif attribute_name == "border_spacing":
  257. return self._border_spacing
  258. elif attribute_name == "fg_color":
  259. return self._fg_color
  260. elif attribute_name == "border_color":
  261. return self._border_color
  262. elif attribute_name == "text_color":
  263. return self._text_color
  264. elif attribute_name == "font":
  265. return self._font
  266. else:
  267. return super().cget(attribute_name)
  268. def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True):
  269. """ called on the tkinter.Canvas """
  270. if not (add == "+" or add is True):
  271. raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks")
  272. self._textbox.bind(sequence, command, add=True)
  273. def unbind(self, sequence: str = None, funcid: str = None):
  274. """ called on the tkinter.Label and tkinter.Canvas """
  275. if funcid is not None:
  276. raise ValueError("'funcid' argument can only be None, because there is a bug in" +
  277. " tkinter and its not clear whether the internal callbacks will be unbinded or not")
  278. self._textbox.unbind(sequence, None)
  279. def focus(self):
  280. return self._textbox.focus()
  281. def focus_set(self):
  282. return self._textbox.focus_set()
  283. def focus_force(self):
  284. return self._textbox.focus_force()
  285. def insert(self, index, text, tags=None):
  286. return self._textbox.insert(index, text, tags)
  287. def get(self, index1, index2=None):
  288. return self._textbox.get(index1, index2)
  289. def bbox(self, index):
  290. return self._textbox.bbox(index)
  291. def compare(self, index, op, index2):
  292. return self._textbox.compare(index, op, index2)
  293. def delete(self, index1, index2=None):
  294. return self._textbox.delete(index1, index2)
  295. def dlineinfo(self, index):
  296. return self._textbox.dlineinfo(index)
  297. def edit_modified(self, arg=None):
  298. return self._textbox.edit_modified(arg)
  299. def edit_redo(self):
  300. self._check_if_scrollbars_needed()
  301. return self._textbox.edit_redo()
  302. def edit_reset(self):
  303. return self._textbox.edit_reset()
  304. def edit_separator(self):
  305. return self._textbox.edit_separator()
  306. def edit_undo(self):
  307. self._check_if_scrollbars_needed()
  308. return self._textbox.edit_undo()
  309. def image_create(self, index, **kwargs):
  310. raise AttributeError("embedding images is forbidden, because would be incompatible with scaling")
  311. def image_cget(self, index, option):
  312. raise AttributeError("embedding images is forbidden, because would be incompatible with scaling")
  313. def image_configure(self, index):
  314. raise AttributeError("embedding images is forbidden, because would be incompatible with scaling")
  315. def image_names(self):
  316. raise AttributeError("embedding images is forbidden, because would be incompatible with scaling")
  317. def index(self, i):
  318. return self._textbox.index(i)
  319. def mark_gravity(self, mark, gravity=None):
  320. return self._textbox.mark_gravity(mark, gravity)
  321. def mark_names(self):
  322. return self._textbox.mark_names()
  323. def mark_next(self, index):
  324. return self._textbox.mark_next(index)
  325. def mark_previous(self, index):
  326. return self._textbox.mark_previous(index)
  327. def mark_set(self, mark, index):
  328. return self._textbox.mark_set(mark, index)
  329. def mark_unset(self, mark):
  330. return self._textbox.mark_unset(mark)
  331. def scan_dragto(self, x, y):
  332. return self._textbox.scan_dragto(x, y)
  333. def scan_mark(self, x, y):
  334. return self._textbox.scan_mark(x, y)
  335. def search(self, pattern, index, *args, **kwargs):
  336. return self._textbox.search(pattern, index, *args, **kwargs)
  337. def see(self, index):
  338. return self._textbox.see(index)
  339. def tag_add(self, tagName, index1, index2=None):
  340. return self._textbox.tag_add(tagName, index1, index2)
  341. def tag_bind(self, tagName, sequence, func, add=None):
  342. return self._textbox.tag_bind(tagName, sequence, func, add)
  343. def tag_cget(self, tagName, option):
  344. return self._textbox.tag_cget(tagName, option)
  345. def tag_config(self, tagName, **kwargs):
  346. if "font" in kwargs:
  347. raise AttributeError("'font' option forbidden, because would be incompatible with scaling")
  348. return self._textbox.tag_config(tagName, **kwargs)
  349. def tag_delete(self, *tagName):
  350. return self._textbox.tag_delete(*tagName)
  351. def tag_lower(self, tagName, belowThis=None):
  352. return self._textbox.tag_lower(tagName, belowThis)
  353. def tag_names(self, index=None):
  354. return self._textbox.tag_names(index)
  355. def tag_nextrange(self, tagName, index1, index2=None):
  356. return self._textbox.tag_nextrange(tagName, index1, index2)
  357. def tag_prevrange(self, tagName, index1, index2=None):
  358. return self._textbox.tag_prevrange(tagName, index1, index2)
  359. def tag_raise(self, tagName, aboveThis=None):
  360. return self._textbox.tag_raise(tagName, aboveThis)
  361. def tag_ranges(self, tagName):
  362. return self._textbox.tag_ranges(tagName)
  363. def tag_remove(self, tagName, index1, index2=None):
  364. return self._textbox.tag_remove(tagName, index1, index2)
  365. def tag_unbind(self, tagName, sequence, funcid=None):
  366. return self._textbox.tag_unbind(tagName, sequence, funcid)
  367. def window_cget(self, index, option):
  368. raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)")
  369. def window_configure(self, index, option):
  370. raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)")
  371. def window_create(self, index, **kwargs):
  372. raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)")
  373. def window_names(self):
  374. raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)")
  375. def xview(self, *args):
  376. return self._textbox.xview(*args)
  377. def xview_moveto(self, fraction):
  378. return self._textbox.xview_moveto(fraction)
  379. def xview_scroll(self, n, what):
  380. return self._textbox.xview_scroll(n, what)
  381. def yview(self, *args):
  382. return self._textbox.yview(*args)
  383. def yview_moveto(self, fraction):
  384. return self._textbox.yview_moveto(fraction)
  385. def yview_scroll(self, n, what):
  386. return self._textbox.yview_scroll(n, what)