libtextworker 0.1.4
Cross-platform, free and open library for Python projects
editor.py
1 """
2 @package libtextworker.interface.tk.editor
3 @brief Home of Tkinter(Ttk) text editors.
4 """
5 
6 # A cross-platform library for Python apps.
7 # Copyright (C) 2023-2024 Le Bao Nguyen and contributors.
8 # This is a part of the libtextworker project.
9 # Licensed under the GNU General Public License version 3.0 or later.
10 
11 from hashlib import md5
12 from tkinter import BooleanVar, Menu, Text, Misc, TclError
13 from tkinter.font import Font
14 from tkinter.ttk import Scrollbar, Frame
15 from typing import overload
16 
17 try:
18  from tklinenums import TkLineNumbers
19 except:
20  LINENUMS = False
21 else:
22  LINENUMS = True
23 
24 from libtextworker import EDITOR_DIR
25 
26 from .miscs import CreateMenu
27 from .. import stock_editor_configs
28 from ... import _
29 from ...get_config import GetConfig
30 
31 
32 class StyledTextControl(Text):
33  """
34  Customized Tkinter Text widget with some extra features.
35  Note: Use StyledTextControl._frame as the real StyledTextControl's parent.
36  """
37 
38  FileLoaded: str = ""
39  Hash = md5("".encode("utf-8"))
40 
41  def __init__(this, master: Misc | None = None, **kwds):
42  this._frame = Frame(master)
43  Text.__init__(this, this._frame, **kwds)
44 
46  this,
47  useMenu: bool = False,
48  useScrollBars: bool = True,
49  custom_config_path: str = EDITOR_DIR + "/editor.ini",
50  tabwidth: int = 4,
51  ):
52  """
53  Initialize the editor, libtextworker's customize part.
54  @param useMenu: Enable right-click menu (depends on the user setting - else defaults to disable)
55  @param useScrollBars: Show scroll bars
56  @param custom_config_path: Custom editor configs path (optional)
57  @param custom_theme_path: Custom editor theme path (optional)
58  @param tabwidth: Tab (\\t character) width (defaults to user setting else 4)
59  """
60 
61  this.cfger = GetConfig(stock_editor_configs, custom_config_path)
62 
63  this.unRedo = this["undo"]
64  this.wrapbtn = BooleanVar(this)
65 
66  if this.cfger.getkey("menu", "enabled", False, True, True):
67  useMenu = bool(this.cfger.getkey("menu", "enabled"))
68 
69  if int(this.cfger.getkey("indentation", "size", True, True, True)):
70  tabwidth = int(this.cfger.getkey("indentation", "size"))
71 
72  if useMenu:
73  this.RMenu = Menu(this, tearoff=0)
74 
75  this.addMenucascade = this.RMenu.add_cascade
76  this.addMenucheckbtn = this.RMenu.add_checkbutton
77  this.addMenucmd = this.RMenu.add_command
78  this.addMenuradiobtn = this.RMenu.add_radiobutton
79  this.addMenusepr = this.RMenu.add_separator
80 
81  this._menu_init()
82  this.bind("<Button-3>", this._open_menu)
83 
84  if useScrollBars is True:
85  this._place_scrollbar()
86 
87  # Place the line-numbers margin
88  if LINENUMS and this.cfger.getkey("editor", "line_count", noraiseexp=True) \
89  in this.cfger.yes_values: # Bad
90  ln = TkLineNumbers(this._frame, this, "center")
91  ln.pack(fill="y", side="left")
92  this.bind("<<Modified>>", lambda evt: this._frame.after_idle(ln.redraw), add=True)
93 
94  # On editor modify
95  def OnEditorModify(evt):
96  this.Hash = md5(this.get(1.0, "end").encode("utf-8"))
97  this.bind("<<Modified>>", OnEditorModify, add=True)
98 
99  # Tab size
100  this.config(tabs=Font(font=this['font']).measure(' '*tabwidth))
101 
102  # Place scrollbars
103  def _place_scrollbar(this):
104  xbar = Scrollbar(this._frame, orient="horizontal", command=this.xview)
105  ybar = Scrollbar(this._frame, orient="vertical", command=this.yview)
106  # ybar.set = ybar.quit
107  xbar.pack(side="bottom", fill="x")
108  ybar.pack(side="right", fill="y")
109 
110  # Right click menu
111  def _menu_init(this):
112  this.RMenu = CreateMenu(
113  [
114  {
115  "label": _("Cut"),
116  "accelerator": "Ctrl+X",
117  "handler": lambda: this.event_generate("<Control-x>"),
118  },
119  {
120  "label": _("Copy"),
121  "accelerator": "Ctrl+C",
122  "handler": lambda: this.event_generate("<Control-c>"),
123  },
124  {
125  "label": _("Paste"),
126  "accelerator": "Ctrl+V",
127  "handler": lambda: this.event_generate("<Control-v>"),
128  },
129  ]
130  )
131  if this.unRedo:
132  this.RMenu.add_separator()
133  this.addMenucmd(
134  label=_("Undo"),
135  accelerator="Ctrl+Z",
136  command=lambda: this.edit_undo(),
137  )
138  this.addMenucmd(
139  label=_("Redo"),
140  accelerator="Ctrl+Y",
141  command=lambda: this.edit_redo(),
142  )
143 
144  def _open_menu(this, event):
145  try:
146  this.RMenu.post(event.x_root, event.y_root)
147  finally:
148  this.RMenu.grab_release()
149 
150  # File load / save
151  @property
152  def IsModified(this) -> bool:
153  """
154  Probably this will let you know if the editor content has been cooked or not.
155  """
156  def checkhash(target): return not this.Hash.digest() == md5(target.encode("utf-8")).digest()
157  if not this.FileLoaded: return checkhash("") # Currently can't use .get for no reason
158  return checkhash(open(this.FileLoaded, "r").read())
159 
160  def LoadFile(this, path: str):
161  """
162  Load a file.
163  Warning: the path must exists on the file system, else
164  an exception will be raised (no handle from us).
165  Also this will OVERWRITE existing editor CONTENT, so make
166  sure you have your backup way.
167  """
168  content = open(path, "r").read()
169  this.insert(1.0, content)
170  this.FileLoaded = path
171  this.Hash = md5(content.encode("utf-8"))
172 
173  @overload
174  def SaveFile(this, path: str):
175  """
176  Write the current editor contents into a file.
177  """
178  content = this.get(1.0, "end")
179  open(path, "w").write(content)
180  this.Hash = md5(content.encode("utf-8"))
181 
182  @overload
183  def SaveFile(this):
184  """
185  Write the current editor contents into the loaded file, if any.
186  If not able to, do nothing.
187  """
188  if this.FileLoaded: return this.SaveFile(this.FileLoaded)
189 
190  # Wrap mode
191  def wrapmode(this, event=None) -> bool:
192  """
193  Toggle editor word wrap mode.
194  Only use with TextWidget.wrapbtn BooleanVar.
195  """
196  value = this.wrapbtn.get()
197  this.configure(wrap="none" if value else "word")
198  this.wrapbtn.set(not value)
199  return not value
200 
201  # Undo/redo forks
202  def edit_undo(this) -> None:
203  """
204  Undoes the last edit option, if able to.
205  """
206  try:
207  super().edit_undo()
208  except TclError:
209  pass
210 
211  def edit_redo(this) -> None:
212  """
213  Redoes the last undone edit option, if able to.
214  """
215  try:
216  super().edit_redo()
217  except TclError:
218  pass
219 
220 
221 TextWidget = StyledTextControl
def EditorInit(this, bool useMenu=False, bool useScrollBars=True, str custom_config_path=EDITOR_DIR+"/editor.ini", int tabwidth=4)
Definition: editor.py:51