summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--poezio/core/tabs.py323
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