libtextworker 0.1.4
Cross-platform, free and open library for Python projects
dirctrl.py
1 """
2 @package libtextworker.interface.wx.dirctrl
3 """
4 
5 # A cross-platform library for Python apps.
6 # Copyright (C) 2023-2024 Le Bao Nguyen and contributors.
7 # This is a part of the libtextworker project.
8 # Licensed under the GNU General Public License version 3.0 or later.
9 
10 import os
11 import time
12 import wx
13 import wx.lib.newevent
14 
15 from libtextworker import _
16 from libtextworker.general import CraftItems, libTewException
18 
19 from enum import auto
20 from typing import Callable
21 from watchdog.events import FileSystemEvent, FileSystemEventHandler
22 from watchdog.observers import Observer
23 
24 # Index for images (for nodes)
25 
26 imgs = wx.ImageList(16, 16)
27 
28 def addImg(name: str) -> int:
29  return imgs.Add(
30  wx.ArtProvider.GetBitmap(
31  getattr(wx, f"ART_{name.upper()}"), wx.ART_OTHER, (16, 16)
32  )
33  )
34 
35 folderidx = addImg("folder")
36 fileidx = addImg("normal_file")
37 openfolderidx = addImg("folder_open")
38 
39 # File system events
40 # I leave them here and you just do what you want
41 # Each *Event class here (yeah they are classes) accepts path as the main keyword.
42 # Use: wx.PostEvent(target, *Event(path=event.src_path)) with event is a watchdog's FileSystemEvent object
43 FileEditedEvent, EVT_FILE_EDITED = wx.lib.newevent.NewEvent()
44 FileCreatedEvent, EVT_FILE_CREATED = wx.lib.newevent.NewEvent()
45 FileDeletedEvent, EVT_FILE_DELETED = wx.lib.newevent.NewEvent()
46 FileOpenedEvent, EVT_FILE_OPEN = wx.lib.newevent.NewEvent()
47 FileClosedEvent, EVT_FILE_CLOSED = wx.lib.newevent.NewEvent()
48 FileMovedEvent, EVT_FILE_MOVED = wx.lib.newevent.NewEvent()
49 
50 DirEditedEvent, EVT_DIR_EDITED = wx.lib.newevent.NewEvent()
51 DirCreatedEvent, EVT_DIR_CREATED = wx.lib.newevent.NewEvent()
52 DirMovedEvent, EVT_DIR_MOVED = wx.lib.newevent.NewEvent()
53 DirDeletedEvent, EVT_DIR_DELETED = wx.lib.newevent.NewEvent()
54 
55 class FSEventHandler(FileSystemEventHandler):
56  """
57  A file system event handler derived from watchdog's FileSystemEventHandler.
58 
59  On both wx and Tk, a new event will be generated.
60  Set the Target attribute which the handler sends the event to.
61 
62  Or, if you use this for your own widget, derive this class like any other classes
63  you use for that widget, and set TargetIsSelf = True instead of Target.
64  This class does not use __init__.
65 
66  Currently only one target is supported.
67 
68  Example usage:
69  ```python
70  from watchdog.observers import Observer
71  (...)
72 
73  def on_close(evt): # On window close
74  observer.stop()
75  observer.join()
76  evt.Skip()
77 
78  def func(evt):
79  # Do anything with evt.path!
80 
81  wind = wx.(...) # A wxWindow
82  wind.Bind("put your wanted event here", func)
83  path = os.path.expanduser('~/')
84  evt_handler = FSEventHandler()
85  evt_handler.Target = wind
86  observer = Observer()
87  observer.schedule(evt_handler, path, recursive=True)
88  observer.start()
89  ```
90  """
91 
92  Target: wx.Window
93  TargetIsSelf: bool = True
94 
95  def evtIsDir(this, event: FileSystemEvent): return "Dir" if event.is_directory else "File"
96  def getTarget(this): return this.Target if not this.TargetIsSelf else this
97 
98  # It sucks when I can't use __dict__ (module variable) to access
99  # class easier (= less code used)
100 
101  def on_moved(this, event: FileSystemEvent):
102  if this.evtIsDir(event) == "Dir": cls_to_use = DirMovedEvent
103  else: cls_to_use = FileMovedEvent
104  wx.PostEvent(this.getTarget(), cls_to_use(path=event.src_path))
105 
106  def on_created(this, event: FileSystemEvent):
107  if this.evtIsDir(event) == "Dir": cls_to_use = DirCreatedEvent
108  else: cls_to_use = FileCreatedEvent
109  wx.PostEvent(this.getTarget(), cls_to_use(path=event.src_path))
110 
111  def on_deleted(this, event: FileSystemEvent):
112  if this.evtIsDir(event) == "Dir": cls_to_use = DirDeletedEvent
113  else: cls_to_use = FileDeletedEvent
114  wx.PostEvent(this.getTarget(), cls_to_use(path=event.src_path))
115 
116  def on_modified(this, event: FileSystemEvent):
117  if this.evtIsDir(event) == "Dir": cls_to_use = DirEditedEvent
118  else: cls_to_use = FileEditedEvent
119  wx.PostEvent(this.getTarget(), cls_to_use(path=event.src_path))
120 
121  def on_closed(this, event: FileSystemEvent):
122  wx.PostEvent(this.getTarget(), FileClosedEvent(path=event.src_path))
123 
124  def on_opened(this, event: FileSystemEvent):
125  wx.PostEvent(this.getTarget(), FileOpenedEvent(path=event.src_path))
126 
127 # For the old os.walk method, please head to
128 # https://python-forum.io/thread-8513.html
129 
130 class DirCtrl(wx.TreeCtrl, FSEventHandler, DirCtrlBase):
131 
132  Parent_ArgName = "parent"
133  TargetIsSelf = True
134  Observers: dict[str] = {}
135 
136  def __init__(this, *args, **kw):
137  """
138  A directory list made from wxTreeCtrl.
139  This is WIP, and lacks lots of features:
140  * Label editting
141  * Copy-paste + right-click menu
142  * Drag-n-drop
143  * Hidden files detect (quite hard, may need to use C/C++)
144  * Sorting items
145 
146  Flags available:
147  * DC_EDIT = wx.TR_EDIT_LABELS
148  * DC_HIDEROOT = hide the root node
149  * DC_ONEROOT: only use one root node
150  * DC_MULTIPLE = wx.TR_MULTIPLE (multiple selections)
151  * No flag at all = wx.TR_DEFAULT_STYLE
152 
153  If you want to add more than one folder to this control,
154  use DC_HIDEROOT and disable DC_ONEROOT (default).
155  """
156  args, kw = DirCtrlBase.__init__(this, *args, **kw)
157 
158  # Process custom styles
159  if not "style" in kw:
160  # wx doc about wxTR_DEFAULT_STYLE:
161  # set flags that are closet to the native system's defaults.
162  if not len(args) >= 5:
163  styles = wx.TR_DEFAULT_STYLE #| wx.TR_HIDE_ROOT
164  use_args = False
165  else:
166  styles = args[4]
167  use_args = True
168  else:
169  styles = kw["style"]
170  use_args = False
171 
172  if DC_EDIT in this.Styles:
173  styles |= wx.TR_EDIT_LABELS
174 
175  if DC_HIDEROOT in this.Styles:
176  if not DC_ONEROOT in this.Styles: # = use multiple folders
177  styles |= wx.TR_HIDE_ROOT
178  # else:
179  # should throw a warning here, but iamlazy rn
180 
181  if DC_MULTIPLE in this.Styles:
182  styles |= wx.TR_MULTIPLE
183 
184  if use_args:
185  args[4] = styles
186  else:
187  kw["style"] = styles
188 
189  wx.TreeCtrl.__init__(this, *args, **kw)
190  this.AssignImageList(imgs)
191 
192  this.Bind(wx.EVT_TREE_ITEM_EXPANDED, this.LazyExpand)
193  this.Bind(wx.EVT_TREE_SEL_CHANGED, this.LazyExpand)
194 
195  def AddItem(evt: FileCreatedEvent | DirCreatedEvent): # type: ignore
196  this.AppendItem(this.MatchItem(os.path.dirname(evt.path)),
197  os.path.basename(evt.path),
198  fileidx if isinstance(evt, FileCreatedEvent) else folderidx)
199  evt.Skip()
200 
201  def DeleteItem(evt: FileDeletedEvent | DirDeletedEvent): # type: ignore
202  wx.TreeCtrl.Delete(this, this.MatchItem(evt.path))
203  evt.Skip()
204 
205  this.Bind(EVT_FILE_CREATED, AddItem)
206  this.Bind(EVT_DIR_CREATED, AddItem)
207  this.Bind(EVT_FILE_DELETED, DeleteItem)
208  this.Bind(EVT_DIR_DELETED, DeleteItem)
209 
210  def Destroy(this):
211  if this.Observers:
212  for item in this.Observers:
213  this.Observers[item].stop()
214  this.Observers[item].join()
215  return wx.TreeCtrl.Destroy(this)
216 
217  def LazyExpand(this, what: wx.PyEvent | wx.TreeItemId):
218  """
219  Expand the given/currently selected item.
220 
221  Explain: if the target item has childs inside, that means
222  the item has been opened before. This can be done by checking
223  whether the item full path is a directory and has items inside.
224  """
225  if isinstance(what, wx.TreeItemId):
226  path = what
227  else:
228  path = this.GetSelection()
229 
230  fullpath = os.path.normpath(this.GetFullPath(path))
231 
232  if os.path.isdir(fullpath) and this.ItemHasChildren(path):
233  wx.TreeCtrl.DeleteChildren(this, path)
234  this.SetItemImage(path, openfolderidx, wx.TreeItemIcon_Expanded)
235  ls = os.listdir(fullpath)
236  for item in ls:
237  craftedpath = CraftItems(fullpath, item)
238  if os.path.isfile(craftedpath) and DC_DIRONLY in this.Styles:
239  continue
240  icon = folderidx if os.path.isdir(craftedpath) else fileidx
241 
242  newitem = this.AppendItem(path, item, icon)
243 
244  if os.path.isdir(craftedpath) and len(os.listdir(craftedpath)) >= 1:
245  this.SetItemHasChildren(newitem)
246 
247  if isinstance(what, wx.PyEvent):
248  what.Skip()
249 
250  def SetFolder(this, path: str):
251  """
252  Make DirCtrl to open (a) directory.
253  @param path (str): Target path
254  @since 0.1.4: Code description
255  """
256 
257  DirCtrlBase.SetFolder(this, path, False)
258 
259  if not DC_ONEROOT in this.Styles and DC_HIDEROOT in this.Styles:
260  root = this.GetRootItem()
261  kickstart = None
262  if not root: root = this.AddRoot("Hidden root")
263  else:
264  for x in this.GetNodeChildren(root):
265  if this.GetItemText(x) == path:
266  kickstart = x
267  if not kickstart: kickstart = this.AppendItem(root, path)
268 
269  elif DC_ONEROOT in this.Styles:
270  this.DeleteAllItems()
271  kickstart = this.AddRoot(path)
272  else:
273  raise libTewException("The tree cannot determine whether to delete everything"
274  " and start from scratch or just add a new one while keeping"
275  " the old root node. Ask the app developer for this.")
276 
277  this.SetItemHasChildren(kickstart)
278  this.SetItemImage(kickstart, folderidx)
279 
280  this.Observers[path] = Observer()
281  this.Observers[path].schedule(this, path, recursive=True)
282  this.Observers[path].start()
283 
284  # From SO (that iterate_root function above) and the
285  # useless help of Google Bard (and I found the problem myself,
286  # Bard just use one more while loop and that's all,
287  # cannot go deeper of the tree)
288  def MatchItem(this, path: str, start: wx.TreeItemId | None = None) -> wx.TreeItemId:
289  """
290  Find for an item in the tree by the specified path.
291  "Item" here is a wx.TreeItemId object.
292  """
293  parent = this.GetRootItem() if not start else start
294  item, cookie = this.GetFirstChild(parent)
295  while item.IsOk():
296  if this.GetFullPath(item) == path:
297  return item
298  if this.ItemHasChildren(item):
299  check = this.MatchItem(path, item)
300  if check: return check
301  item, cookie = this.GetNextChild(parent, cookie)
302 
303  def GetNodeChildren(this, item: wx.TreeItemId | str) -> list[wx.TreeItemId]:
304  """
305  Get all subitems of a tree node.
306  """
307 
308  if isinstance(item, str):
309  node = this.MatchItem(item)
310  else:
311  node = item
312  result = []
313  it, cookie = this.GetFirstChild(node)
314  while it.IsOk():
315  result += [it]
316  it, cookie = this.GetNextChild(it, cookie)
317  return result
318 
319  def Delete(this, item: wx.TreeItemId):
320  """
321  Delete the specified item, but also delete the item on the
322  file system.
323  wx.TreeCtrl.Delete() exists (original method, use with care).
324  """
325 
326  fullpath = this.GetFullPath(item)
327  if os.path.isdir(fullpath) and fullpath in this.Observers:
328  this.Observers[fullpath].stop()
329  this.Observers[fullpath].join()
330  this.Observers.pop(fullpath)
331 
332  import shutil
333  if os.path.isdir(fullpath): shutil.rmtree(fullpath)
334  else: os.remove(fullpath)
335  wx.TreeCtrl.Delete(this, item)
336 
337  def DeleteChildren(this, item: wx.TreeItemId):
338  """
339  Remove all subitems of the specified item, except the item itself.
340  Also remove them from the file system.
341  wx.TreeCtrl.DeleteChildren() exists (original method, use with care).
342  """
343  if not this.ItemHasChildren(item): return
344  for child in this.GetNodeChildren(item):
345  this.Delete(child)
346 
347  def GetFullPath(this, item: wx.TreeItemId | None = None) -> str:
348  """
349  Get the full path of an item.
350  The same work as wxGenericDirCtrl.GetPath.
351  """
352  if item == None:
353  parent = this.GetSelection()
354  else:
355  parent = item
356 
357  result = []
358 
359  if parent == this.GetRootItem():
360  return this.GetItemText(parent)
361 
362  def getroot():
363  nonlocal parent
364  text = this.GetItemText(parent)
365  result.insert(0, text)
366  if parent != this.GetRootItem():
367  if DC_HIDEROOT in this.Styles and DC_ONEROOT not in this.Styles \
368  and parent in this.GetNodeChildren(this.GetRootItem()): return
369  parent = this.GetItemParent(parent)
370  getroot()
371 
372  getroot()
373 
374  if len(result) >= 2:
375  return CraftItems(*tuple(result))
376  else:
377  return result[0]
378 
379 
380 # @since 0.1.4: PatchedDirCtrl renamed to DirCtrl, and PatchedDirCtrl now points to that class
381 # @brief "Patched".. seems not right:v (it's derived from wxTreeCtrl not wxGenericDirCtrl)
382 PatchedDirCtrl = DirCtrl
383 
384 
385 class DirList(wx.ListCtrl, FSEventHandler, DirCtrlBase):
386  """
387  Unlike wxDirCtrl, wxDirList lists not only files + folders, but also their
388  last modified and item type, size. You will see it most in your file explorer,
389  the main pane.
390  Of couse this is wxLC_REPORT will be used.
391  This comes with check buttons, which is optional.
392 
393  libtextworker flags to be ignored:
394  * DC_HIDEROOT (where's the root node in this list control?)
395  * DC_ONEROOT (one root by default so this is useless)
396  """
397 
398  currpath: str
399  Styles = DC_USEICON
400  History: list = []
401  TargetIsSelf = True
402  Watcher: Observer # type: ignore
403 
404  def __init__(this, parent: wx.Window, id=wx.ID_ANY, pos=wx.DefaultPosition, size=wx.DefaultSize,
405  style=wx.LC_REPORT, validator=wx.DefaultValidator, name=wx.ListCtrlNameStr, w_styles: auto = DC_USEICON):
406 
407  if DC_EDIT in w_styles:
408  style |= wx.LC_EDIT_LABELS
409 
410  for i in [wx.LC_ICON, wx.LC_SMALL_ICON, wx.LC_LIST]:
411  if style & i:
412  style /= i
413 
414  DirCtrlBase.__init__(this)
415  wx.ListCtrl.__init__(this, parent, id, pos, size, style, validator, name)
416 
417  this.InsertColumn(0, _("Name"), width=246)
418  this.InsertColumn(1, _("Item type"))
419  this.InsertColumn(2, _("Last modified"), width=150)
420  this.InsertColumn(3, _("Size"))
421 
422  this.AssignImageList(imgs, wx.IMAGE_LIST_SMALL)
423 
424  this.Bind(wx.EVT_LIST_ITEM_ACTIVATED, this.SetFolder)
425 
426  def Destroy(this):
427  this.Watcher.stop()
428  this.Watcher.join()
429  del this.Watcher
430  wx.ListCtrl.Destroy(this)
431 
432  def DrawItems(this, path: str = os.path.expanduser("~/")):
433  """
434  Fill the list control with items;)
435  """
436 
437  this.DeleteAllItems()
438  for item in os.listdir(path):
439  crafted = os.path.join(path, item)
440  statinfo = os.stat(crafted)
441  it_size = 0
442 
443  if os.path.isdir(crafted):
444  it_size = ""
445  this.InsertItem(0, item, folderidx)
446  this.SetItem(0, 1, _("Folder"))
447  elif DC_DIRONLY not in this.Styles:
448  it_size = statinfo.st_size
449  this.InsertItem(0, item, fileidx)
450  this.SetItem(0, 1, _("File"))
451 
452  m_time = statinfo.st_mtime
453  lastmod = time.strftime("%d %b %Y, %H:%M:%S", time.localtime(m_time))
454 
455  this.SetItem(0, 2, lastmod)
456 
457  if isinstance(it_size, int):
458  it_size = this.sizeof_fmt(it_size)
459 
460  this.SetItem(0, 3, str(it_size))
461 
462 
463  def SetFolder(this, evt=None, path: str = ""):
464  """
465  Make this control show the content of a folder.
466  @param evt = None: wxListCtrl event
467  @param path (str): Target path (if not specified but evt will use the current item instead)
468  """
469  if evt and not path:
470  pos = evt.Index
471  name = this.GetItemText(pos)
472  item_type = this.GetItemText(pos, 1)
473 
474  if item_type == _("Folder"):
475  path = os.path.join(this.currpath, name)
476 
477  elif not path:
478  raise Exception("Who the hell call DirList.SetFolder with no directory to go???")
479 
480  DirCtrlBase.SetFolder(this, path, False)
481  this.DrawItems(path)
482  this.Watcher = Observer()
483  this.Watcher.schedule(this, path, True)
484  this.Watcher.start()
485  this.PostSetDir(path, "go")
486 
487  def GoUp(this, evt):
488  this.SetFolder(path=os.path.dirname(this.currpath))
489 
491  this, item: str | Callable | None = None, event: Callable | None = None
492  ):
493  """
494  Never be implemented.
495  Find the way yourself.
496  """
497  raise NotImplementedError("Why do you calling this? Don't be too lazy!")
list[wx.TreeItemId] GetNodeChildren(this, wx.TreeItemId|str item)
Definition: dirctrl.py:303
def DeleteChildren(this, wx.TreeItemId item)
Definition: dirctrl.py:337
wx.TreeItemId MatchItem(this, str path, wx.TreeItemId|None start=None)
Definition: dirctrl.py:288
def Delete(this, wx.TreeItemId item)
Definition: dirctrl.py:319
def LazyExpand(this, wx.PyEvent|wx.TreeItemId what)
Definition: dirctrl.py:217
str GetFullPath(this, wx.TreeItemId|None item=None)
Definition: dirctrl.py:347
def SetFolder(this, evt=None, str path="")
Definition: dirctrl.py:463
def GetFullPath(this, str|Callable|None item=None, Callable|None event=None)
Definition: dirctrl.py:492
def DrawItems(this, str path=os.path.expanduser("~/"))
Definition: dirctrl.py:432