123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- import curses
- import datetime
- import bisect
- import logging
- def max_key_length(obj):
- return max([len(key) for key in [obj[i]['key'] for i in range(0, len(obj))]])
- def today():
- return datetime.date.today().strftime('%Y-%m-%d')
- class CommandWindow():
- def __init__(self, max_nl, max_nc, y, x):
- self.max_nl = max_nl
- self.max_nc = max_nc
- self.y = y
- self.x = x
- self.window = curses.newwin(max_nl, max_nc, y, x)
- self.textbox = curses.textpad.Textbox(self.window)
- self.text = None
- self.search_query = None
-
- def update(self, state, keypress):
- if keypress == curses.KEY_RESIZE:
- max_nl, max_nc = state['stdscr'].getmaxyx()
- self.max_nc = max_nc
- self.window.mvwin(max_nl - 1, 0)
- if state['write']:
- filepath = state['filepath']
- state['write'] = False
- state['write_callback'](filepath, state['logs'])
- self.text = f'file "{filepath}" written'
- def render(self):
- self.window.clear()
- self.window.addstr(0, 0, self.text[:self.max_nc] if self.text else '', curses.A_REVERSE)
- self.window.refresh()
-
- def clear(self):
- self.text = None
- def enter(self, prefix):
- self.window.erase()
- self.window.addstr(0, 0, prefix)
- self.textbox.edit()
- self.text = self.textbox.gather()
- return self.text
- class WindowTreeNode():
- def __init__(self, cmd, max_nl, max_nc, nl, nc, y, x, entry, parent=None, active=False, display_metadata=False):
- self.cmd = cmd
- self.max_nl = max_nl
- self.max_nc = max_nc
- self.nl = nl
- self.nc = nc
- self.y = y
- self.x = x
- self.window = curses.newwin(nl, nc, y, x)
- self.entry = entry
- self.parent = parent
- self.active = active
- self.display_metadata = display_metadata
- self.children = []
- self.cursor_pos = 0
- self.deletion_queue = []
- self.metadata_columns = 4 if display_metadata else 0
- self.key = self.entry['key']
- self.items = self.entry['items']
- self.leaf = self.entry['leaf']
- self.sorting = self.entry['sorting']
- self.comment = self.entry['comment']
- def deactivate(self):
- self.active = False
- def activate(self):
- self.active = True
- def focus(self):
- self.nc = self.max_nc - (self.parent.nc if self.parent else 0)
- self.window.resize(self.nl, self.nc)
- def defocus(self):
- self.nc = max_key_length(self.items)
- self.window.resize(self.nl, self.nc)
-
- def selected(self):
- return None if len(self.items) == 0 else self.items[self.cursor_pos]
- def get_key(self, item):
- return item if self.leaf else item['key']
- def add_child_window(self):
- self.defocus()
- sel = self.selected()
- if self.leaf or not sel:
- return
- child = WindowTreeNode(
- cmd=self.cmd,
- max_nl=self.max_nl,
- max_nc=self.max_nc,
- nl=self.nl,
- nc=self.max_nc - self.nc - 2,
- y=self.y,
- x=self.x + self.nc + 2,
- entry=sel,
- parent=self,
- active=True,
- display_metadata=self.display_metadata)
- self.children.append(child)
- def remove_child_window(self, window):
- self.focus()
- self.children.remove(window)
- def build_dict_entry(self, key):
- return {'key': key, 'leaf': True, 'sorting': True, 'comment': '', 'items': []}
- def notify_cmd(self, text):
- self.cmd.clear()
- self.cmd.text = text
- def query_cmd(self, prefix, init_text=''):
- self.cmd.clear()
- entered = self.cmd.enter(f'{prefix}{init_text}')
- return entered[len(prefix):].rstrip()
-
- def search(self, query):
- # TODO: support reverse search to implement [n]ext/[N]ext functionality
- if self.leaf:
- self.search_query = query
- for i in range(self.cursor_pos, len(self.items)):
- if query in self.items[i]:
- self.cursor_pos = i
- return
- for i in range(0, self.cursor_pos):
- if query in self.items[i]:
- self.cursor_pos = i
- return
- self.notify_cmd(f'search for {query}: not found')
- def update_entry(self, new_entry):
- if len(new_entry) == 0:
- return
- sel = self.selected()
- if self.leaf:
- self.items.remove(sel)
- self.insert_entry(new_entry)
- else:
- sel['key'] = new_entry
- def insert_entry(self, new_entry, after=False):
- if len(new_entry) == 0:
- return
- if len(self.items) == 0:
- choice_leaf = self.query_cmd('insert as new category? [y/N] ')
- choice_sorting = self.query_cmd('create list with sorting? [y/N] ')
- self.sorting = self.entry['sorting'] = 'y' in choice_sorting.lower()
- if 'y' in choice_leaf.lower():
- self.items.append(self.build_dict_entry(new_entry))
- self.leaf = self.entry['leaf'] = False
- else:
- self.items.append(new_entry)
- elif self.leaf:
- if self.sorting:
- bisect.insort(self.items, new_entry)
- self.cursor_pos = bisect.bisect_left(self.items, new_entry)
- else:
- new_pos = self.cursor_pos if not after else self.cursor_pos + 1
- self.items.insert(new_pos, new_entry)
- self.cursor_pos = new_pos
- else:
- new_dict = self.build_dict_entry(new_entry)
- new_pos = self.cursor_pos if not after else self.cursor_pos + 1
- self.items.insert(new_pos, new_dict)
- self.cursor_pos = new_pos
- def soft_delete_selected_entry(self):
- if self.cursor_pos not in self.deletion_queue:
- self.deletion_queue.append(self.cursor_pos)
- def hard_delete_entries(self):
- indices = sorted(self.deletion_queue, reverse=True)
- for pos in indices:
- del self.items[pos]
- self.deletion_queue.clear()
- self.cursor_pos = min(self.cursor_pos, len(self.items) - 1)
-
- def undo_soft_delete(self):
- if len(self.deletion_queue) > 0:
- self.deletion_queue.pop()
- def toggle_sorting(self):
- self.sorting = self.entry['sorting'] = not self.sorting
- self.notify_cmd(f'sorting toggled to {self.sorting}')
- def update(self, state, keypress):
- if keypress == curses.KEY_RESIZE:
- max_nl, max_nc = state['stdscr'].getmaxyx()
- # subtract cmd window
- max_nl -= 1
- diff_l = max_nl - self.max_nl
- diff_c = max_nc - self.max_nc
- self.max_nl = max_nl
- self.max_nc = max_nc
- self.nl += diff_l
- self.nc += diff_c
- for child in self.children:
- child.update(state, keypress)
- elif not self.active:
- for child in self.children:
- child.update(state, keypress)
- elif keypress == ord(':'):
- entered = self.query_cmd(':')
- # handle exits
- if entered == 'q' or entered == 'quit':
- state['quit'] = True
- elif entered == 'w' or entered == 'write':
- self.hard_delete_entries()
- state['write'] = True
- elif entered == 'c' or entered == 'comment':
- entered = self.query_cmd('enter comment: ')
- self.comment = self.entry['comment'] = entered.strip()
- elif keypress == ord('/'):
- entered = self.query_cmd('/')
- self.search(entered)
- elif keypress == ord('i') or keypress == ord('I'):
- if len(self.items) > 0:
- entered = self.query_cmd('edit entry: ', self.get_key(self.selected()))
- self.update_entry(entered)
- else:
- self.notify_cmd('insert failed: cannot update entry in empty list')
- elif keypress == ord('o') or keypress == ord('O'):
- entered = self.query_cmd('new entry: ')
- self.insert_entry(entered, keypress == ord('o'))
- elif keypress == ord('t') or keypress == ord('T'):
- entered = self.query_cmd('new entry: ', today())
- self.insert_entry(entered)
- elif keypress == ord('d') or keypress == ord('D'):
- self.soft_delete_selected_entry()
- elif keypress == ord('u'):
- self.undo_soft_delete()
- elif keypress == ord('s'):
- self.toggle_sorting()
- # handle navigation down
- elif keypress == ord('j') or keypress == curses.KEY_DOWN:
- self.cursor_pos = min(len(self.items) - 1, self.cursor_pos + 1)
- # handle navigation up
- elif keypress == ord('k') or keypress == curses.KEY_UP:
- self.cursor_pos = max(0, self.cursor_pos - 1)
- # handle navigation to top
- elif keypress == ord('g'):
- self.cursor_pos = 0
- # handle navigation to bottom
- elif keypress == ord('G'):
- self.cursor_pos = len(self.items) - 1
- # handle navigation right
- elif keypress == ord('l'):
- sel = self.selected()
- if self and not self.leaf:
- self.deactivate()
- self.add_child_window()
- # handle navigation left
- elif keypress == ord('h'):
- if self.parent:
- self.deletion_queue.clear()
- self.deactivate()
- self.parent.activate()
- self.parent.remove_child_window(self)
-
- def get_filtered_item_slice(self, items, start, end):
- soft_delete_marker = '***'
- new_list = []
- for i, item in enumerate(items[start:end]):
- if i + start in self.deletion_queue:
- new_list.append(soft_delete_marker if self.leaf else {'key': soft_delete_marker})
- else:
- new_list.append(item)
- return new_list
- def get_item_view(self):
- il = len(self.items)
- ll = self.nl - self.metadata_columns
- if il < ll:
- return self.cursor_pos, self.get_filtered_item_slice(self.items, 0, len(self.items))
- hnl = ll // 2
- el = ll % 2
- cp = self.cursor_pos
- if cp < hnl:
- cursor_view = cp
- item_view = self.get_filtered_item_slice(self.items, 0, ll)
- return cursor_view, item_view
- elif cp > il - hnl - el:
- cursor_view = -(il - cp) + ll
- item_view = self.get_filtered_item_slice(self.items, il - ll, il)
- return cursor_view, item_view
- else:
- cursor_view = hnl
- item_view = self.get_filtered_item_slice(self.items, cp - hnl, cp - hnl + ll)
- return cursor_view, item_view
- def render_item(self, item, pos, style):
- text = item if self.leaf else item['key']
- self.window.addstr(pos, 0, str(text)[:self.nc], style)
- def render(self):
- self.window.erase()
- # draw window dimensions and other metadata
- if self.display_metadata:
- # upper line
- self.window.hline(self.nl - 4, 0, '-', self.nc)
- self.window.addstr(self.nl - 4, 0, '+')
- # optional comment
- self.window.addstr(self.nl - 3, 0, self.comment if self.comment else '')
- # selected item highlight
- if self.leaf and len(self.items) > 0:
- self.window.addstr(self.nl - 2, 0, str(self.selected())[:self.nc])
- #self.window.addstr(self.nl - 2, 0, f'{self.nl}x{self.nc}'[:self.nc], curses.A_REVERSE)
- self.window.hline(self.nl - 1, 0, '-', self.nc)
- # lower line
- self.window.addstr(self.nl - 1, 0, '+')
- # draw items (subset if necessary)
- cursor, items = self.get_item_view()
- for i, item in enumerate(items):
- # draw item
- self.render_item(item, i, curses.A_REVERSE if i == cursor else curses.A_NORMAL)
- self.window.refresh()
- for child in self.children:
- child.render()
|