window_tree_node.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import curses
  2. import datetime
  3. import bisect
  4. import logging
  5. def max_key_length(obj):
  6. return max([len(key) for key in [obj[i]['key'] for i in range(0, len(obj))]])
  7. def today():
  8. return datetime.date.today().strftime('%Y-%m-%d')
  9. class CommandWindow():
  10. def __init__(self, max_nl, max_nc, y, x):
  11. self.max_nl = max_nl
  12. self.max_nc = max_nc
  13. self.y = y
  14. self.x = x
  15. self.window = curses.newwin(max_nl, max_nc, y, x)
  16. self.textbox = curses.textpad.Textbox(self.window)
  17. self.text = None
  18. self.search_query = None
  19. def update(self, state, keypress):
  20. if keypress == curses.KEY_RESIZE:
  21. max_nl, max_nc = state['stdscr'].getmaxyx()
  22. self.max_nc = max_nc
  23. self.window.mvwin(max_nl - 1, 0)
  24. if state['write']:
  25. filepath = state['filepath']
  26. state['write'] = False
  27. state['write_callback'](filepath, state['logs'])
  28. self.text = f'file "{filepath}" written'
  29. def render(self):
  30. self.window.clear()
  31. self.window.addstr(0, 0, self.text[:self.max_nc] if self.text else '', curses.A_REVERSE)
  32. self.window.refresh()
  33. def clear(self):
  34. self.text = None
  35. def enter(self, prefix):
  36. self.window.erase()
  37. self.window.addstr(0, 0, prefix)
  38. self.textbox.edit()
  39. self.text = self.textbox.gather()
  40. return self.text
  41. class WindowTreeNode():
  42. def __init__(self, cmd, max_nl, max_nc, nl, nc, y, x, entry, parent=None, active=False, display_metadata=False):
  43. self.cmd = cmd
  44. self.max_nl = max_nl
  45. self.max_nc = max_nc
  46. self.nl = nl
  47. self.nc = nc
  48. self.y = y
  49. self.x = x
  50. self.window = curses.newwin(nl, nc, y, x)
  51. self.entry = entry
  52. self.parent = parent
  53. self.active = active
  54. self.display_metadata = display_metadata
  55. self.children = []
  56. self.cursor_pos = 0
  57. self.deletion_queue = []
  58. self.metadata_columns = 4 if display_metadata else 0
  59. self.key = self.entry['key']
  60. self.items = self.entry['items']
  61. self.leaf = self.entry['leaf']
  62. self.sorting = self.entry['sorting']
  63. self.comment = self.entry['comment']
  64. def deactivate(self):
  65. self.active = False
  66. def activate(self):
  67. self.active = True
  68. def focus(self):
  69. self.nc = self.max_nc - (self.parent.nc if self.parent else 0)
  70. self.window.resize(self.nl, self.nc)
  71. def defocus(self):
  72. self.nc = max_key_length(self.items)
  73. self.window.resize(self.nl, self.nc)
  74. def selected(self):
  75. return None if len(self.items) == 0 else self.items[self.cursor_pos]
  76. def get_key(self, item):
  77. return item if self.leaf else item['key']
  78. def add_child_window(self):
  79. self.defocus()
  80. sel = self.selected()
  81. if self.leaf or not sel:
  82. return
  83. child = WindowTreeNode(
  84. cmd=self.cmd,
  85. max_nl=self.max_nl,
  86. max_nc=self.max_nc,
  87. nl=self.nl,
  88. nc=self.max_nc - self.nc - 2,
  89. y=self.y,
  90. x=self.x + self.nc + 2,
  91. entry=sel,
  92. parent=self,
  93. active=True,
  94. display_metadata=self.display_metadata)
  95. self.children.append(child)
  96. def remove_child_window(self, window):
  97. self.focus()
  98. self.children.remove(window)
  99. def build_dict_entry(self, key):
  100. return {'key': key, 'leaf': True, 'sorting': True, 'comment': '', 'items': []}
  101. def notify_cmd(self, text):
  102. self.cmd.clear()
  103. self.cmd.text = text
  104. def query_cmd(self, prefix, init_text=''):
  105. self.cmd.clear()
  106. entered = self.cmd.enter(f'{prefix}{init_text}')
  107. return entered[len(prefix):].rstrip()
  108. def search(self, query):
  109. # TODO: support reverse search to implement [n]ext/[N]ext functionality
  110. if self.leaf:
  111. self.search_query = query
  112. for i in range(self.cursor_pos, len(self.items)):
  113. if query in self.items[i]:
  114. self.cursor_pos = i
  115. return
  116. for i in range(0, self.cursor_pos):
  117. if query in self.items[i]:
  118. self.cursor_pos = i
  119. return
  120. self.notify_cmd(f'search for {query}: not found')
  121. def update_entry(self, new_entry):
  122. if len(new_entry) == 0:
  123. return
  124. sel = self.selected()
  125. if self.leaf:
  126. self.items.remove(sel)
  127. self.insert_entry(new_entry)
  128. else:
  129. sel['key'] = new_entry
  130. def insert_entry(self, new_entry, after=False):
  131. if len(new_entry) == 0:
  132. return
  133. if len(self.items) == 0:
  134. choice_leaf = self.query_cmd('insert as new category? [y/N] ')
  135. choice_sorting = self.query_cmd('create list with sorting? [y/N] ')
  136. self.sorting = self.entry['sorting'] = 'y' in choice_sorting.lower()
  137. if 'y' in choice_leaf.lower():
  138. self.items.append(self.build_dict_entry(new_entry))
  139. self.leaf = self.entry['leaf'] = False
  140. else:
  141. self.items.append(new_entry)
  142. elif self.leaf:
  143. if self.sorting:
  144. bisect.insort(self.items, new_entry)
  145. self.cursor_pos = bisect.bisect_left(self.items, new_entry)
  146. else:
  147. new_pos = self.cursor_pos if not after else self.cursor_pos + 1
  148. self.items.insert(new_pos, new_entry)
  149. self.cursor_pos = new_pos
  150. else:
  151. new_dict = self.build_dict_entry(new_entry)
  152. new_pos = self.cursor_pos if not after else self.cursor_pos + 1
  153. self.items.insert(new_pos, new_dict)
  154. self.cursor_pos = new_pos
  155. def soft_delete_selected_entry(self):
  156. if self.cursor_pos not in self.deletion_queue:
  157. self.deletion_queue.append(self.cursor_pos)
  158. def hard_delete_entries(self):
  159. indices = sorted(self.deletion_queue, reverse=True)
  160. for pos in indices:
  161. del self.items[pos]
  162. self.deletion_queue.clear()
  163. self.cursor_pos = min(self.cursor_pos, len(self.items) - 1)
  164. def undo_soft_delete(self):
  165. if len(self.deletion_queue) > 0:
  166. self.deletion_queue.pop()
  167. def toggle_sorting(self):
  168. self.sorting = self.entry['sorting'] = not self.sorting
  169. self.notify_cmd(f'sorting toggled to {self.sorting}')
  170. def update(self, state, keypress):
  171. if keypress == curses.KEY_RESIZE:
  172. max_nl, max_nc = state['stdscr'].getmaxyx()
  173. # subtract cmd window
  174. max_nl -= 1
  175. diff_l = max_nl - self.max_nl
  176. diff_c = max_nc - self.max_nc
  177. self.max_nl = max_nl
  178. self.max_nc = max_nc
  179. self.nl += diff_l
  180. self.nc += diff_c
  181. for child in self.children:
  182. child.update(state, keypress)
  183. elif not self.active:
  184. for child in self.children:
  185. child.update(state, keypress)
  186. elif keypress == ord(':'):
  187. entered = self.query_cmd(':')
  188. # handle exits
  189. if entered == 'q' or entered == 'quit':
  190. state['quit'] = True
  191. elif entered == 'w' or entered == 'write':
  192. self.hard_delete_entries()
  193. state['write'] = True
  194. elif entered == 'c' or entered == 'comment':
  195. entered = self.query_cmd('enter comment: ')
  196. self.comment = self.entry['comment'] = entered.strip()
  197. elif keypress == ord('/'):
  198. entered = self.query_cmd('/')
  199. self.search(entered)
  200. elif keypress == ord('i') or keypress == ord('I'):
  201. if len(self.items) > 0:
  202. entered = self.query_cmd('edit entry: ', self.get_key(self.selected()))
  203. self.update_entry(entered)
  204. else:
  205. self.notify_cmd('insert failed: cannot update entry in empty list')
  206. elif keypress == ord('o') or keypress == ord('O'):
  207. entered = self.query_cmd('new entry: ')
  208. self.insert_entry(entered, keypress == ord('o'))
  209. elif keypress == ord('t') or keypress == ord('T'):
  210. entered = self.query_cmd('new entry: ', today())
  211. self.insert_entry(entered)
  212. elif keypress == ord('d') or keypress == ord('D'):
  213. self.soft_delete_selected_entry()
  214. elif keypress == ord('u'):
  215. self.undo_soft_delete()
  216. elif keypress == ord('s'):
  217. self.toggle_sorting()
  218. # handle navigation down
  219. elif keypress == ord('j') or keypress == curses.KEY_DOWN:
  220. self.cursor_pos = min(len(self.items) - 1, self.cursor_pos + 1)
  221. # handle navigation up
  222. elif keypress == ord('k') or keypress == curses.KEY_UP:
  223. self.cursor_pos = max(0, self.cursor_pos - 1)
  224. # handle navigation to top
  225. elif keypress == ord('g'):
  226. self.cursor_pos = 0
  227. # handle navigation to bottom
  228. elif keypress == ord('G'):
  229. self.cursor_pos = len(self.items) - 1
  230. # handle navigation right
  231. elif keypress == ord('l'):
  232. sel = self.selected()
  233. if self and not self.leaf:
  234. self.deactivate()
  235. self.add_child_window()
  236. # handle navigation left
  237. elif keypress == ord('h'):
  238. if self.parent:
  239. self.deletion_queue.clear()
  240. self.deactivate()
  241. self.parent.activate()
  242. self.parent.remove_child_window(self)
  243. def get_filtered_item_slice(self, items, start, end):
  244. soft_delete_marker = '***'
  245. new_list = []
  246. for i, item in enumerate(items[start:end]):
  247. if i + start in self.deletion_queue:
  248. new_list.append(soft_delete_marker if self.leaf else {'key': soft_delete_marker})
  249. else:
  250. new_list.append(item)
  251. return new_list
  252. def get_item_view(self):
  253. il = len(self.items)
  254. ll = self.nl - self.metadata_columns
  255. if il < ll:
  256. return self.cursor_pos, self.get_filtered_item_slice(self.items, 0, len(self.items))
  257. hnl = ll // 2
  258. el = ll % 2
  259. cp = self.cursor_pos
  260. if cp < hnl:
  261. cursor_view = cp
  262. item_view = self.get_filtered_item_slice(self.items, 0, ll)
  263. return cursor_view, item_view
  264. elif cp > il - hnl - el:
  265. cursor_view = -(il - cp) + ll
  266. item_view = self.get_filtered_item_slice(self.items, il - ll, il)
  267. return cursor_view, item_view
  268. else:
  269. cursor_view = hnl
  270. item_view = self.get_filtered_item_slice(self.items, cp - hnl, cp - hnl + ll)
  271. return cursor_view, item_view
  272. def render_item(self, item, pos, style):
  273. text = item if self.leaf else item['key']
  274. self.window.addstr(pos, 0, str(text)[:self.nc], style)
  275. def render(self):
  276. self.window.erase()
  277. # draw window dimensions and other metadata
  278. if self.display_metadata:
  279. # upper line
  280. self.window.hline(self.nl - 4, 0, '-', self.nc)
  281. self.window.addstr(self.nl - 4, 0, '+')
  282. # optional comment
  283. self.window.addstr(self.nl - 3, 0, self.comment if self.comment else '')
  284. # selected item highlight
  285. if self.leaf and len(self.items) > 0:
  286. self.window.addstr(self.nl - 2, 0, str(self.selected())[:self.nc])
  287. #self.window.addstr(self.nl - 2, 0, f'{self.nl}x{self.nc}'[:self.nc], curses.A_REVERSE)
  288. self.window.hline(self.nl - 1, 0, '-', self.nc)
  289. # lower line
  290. self.window.addstr(self.nl - 1, 0, '+')
  291. # draw items (subset if necessary)
  292. cursor, items = self.get_item_view()
  293. for i, item in enumerate(items):
  294. # draw item
  295. self.render_item(item, i, curses.A_REVERSE if i == cursor else curses.A_NORMAL)
  296. self.window.refresh()
  297. for child in self.children:
  298. child.render()