diff options
-rw-r--r-- | poezio/core/tabs.py | 323 |
1 files changed, 323 insertions, 0 deletions
diff --git a/poezio/core/tabs.py b/poezio/core/tabs.py new file mode 100644 index 00000000..18f0ce49 --- /dev/null +++ b/poezio/core/tabs.py @@ -0,0 +1,323 @@ +""" +Tabs management module + +Provide a class holding the current tabs of the application. +Supported list modification operations: + - Appending a tab + - Deleting a tab (and going back to the previous one) + - Inserting a tab from a position to another + - Replacing the whole tab list with another (used for rearranging the + list from outside) + +This class holds a cursor to the current tab, which allows: + - Going left (prev()) or right (next()) in the list, cycling + - Getting a reference to the current tab + - Setting the current tab by index or reference + +It supports the poezio "gap tab" concept, aka empty tabs taking a space in the +tab list in order to avoid shifting the tab numbers when closing a tab. +Example tab list: [0|1|2|3|4] +We then close the tab 3: [0|1|2|4] +The tab has been closed and replaced with a "gap tab", which the user cannot +switch to, but which avoids shifting numbers (in the case above, the list would +have become [0|1|2|3], with the tab "4" renumbered to "3" if gap tabs are +disabled. +""" + +from typing import List, Dict, Type, Optional, Union +from collections import defaultdict +from poezio import tabs + + +class Tabs: + """ + Tab list class + """ + __slots__ = [ + '_current_index', '_current_tab', '_tabs', '_tab_types', '_tab_names', + '_previous_tab' + ] + + def __init__(self): + """ + Initialize the Tab List. Even though the list is initially + empty, all methods are only valid once append() has been called + once. Otherwise, mayhem is expected. + """ + # cursor + self._current_index: int = 0 + self._previous_tab: Optional[tabs.Tab] = None + self._current_tab: Optional[tabs.Tab] = None + self._tabs: List[tabs.Tab] = [] + self._tab_types: Dict[Type[tabs.Tab], List[tabs.Tab]] = defaultdict( + list) + self._tab_names: Dict[str, tabs.Tab] = dict() + + def __len__(self): + return len(self._tabs) + + def __iter__(self): + return iter(self._tabs) + + def __getitem__(self, index: Union[int, str]): + if isinstance(index, int): + return self._tabs[index] + return self.by_name(index) + + def first(self) -> tabs.Tab: + """Return the Roster tab""" + return self._tabs[0] + + @property + def current_index(self) -> int: + """Current tab index""" + return self._current_index + + def set_current_index(self, value: int) -> bool: + """Set the current tab index""" + if 0 <= value < len(self._tabs): + tab = self._tabs[value] + if not isinstance(tab, tabs.GapTab): + self._store_previous() + self._current_index = tab.nb + self._current_tab = tab + return True + return False + + @property + def current_tab(self) -> Optional[tabs.Tab]: + """Current tab""" + return self._current_tab + + def set_current_tab(self, tab: tabs.Tab) -> bool: + """Set the current tab""" + if (not isinstance(tab, tabs.GapTab) + and 0 <= tab.nb < len(self._tabs)): + self._store_previous() + self._current_index = tab.nb + self._current_tab = tab + return True + return False + + def get_tabs(self) -> List[tabs.Tab]: + """Return the tab list""" + return self._tabs + + def by_name(self, name: str) -> tabs.Tab: + """Get a tab with a specific name""" + return self._tab_names.get(name) + + def by_class(self, cls: Type[tabs.Tab]) -> List[tabs.Tab]: + """Get all the tabs of a class""" + return self._tab_types.get(cls, []) + + def find_match(self, name: str) -> Optional[tabs.Tab]: + """Get a tab using extended matching (tab.matching_name())""" + + def transform(tab_index): + """Wrap the value of the range around the current index""" + return (tab_index + self._current_index + 1) % len(self._tabs) + + for i in map(transform, range(len(self._tabs) - 1)): + for tab_name in self._tabs[i].matching_names(): + if tab_name[1] and name in tab_name[1].lower(): + return self._tabs[i] + return None + + def by_name_and_class(self, name: str, + cls: Type[tabs.Tab]) -> Optional[tabs.Tab]: + """Get a tab with its name and class""" + cls_tabs = self._tab_types.get(cls, []) + for tab in cls_tabs: + if tab.name == name: + return tab + return None + + def _rebuild(self): + self._tab_types = defaultdict(list) + self._tab_names = dict() + for tab in self._tabs: + self._tab_types[type(tab)].append(tab) + self._tab_names[tab.name] = tab + self._update_numbers() + + def replace_tabs(self, new_tabs: List[tabs.Tab]): + """ + Replace the current tab list with another, and + rebuild the mappings. + """ + self._tabs = new_tabs + self._rebuild() + current_tab = self.current_tab + try: + idx = self._tabs.index(current_tab) + self._current_index = idx + except ValueError: + self._current_index = 0 + self._current_tab = self._tabs[0] + + def _inc_cursor(self): + self._current_index += 1 + if self._current_index >= len(self._tabs): + self._current_index = 0 + self._current_tab = self._tabs[self._current_index] + + def _dec_cursor(self): + self._current_index -= 1 + if self._current_index < 0: + self._current_index = len(self._tabs) - 1 + self._current_tab = self._tabs[self._current_index] + + def _store_previous(self): + self._previous_tab = self._current_tab + + def next(self): + """Go to the right of the tab list (circular)""" + self._store_previous() + self._inc_cursor() + while isinstance(self.current_tab, tabs.GapTab): + self._inc_cursor() + + def prev(self): + """Go to the left of the tab list (circular)""" + self._store_previous() + self._dec_cursor() + while isinstance(self.current_tab, tabs.GapTab): + self._dec_cursor() + + def append(self, tab: tabs.Tab): + """ + Add a tab to the list + """ + if not self._tabs: + tab.nb = 0 + self._current_tab = tab + else: + tab.nb = self._tabs[-1].nb + 1 + self._tabs.append(tab) + self._tab_types[type(tab)].append(tab) + self._tab_names[tab.name] = tab + + def delete(self, tab: tabs.Tab, gap=False): + """Remove a tab""" + if isinstance(tab, tabs.RosterInfoTab): + return + + if gap: + self._tabs[tab.nb] = tabs.GapTab(None) + else: + self._tabs.remove(tab) + + is_current = tab is self.current_tab + + self._tab_types[type(tab)].remove(tab) + del self._tab_names[tab.name] + self._collect_trailing_gaptabs() + self._update_numbers() + if is_current: + self._restore_previous_tab() + if tab is self._previous_tab: + self._previous_tab = None + self._validate_current_index() + + def _restore_previous_tab(self): + if self._previous_tab: + if not self.set_current_tab(self._previous_tab): + self.set_current_index(0) + + def _validate_current_index(self): + if not 0 <= self._current_index < len( + self._tabs) or not self.current_tab: + self.prev() + + def _collect_trailing_gaptabs(self): + """Remove trailing gap tabs if any""" + i = len(self._tabs) - 1 + while isinstance(self._tabs[i], tabs.GapTab): + self._tabs.pop() + i -= 1 + + def _update_numbers(self): + for i, tab in enumerate(self._tabs): + tab.nb = i + + # Moving tabs around # + + def update_gaps(self, enable_gaps: bool): + """ + Remove the present gaps from the list if enable_gaps is False. + """ + if not enable_gaps: + self._tabs = [tab for tab in self._tabs if tab] + self._update_numbers() + + def _insert_nogaps(self, old_pos: int, new_pos: int) -> bool: + """ + Move tabs without creating gaps + old_pos: old position of the tab + new_pos: desired position of the tab + """ + tab = self._tabs[old_pos] + if new_pos < old_pos: + self._tabs.pop(old_pos) + self._tabs.insert(new_pos, tab) + elif new_pos > old_pos: + self._tabs.insert(new_pos, tab) + self._tabs.remove(tab) + else: + return False + return True + + def _insert_gaps(self, old_pos: int, new_pos: int) -> bool: + """ + Move tabs and create gaps in the eventual remaining space + old_pos: old position of the tab + new_pos: desired position of the tab + """ + tab = self._tabs[old_pos] + target = None if new_pos >= len(self._tabs) else self._tabs[new_pos] + if not target: + if new_pos < len(self._tabs): + old_tab = self._tabs[old_pos] + self._tabs[new_pos], self._tabs[ + old_pos] = old_tab, tabs.GapTab(self) + else: + self._tabs.append(self._tabs[old_pos]) + self._tabs[old_pos] = tabs.GapTab(self) + else: + if new_pos > old_pos: + self._tabs.insert(new_pos, tab) + self._tabs[old_pos] = tabs.GapTab(self) + elif new_pos < old_pos: + self._tabs[old_pos] = tabs.GapTab(self) + self._tabs.insert(new_pos, tab) + else: + return False + i = self._tabs.index(tab) + done = False + # Remove the first Gap on the right in the list + # in order to prevent global shifts when there is empty space + while not done: + i += 1 + if i >= len(self._tabs): + done = True + elif not self._tabs[i]: + self._tabs.pop(i) + done = True + self._collect_trailing_gaptabs() + return True + + def insert_tab(self, old_pos: int, new_pos=99999, gaps=False) -> bool: + """ + Insert a tab at a position, changing the number of the following tabs + returns False if it could not move the tab, True otherwise + """ + if (old_pos <= 0 or old_pos >= len(self._tabs) or new_pos <= 0 + or new_pos == old_pos or not self._tabs[old_pos]): + return False + if gaps: + result = self._insert_gaps(old_pos, new_pos) + else: + result = self._insert_nogaps(old_pos, new_pos) + self._update_numbers() + return result |