diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 47cba592288..5630dc8cf18 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -52,6 +52,8 @@ from qutebrowser.browser import webelem from qutebrowser.browser.inspector import AbstractWebInspector +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.misc.notree import Node tab_id_gen = itertools.count(0) _WidgetType = Union["QWebView", "QWebEngineView"] @@ -1058,6 +1060,11 @@ def __init__(self, *, win_id: int, self, parent=self) self.backend: Optional[usertypes.Backend] = None + if parent and isinstance(parent, TreeTabWidget): + self.node: AbstractTab = Node(self, parent=parent.tree_root) + else: + self.node: AbstractTab = Node(self, parent=None) + # If true, this tab has been requested to be removed (or is removed). self.pending_removal = False self.shutting_down.connect(functools.partial( diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 3b38c44c05b..d1a1cf6be69 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -20,6 +20,7 @@ import os.path import shlex import functools +import urllib.parse from typing import cast, Callable, Dict, Union, Optional from qutebrowser.qt.widgets import QApplication, QTabBar @@ -34,7 +35,7 @@ from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, standarddir, debug) from qutebrowser.utils.usertypes import KeyMode -from qutebrowser.misc import editor, guiprocess, objects +from qutebrowser.misc import editor, guiprocess, objects, notree from qutebrowser.completion.models import urlmodel, miscmodels from qutebrowser.mainwindow import mainwindow, windowundo @@ -112,6 +113,7 @@ def _open( background: bool = False, window: bool = False, related: bool = False, + sibling: bool = False, private: Optional[bool] = None, ) -> None: """Helper function to open a page. @@ -123,6 +125,7 @@ def _open( window: Whether to open in a new window private: If opening a new window, open it in private browsing mode. If not given, inherit the current window's mode. + sibling: Open tab in a sibling node of the currently focused tab. """ urlutils.raise_cmdexc_if_invalid(url) tabbed_browser = self._tabbed_browser @@ -135,10 +138,16 @@ def _open( tabbed_browser = self._new_tabbed_browser(private) tabbed_browser.tabopen(url) tabbed_browser.window().show() - elif tab: - tabbed_browser.tabopen(url, background=False, related=related) - elif background: - tabbed_browser.tabopen(url, background=True, related=related) + elif tab or background: + if tabbed_browser.is_treetabbedbrowser: + tabbed_browser.tabopen(url, background=background, + related=related, sibling=sibling) + elif sibling: + raise cmdutils.CommandError("--sibling flag only works with \ + tree-tab enabled") + else: + tabbed_browser.tabopen(url, background=background, + related=related) else: widget = self._current_widget() widget.load_url(url) @@ -220,7 +229,8 @@ def _get_selection_override(self, prev, next_, opposite): "{!r}!".format(conf_selection)) return None - def _tab_close(self, tab, prev=False, next_=False, opposite=False): + def _tab_close(self, tab, prev=False, next_=False, + opposite=False, new_undo=True): """Helper function for tab_close be able to handle message.async. Args: @@ -236,17 +246,17 @@ def _tab_close(self, tab, prev=False, next_=False, opposite=False): opposite) if selection_override is None: - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) else: old_selection_behavior = tabbar.selectionBehaviorOnRemove() tabbar.setSelectionBehaviorOnRemove(selection_override) - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) def tab_close(self, prev=False, next_=False, opposite=False, - force=False, count=None): + force=False, count=None, recursive=False): """Close the current/[count]th tab. Args: @@ -255,15 +265,37 @@ def tab_close(self, prev=False, next_=False, opposite=False, opposite: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. force: Avoid confirmation for pinned tabs. + recursive: Close all descendents (tree-tabs) as well as current tab count: The tab index to close, or None """ tab = self._cntwidget(count) + tabbed_browser = self._tabbed_browser if tab is None: return - close = functools.partial(self._tab_close, tab, prev, - next_, opposite) - - self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) + if (tabbed_browser.is_treetabbedbrowser and recursive and not + tab.node.collapsed): + # if collapsed, recursive is the same as normal close + new_undo = True # only for first one + for descendent in tab.node.traverse(notree.TraverseOrder.POST_R, + True): + if self._tabbed_browser.widget.indexOf(descendent.value) > -1: + close = functools.partial(self._tab_close, + descendent.value, prev, next_, + opposite, new_undo) + tabbed_browser.tab_close_prompt_if_pinned(tab, force, + close) + new_undo = False + else: + tab = descendent.value + tab.private_api.shutdown() + tab.deleteLater() + tab.layout().unwrap() + else: + # this also applied to closing collapsed tabs + # logic for that is in TreeTabbedBrowser + close = functools.partial(self._tab_close, tab, prev, + next_, opposite) + tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') @@ -288,8 +320,9 @@ def tab_pin(self, count=None): @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @cmdutils.argument('url', completion=urlmodel.url) + @cmdutils.argument('sibling', flag='S') @cmdutils.argument('count', value=cmdutils.Value.count) - def openurl(self, url=None, related=False, + def openurl(self, url=None, related=False, sibling=False, bg=False, tab=False, window=False, count=None, secure=False, private=False): """Open a URL in the current/[count]th tab. @@ -303,6 +336,8 @@ def openurl(self, url=None, related=False, window: Open in a new window. related: If opening a new tab, position the tab as related to the current one (like clicking on a link). + sibling: If opening a new tab, position the as a sibling of the + current one. count: The tab index to open the URL in, or None. secure: Force HTTPS. private: Open a new window in private browsing mode. @@ -321,8 +356,8 @@ def openurl(self, url=None, related=False, bg = True if tab or bg or window or private: - self._open(cur_url, tab, bg, window, related=related, - private=private) + self._open(cur_url, tab, bg, window, private=private, + related=related, sibling=sibling) else: curtab = self._cntwidget(count) if curtab is None: @@ -461,11 +496,40 @@ def tab_take(self, index, keep=False): if not keep: tabbed_browser.close_tab(tab, add_undo=False, transfer=True) + def _tree_tab_give(self, tabbed_browser, keep): + """Helper function to simplify tab-give.""" + uid_map = {1: 1} + traversed = list(self._current_widget().node.traverse()) + # first pass: open tabs + for node in traversed: + tab = tabbed_browser.tabopen(node.value.url()) + + uid_map[node.uid] = tab.node.uid + + # second pass: copy tree structure over + newroot = tabbed_browser.widget.tree_root + oldroot = self._tabbed_browser.widget.tree_root + for node in traversed: + if node.parent is not oldroot: + uid = uid_map[node.uid] + new_node = newroot.get_descendent_by_uid(uid) + parent_uid = uid_map[node.parent.uid] + new_parent = newroot.get_descendent_by_uid(parent_uid) + new_node.parent = new_parent + + # third pass: remove tabs from old window + if not keep: + for _node in traversed: + self._tabbed_browser.close_tab(self._current_widget(), + add_undo=False, + transfer=True) + @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('win_id', completion=miscmodels.window) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_give(self, win_id: int = None, keep: bool = False, - count: int = None, private: bool = False) -> None: + count: int = None, private: bool = False, + recursive: bool = False) -> None: """Give the current tab to a new or existing window if win_id given. If no win_id is given, the tab will get detached into a new window. @@ -474,6 +538,7 @@ def tab_give(self, win_id: int = None, keep: bool = False, win_id: The window ID of the window to give the current tab to. keep: If given, keep the old tab around. count: Overrides win_id (index starts at 1 for win_id=0). + recursive: Whether to move the entire subtree starting at the tab. private: If the tab should be detached into a private instance. """ if config.val.tabs.tabs_are_windows: @@ -505,13 +570,15 @@ def tab_give(self, win_id: int = None, keep: bool = False, raise cmdutils.CommandError( "The window with id {} is not private".format(win_id)) - tabbed_browser.tabopen(self._current_url()) - tabbed_browser.window().show() - - if not keep: - self._tabbed_browser.close_tab(self._current_widget(), - add_undo=False, - transfer=True) + if recursive and tabbed_browser.is_treetabbedbrowser: + self._tree_tab_give(tabbed_browser, keep) + else: + tabbed_browser.tabopen(self._current_url()) + tabbed_browser.window().show() + if not keep: + self._tabbed_browser.close_tab(self._current_widget(), + add_undo=False, + transfer=True) def _back_forward( self, *, @@ -889,43 +956,79 @@ def undo(self, window: bool = False, @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_prev(self, count=1): + def tab_prev(self, count=1, sibling=False): """Switch to the previous tab, or switch [count] tabs back. Args: count: How many tabs to switch back. + sibling: Whether to focus the previous tree sibling. """ if self._count() == 0: # Running :tab-prev after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() - count - if newidx >= 0: - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx - count + if new_idx >= 0 or config.val.tabs.wrap: + target_node = siblings[(node_idx-count) % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("First sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("First tab") + newidx = self._current_index() - count + if newidx >= 0: + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("First tab") @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_next(self, count=1): + def tab_next(self, count=1, sibling=False): """Switch to the next tab, or switch [count] tabs forward. Args: count: How many tabs to switch forward. + sibling: Whether to focus the next tree sibling. """ if self._count() == 0: # Running :tab-next after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() + count - if newidx < self._count(): - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx + count + if new_idx < len(siblings) or config.val.tabs.wrap: + target_node = siblings[new_idx % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("Last sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("Last tab") + newidx = self._current_index() + count + if newidx < self._count(): + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("Last tab") def _resolve_tab_index(self, index): """Resolve a tab index to the tabbedbrowser and tab. @@ -1005,7 +1108,8 @@ def tab_select(self, index=None, count=None): tabbed_browser.widget.setCurrentWidget(tab) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'], + @cmdutils.argument('index', choices=['last', 'parent', + 'stack-next', 'stack-prev'], completion=miscmodels.tab_focus) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_focus(self, index: Union[str, int] = None, @@ -1016,11 +1120,15 @@ def tab_focus(self, index: Union[str, int] = None, If both are given, use count. Args: - index: The tab index to focus, starting with 1. The special value - `last` focuses the last focused tab (regardless of count), - and `stack-prev`/`stack-next` traverse a stack of visited - tabs. Negative indices count from the end, such that -1 is - the last tab. + index: The tab index to focus, starting with 1. Negative indices + count from the end, such that -1 is the last tab. Special + values are: + - `last` focuses the last focused tab (regardless of + count). + - `parent` focuses the parent tab in the tree hierarchy, + if `tabs.tree_tabs` is enabled. + - `stack-prev`/`stack-next` traverse a stack of visited + tabs. count: The tab index to focus, starting with 1. no_last: Whether to avoid focusing last tab if already focused. """ @@ -1030,6 +1138,22 @@ def tab_focus(self, index: Union[str, int] = None, assert isinstance(index, str) self._tab_focus_stack(index) return + elif index == 'parent' and self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + path = node.path + if count: + if count < len(path): + path_idx = 0 - count - 1 # path[-1] is node, so shift by 1 + else: + path_idx = 1 # first non-root node + else: + path_idx = -2 # immediate parent (path[-1] is node) + + target_node = path[path_idx] + if node is target_node or target_node.value is None: + raise cmdutils.CommandError("Tab has no parent! ") + target_tab = target_node.value + index = self._tabbed_browser.widget.indexOf(target_tab) + 1 elif index is None: message.warning( "Using :tab-focus without count is deprecated, " @@ -1069,17 +1193,31 @@ def tab_move(self, index: Union[str, int] = None, count: int = None) -> None: If moving absolutely: New position (default: 0). This overrides the index argument, if given. """ + # pylint: disable=invalid-unary-operand-type + # https://github.com/PyCQA/pylint/issues/1472 if index in ["+", "-"]: # relative moving new_idx = self._current_index() delta = 1 if count is None else count - if index == "-": - new_idx -= delta - elif index == "+": # pragma: no branch - new_idx += delta - if config.val.tabs.wrap: - new_idx %= self._count() + if self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + parent = node.parent + siblings = list(parent.children) + + if len(siblings) <= 1: + return + rel_idx = siblings.index(node) + rel_idx += delta if index == '+' else - delta + rel_idx %= len(siblings) + new_idx = self._tabbed_browser.widget.indexOf( + siblings[rel_idx].value) + + else: + new_idx += delta if index == '+' else - delta + + if config.val.tabs.wrap: + new_idx %= self._count() else: # pylint: disable=else-if-used # absolute moving @@ -1102,7 +1240,34 @@ def tab_move(self, index: Union[str, int] = None, count: int = None) -> None: cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') cmdutils.check_overflow(new_idx, 'int') - self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) + + if self._tabbed_browser.is_treetabbedbrowser: + # self._tree_tab_move(new_idx) + new_idx += 1 # tree-tabs indexes start at 1 (0 is hidden root tab) + tab = self._current_widget() + + # traverse order is the same as display order + # so indexing works correctly + tree_root = self._tabbed_browser.widget.tree_root + tabs = list(tree_root.traverse(render_collapsed=False)) + target_node = tabs[new_idx] + if tab.node in target_node.path: + raise cmdutils.CommandError("Can't move tab to a descendent" + " of itself") + + new_parent = target_node.parent + # we need index relative to parent for correct placement + dest_tab = tabs[new_idx] + new_idx_relative = new_parent.children.index(dest_tab) + + tab.node.parent = None # avoid duplicate errors + siblings = list(new_parent.children) + siblings.insert(new_idx_relative, tab.node) + new_parent.children = siblings + + self._tabbed_browser.widget.tree_tab_update() + else: + self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) @@ -1899,3 +2064,123 @@ def fullscreen(self, leave=False, enter=False): log.misc.debug('state before fullscreen: {}'.format( debug.qflags_key(Qt, window.state_before_fullscreen))) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_promote(self, count=1): + """Promote a tab so it becomes next sibling of its parent. + + Observes tabs.new_position.tree.promote in positioning the tab among + new siblings. + + Args: + count: How many levels the tabs should be promoted to + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + config_position = config.val.tabs.new_position.tree.promote + try: + self._current_widget().node.promote(count, config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no parent!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_demote(self): + """Demote a tab making it children of its previous adjacent sibling. + + Observes tabs.new_position.tree.demote in positioning the tab among new + siblings. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + cur_node = self._current_widget().node + + config_position = config.val.tabs.new_position.tree.demote + try: + cur_node.demote(config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no previous sibling!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_toggle_hide(self, count=None): + """If the current tab's children are shown hide them, and vice-versa. + + This toggles the current tab's node's `collapsed` attribute. + + Args: + count: Which tab to collapse + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + tab = self._cntwidget(count) + if not tab.node.children: + return + tab.node.collapsed = not tab.node.collapsed + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_cycle_hide(self, count=1): + """Hides levels of descendents: children, grandchildren, and so on. + + Args: + count: How many levels to hide. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + while count > 0: + tab = self._current_widget() + self._tabbed_browser.cycle_hide_tab(tab.node) + count -= 1 + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_create_group(self, *name, related=False, + background=False): + """Wrapper around :open qute://treegroup/name. Correctly escapes names. + + Example: `:tree-tab-create-group Foo Bar` calls + `:open qute://treegroup/Foo%20Bar` + + Args: + name: Name of the group to create + related: whether to open as a child of current tab or under root + background: whether to open in a background tab + """ + title = ' '.join(name) + path = urllib.parse.quote(title) + if background: + self.openurl('qute://treegroup/' + path, related=related, bg=True) + else: + self.openurl('qute://treegroup/' + path, related=related, tab=True) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_suspend_children(self, count=None): + """Suspends all descendent of a tab to reduce memory usage. + + Args: + count: Target tab. + """ + tab = self._cntwidget(count) + for descendent in tab.node.traverse(): + cur_tab = descendent.value + if cur_tab and cur_tab is not tab: + cur_url = cur_tab.url().url() + if not cur_url.startswith("qute://"): + new_url = self._parse_url( + "qute://back/#" + cur_tab.title()) + cur_tab.load_url(new_url) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 25834670b11..15e4bc769e4 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -588,6 +588,18 @@ def qute_warning(url: QUrl) -> _HandlerRet: return 'text/html', src +@add_handler('treegroup') +def qute_treegroup(url): + """Handler for qute://treegroup/x. + + Makes an empty tab with a title, for use with tree-tabs as a grouping + feature. + """ + src = jinja.render('tree_group.html', + title=url.path()[1:]) + return 'text/html', src + + @add_handler('resource') def qute_resource(url: QUrl) -> _HandlerRet: """Handler for qute://resource.""" diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 655a5a336ad..76a44934e97 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -65,6 +65,7 @@ class Command: both) no_replace_variables: Don't replace variables like {url} modes: The modes the command can be executed in. + tree_tab: Whether the command is a tree-tabs command _qute_args: The saved data from @cmdutils.argument _count: The count set for the command. _instance: The object to bind 'self' to. @@ -78,7 +79,7 @@ class Command: def __init__(self, *, handler, name, instance=None, maxsplit=None, modes=None, not_modes=None, debug=False, deprecated=False, no_cmd_split=False, star_args_optional=False, scope='global', - backend=None, no_replace_variables=False): + backend=None, no_replace_variables=False, tree_tab=False): if modes is not None and not_modes is not None: raise ValueError("Only modes or not_modes can be given!") if modes is not None: @@ -107,6 +108,7 @@ def __init__(self, *, handler, name, instance=None, maxsplit=None, self.handler = handler self.no_cmd_split = no_cmd_split self.backend = backend + self.tree_tab = tree_tab self.no_replace_variables = no_replace_variables self.docparser = docutils.DocstringParser(handler) diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index 69a192f686e..16452d7b571 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -21,6 +21,7 @@ from qutebrowser.utils import usertypes from qutebrowser.misc import objects +from qutebrowser.config import config DeleteFuncType = Callable[[Sequence[str]], None] @@ -44,8 +45,9 @@ def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): hide_debug = obj.debug and not objects.args.debug hide_mode = (usertypes.KeyMode.normal not in obj.modes and not include_hidden) + hide_tree = obj.tree_tab and not config.cache['tabs.tree_tabs'] hide_ni = obj.name == 'Ni!' - if not (hide_debug or hide_mode or obj.deprecated or hide_ni): + if not (hide_tree or hide_debug or hide_mode or obj.deprecated or hide_ni): bindings = ', '.join(cmd_to_keys.get(obj.name, [])) cmdlist.append((prefix + obj.name, obj.desc, bindings)) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 0b9d669dceb..05526260e02 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2224,6 +2224,40 @@ tabs.new_position.stacking: Only applies for `next` and `prev` values of `tabs.new_position.related` and `tabs.new_position.unrelated`. +tabs.new_position.tree.new_child: + default: first + type: NewChildPosition + desc: >- + Position of new children among siblings, e.g. after calling `:open + --relative ...` or following a link. + +tabs.new_position.tree.new_sibling: + default: first + type: NewTabPosition + desc: >- + Position of siblings, e.g. after calling `:open --sibling ...`. + +tabs.new_position.tree.new_toplevel: + default: last + type: NewTabPosition + desc: >- + Position of new top-level tabs related to the topmost ancestor of current + tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. + +tabs.new_position.tree.promote: + default: next + type: NewTabPosition + desc: >- + Position at which a tab is placed among its new siblings after being + promoted with `:tree-tab-promote` + +tabs.new_position.tree.demote: + default: last + type: NewChildPosition + desc: >- + Position at which a tab is placed among its new siblings after being + demoted with `:tree-tab-demote` + tabs.padding: default: top: 0 @@ -2289,7 +2323,7 @@ tabs.title.elide: desc: Position of ellipsis in truncated title of tabs. tabs.title.format: - default: '{audio}{index}: {current_title}' + default: '{tree}{collapsed}{audio}{index}: {current_title}' type: name: FormatString fields: @@ -2307,12 +2341,16 @@ tabs.title.format: - current_url - protocol - audio + - collapsed + - tree none_ok: true desc: | Format to use for the tab title. The following placeholders are defined: * `{perc}`: Percentage as a string like `[10%]`. + * `{collapsed}`: If children tabs are hidden, the string `[...]`, empty otherwise + * `{tree}`: The ASCII tree prefix of current tab. * `{perc_raw}`: Raw percentage, e.g. `10`. * `{current_title}`: Title of the current web page. * `{title_sep}`: The string `" - "` if a title is set, empty otherwise. @@ -2348,6 +2386,8 @@ tabs.title.format_pinned: - current_url - protocol - audio + - collapsed + - tree none_ok: true desc: Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. @@ -2446,6 +2486,12 @@ tabs.wrap: type: Bool desc: Wrap when changing tabs. +tabs.tree_tabs: + default: false + type: Bool + desc: Enable tree-tabs mode. + restart: true + tabs.focus_stack_size: default: 10 type: @@ -3831,6 +3877,17 @@ bindings.default: all no-3rdparty never ;; reload tCu: config-cycle -p -u {url} content.cookies.accept all no-3rdparty never ;; reload + zH: tree-tab-promote + zL: tree-tab-demote + zJ: tab-next -s + zK: tab-prev -s + zd: tab-close -r + zg: set-cmd-text -s :tree-tab-create-group -r + zG: set-cmd-text -s :tree-tab-create-group + za: tree-tab-toggle-hide + zp: tab-focus parent + zo: set-cmd-text --space :open -tr + zO: set-cmd-text --space :open -tS insert: : edit-text : insert-text -- {primary} diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index b451744c338..a10f4aa3bb5 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1955,6 +1955,21 @@ def __init__( ('last', "At the end.")) +class NewChildPosition(String): + + """How new children are positioned.""" + + def __init__( + self, *, + none_ok: bool = False, + completions: _Completions = None, + ) -> None: + super().__init__(none_ok=none_ok, completions=completions) + self.valid_values = ValidValues( + ('first', "At the beginning."), + ('last', "At the end.")) + + class LogLevel(String): """A logging level.""" diff --git a/qutebrowser/html/tree_group.html b/qutebrowser/html/tree_group.html new file mode 100644 index 00000000000..b3717e52b6e --- /dev/null +++ b/qutebrowser/html/tree_group.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block style %} +h1, p { + margin-left: 3rem; +} + +pre { + margin-left: 6em; +} +{% endblock %} +{% block content %} +

+ {{ title }} +

+

+ Group for tree tabs +

+
+{% raw %}
+                       _.
+                _~.:'^%^ >@~.
+             ,-~              ? =*=
+           $^_`  `  , '   ,    +   -.,
+        (*-^. , *            ;'       >
+      >.      ,>    .     ' .,.,.      %-.,_ ,.-,
+      #    ' ` " - "     *    .,.      *   .^    `
+     *@!    ., *    ' '    ,    ;'   '         .  %!
+      &       "  .`      :'              `   '   . `~,
+     &    '        .`  '   '  .     '":   :          +.
+     ^      .",  ,       `      '   `   * , '   `      |
+      ]     *   .   , ""]   ..    ` . , `  , "  . . '  ,;,
+      %  '         ::,  ,   /    ,            '   ,     ;
+    .* ,*    /       *%     \  .  .  *'  `    ,    '     '.
+   ?     > .   ,      ::. :;^^.     %`      '        `     @
+  /   '   `/      `    &#@%^^      `&``   `     %;;       %
+ ;:       :%   *  *  :$%)\      '  `@%$        @%^      ,).
+ .    #    %&^     (!*^ .\,.   `      ^@%^  $#%%^  `   >
+ \           :#$%  #^&#   :   `  *      %###$%@!       &
+  |  '  *     %$#@)$*}]           `     `#@#%%^      *^
+   :     *'  *  @%&&^:$   `  '   `%%.  #$$$^^-,     7
+    &;            @#$~~   '   `     @#$%&      $,*.-
+     *...*^  .._   %$$#@!   @ .,  *&&#@
+         :..^   -   !%&@}{#&     @#$@%
+                 --_..%#%%$#&% #&%$#;:
+                        $%#^%#@@%%*&;;
+                         a%##@%%@% ;:;
+                           %####j#:::;
+                            &#%Rj;%;;:
+                            &#%%#;::;
+                            $#%##:%::
+                            "#%%#;:;
+                           ."$###:::
+                           #&$%%#:;:
+                           %&#%%#::;
+                           %&%###;::
+                           &&#%%#:;;
+                          *@&#%#};:;
+                          $#%#%%^:::
+                         *@#$#%#;::;:
+                        %%@#$####@$:;:
+                    ...%###pinusc@$%%:._____
+{% endraw %}
+
+
+{% endblock %} diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 5e34a6649fe..405e9fd285b 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -21,7 +21,7 @@ import base64 import itertools import functools -from typing import List, MutableSequence, Optional, Tuple, cast +from typing import List, MutableSequence, Optional, Tuple, cast, Union from qutebrowser.qt import machinery from qutebrowser.qt.core import (pyqtBoundSignal, pyqtSlot, QRect, QPoint, QTimer, Qt, @@ -208,7 +208,7 @@ def __init__(self, *, super().__init__(parent) # Late import to avoid a circular dependency # - browsertab -> hints -> webelem -> mainwindow -> bar -> browsertab - from qutebrowser.mainwindow import tabbedbrowser + from qutebrowser.mainwindow import treetabbedbrowser, tabbedbrowser from qutebrowser.mainwindow.statusbar import bar self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -237,8 +237,14 @@ def __init__(self, *, self.is_private = config.val.content.private_browsing or private - self.tabbed_browser: tabbedbrowser.TabbedBrowser = tabbedbrowser.TabbedBrowser( - win_id=self.win_id, private=self.is_private, parent=self) + self.tabbed_browser: Union[tabbedbrowser.TabbedBrowser, + treetabbedbrowser.TreeTabbedBrowser] + if config.val.tabs.tree_tabs: + self.tabbed_browser = treetabbedbrowser.TreeTabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) + else: + self.tabbed_browser = tabbedbrowser.TabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) self._init_command_dispatcher() @@ -510,8 +516,10 @@ def _connect_signals(self): mode_manager.keystring_updated.connect( self.status.keystring.on_keystring_updated) self.status.cmd.got_cmd[str].connect(self._commandrunner.run_safely) - self.status.cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) - self.status.cmd.returnPressed.connect(self.tabbed_browser.on_cmd_return_pressed) + self.status.cmd.got_cmd[str, int].connect( + self._commandrunner.run_safely) + self.status.cmd.returnPressed.connect( + self.tabbed_browser.on_cmd_return_pressed) self.status.cmd.got_search.connect(self._command_dispatcher.search) # key hint popup diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index da3392a7e81..61f75c2b5a1 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -22,8 +22,8 @@ import weakref import datetime import dataclasses -from typing import ( - Any, Deque, List, Mapping, MutableMapping, MutableSequence, Optional, Tuple) +from typing import (Any, Deque, List, Mapping, + MutableMapping, MutableSequence, Optional, Tuple) from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint @@ -207,6 +207,7 @@ class TabbedBrowser(QWidget): resized = pyqtSignal('QRect') current_tab_changed = pyqtSignal(browsertab.AbstractTab) new_tab = pyqtSignal(browsertab.AbstractTab, int) + is_treetabbedbrowser = False shutting_down = pyqtSignal() def __init__(self, *, win_id, private, parent=None): @@ -485,6 +486,7 @@ def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): crashed: Whether we're closing a tab with crashed renderer process. """ idx = self.widget.indexOf(tab) + if idx == -1: if crashed: return @@ -553,17 +555,26 @@ def undo(self, depth=1): entries = self.undo_stack[-depth] del self.undo_stack[-depth] + # we return the tab list because tree_tabs needs it in post_processing + new_tabs = [] for entry in reversed(entries): if use_current_tab: newtab = self._tab_by_idx(0) assert newtab is not None use_current_tab = False else: - newtab = self.tabopen(background=False, idx=entry.index) + # FIXME:typing mypy thinks this is None due to @pyqtSlot + newtab = self.tabopen( + background=False, + related=False, + idx=entry.index + ) newtab.history.private_api.deserialize(entry.history) newtab.set_pinned(entry.pinned) + new_tabs.append(newtab) newtab.setFocus() + return new_tabs @pyqtSlot('QUrl', bool) def load_url(self, url, newtab): @@ -651,7 +662,7 @@ def tabopen( if idx is None: idx = self._get_new_tab_idx(related) - self.widget.insertTab(idx, tab, "") + idx = self.widget.insertTab(idx, tab, "") if url is not None: tab.load_url(url) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index fe9ce1e0676..f2cb1765828 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -26,7 +26,7 @@ QTimer, QUrl) from qutebrowser.qt.widgets import (QTabWidget, QTabBar, QSizePolicy, QProxyStyle, QStyle, QStylePainter, QStyleOptionTab, - QCommonStyle) + QCommonStyle, QWidget) from qutebrowser.qt.gui import QIcon, QPalette, QColor from qutebrowser.utils import qtutils, objreg, utils, usertypes, log @@ -36,7 +36,6 @@ class TabWidget(QTabWidget): - """The tab widget used for TabbedBrowser. Signals: @@ -59,14 +58,14 @@ def __init__(self, win_id, parent=None): self.setStyle(TabBarStyle()) self.setTabBar(bar) bar.tabCloseRequested.connect(self.tabCloseRequested) - bar.tabMoved.connect(functools.partial( - QTimer.singleShot, 0, self.update_tab_titles)) + bar.tabMoved.connect(self.update_tab_titles) bar.currentChanged.connect(self._on_current_changed) bar.new_tab_requested.connect(self._on_new_tab_requested) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.setDocumentMode(True) self.setUsesScrollButtons(True) bar.setDrawBase(False) + self._init_config() config.instance.changed.connect(self._init_config) @@ -92,8 +91,9 @@ def tab_bar(self) -> "TabBar": assert isinstance(bar, TabBar), bar return bar - def _tab_by_idx(self, idx: int) -> Optional[browsertab.AbstractTab]: + def _tab_by_idx(self, idx: int) -> Optional[QWidget]: """Get the tab at the given index.""" + tab = self.widget(idx) if tab is not None: assert isinstance(tab, browsertab.AbstractTab), tab @@ -189,6 +189,9 @@ def get_tab_fields(self, idx): fields['perc_raw'] = tab.progress() fields['backend'] = objects.backend.name fields['private'] = ' [Private Mode] ' if tab.is_private else '' + fields['tree'] = '' + fields['collapsed'] = '' + try: if tab.audio.is_muted(): fields['audio'] = TabWidget.MUTE_STRING @@ -347,7 +350,7 @@ def tab_url(self, idx): qtutils.ensure_valid(url) return url - def update_tab_favicon(self, tab: browsertab.AbstractTab) -> None: + def update_tab_favicon(self, tab: QWidget) -> None: """Update favicon of the given tab.""" idx = self.indexOf(tab) diff --git a/qutebrowser/mainwindow/treetabbedbrowser.py b/qutebrowser/mainwindow/treetabbedbrowser.py new file mode 100644 index 00000000000..c7e10ab180d --- /dev/null +++ b/qutebrowser/mainwindow/treetabbedbrowser.py @@ -0,0 +1,350 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Giuseppe Stelluto (pinusc) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Subclass of TabbedBrowser to provide tree-tab functionality.""" + +import collections +import dataclasses +import datetime +from typing import List, Dict +from qutebrowser.qt.widgets import QSizePolicy +from qutebrowser.qt.core import pyqtSlot, QUrl + +from qutebrowser.config import config +from qutebrowser.mainwindow.tabbedbrowser import TabbedBrowser +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.browser import browsertab +from qutebrowser.misc import notree +from qutebrowser.utils import log + + +@dataclasses.dataclass +class _TreeUndoEntry(): + """Information needed for :undo.""" + + url: QUrl + history: bytes + index: int + pinned: bool + uid: int + parent_node_uid: int + children_node_uids: List[int] + local_index: int # index of the tab relative to its siblings + created_at: datetime.datetime = dataclasses.field( + default_factory=datetime.datetime.now) + + @staticmethod + def from_node(node, idx): + """Make a TreeUndoEntry from a Node.""" + url = node.value.url() + try: + history_data = node.value.history.private_api.serialize() + except browsertab.WebTabError: + history_data = [] + pinned = node.value.data.pinned + uid = node.uid + parent_uid = node.parent.uid + children = [n.uid for n in node.children] + local_idx = node.index + return _TreeUndoEntry(url=url, + history=history_data, + index=idx, + pinned=pinned, + uid=uid, + parent_node_uid=parent_uid, + children_node_uids=children, + local_index=local_idx) + + +class TreeTabbedBrowser(TabbedBrowser): + """Subclass of TabbedBrowser to provide tree-tab functionality. + + Extends TabbedBrowser methods (mostly tabopen, undo, and _remove_tab) so + that the internal tree is updated after every action. + + Provides methods to hide and show subtrees, and to cycle visibility. + """ + + is_treetabbedbrowser = True + + def __init__(self, *, win_id, private, parent=None): + super().__init__(win_id=win_id, private=private, parent=parent) + self.is_treetabbedbrowser = True + self.widget = TreeTabWidget(win_id, parent=self) + self.widget.tabCloseRequested.connect(self.on_tab_close_requested) + self.widget.new_tab_requested.connect(self.tabopen) + self.widget.currentChanged.connect(self._on_current_changed) + self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._reset_stack_counters() + + def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): + """Handle children positioning after a tab is removed.""" + node = tab.node + # FIXME after the fixme in _add_undo_entry is resolved, no need + # to save descendents + descendents = tuple(node.traverse(render_collapsed=True)) + + super()._remove_tab(tab, add_undo=False, new_undo=False, + crashed=crashed) + + if not tab.url().isEmpty() and tab.url().isValid() and add_undo: + idx = self.widget.indexOf(tab) + self._add_undo_entry(tab, idx, new_undo) + + parent = node.parent + + if node.collapsed: + # node will already be removed from tree + # but we need to manually close the tab processes + for descendent in descendents: + tab = descendent.value + tab.private_api.shutdown() + tab.deleteLater() + tab.layout().unwrap() + elif parent: + siblings = list(parent.children) + children = node.children + + if children: + next_node = children[0] + + for n in children[1:]: + n.parent = next_node + + # swap nodes + node_idx = siblings.index(node) + siblings[node_idx] = next_node + + parent.children = tuple(siblings) + node.children = () + + node.parent = None + self.widget.tree_tab_update() + + def _add_undo_entry(self, tab, idx, new_undo): + """Save undo entry with tree information. + + This function was removed in tabbedbrowser, but it is still useful here because + the mechanism is quite a bit more complex + """ + # TODO see if it's possible to remove duplicate code from + # super()._add_undo_entry + try: + history_data = tab.history.private_api.serialize() + except browsertab.WebTabError: + pass # special URL + else: + node = tab.node + uid = node.uid + parent_uid = node.parent.uid + if not node.collapsed: + children = [n.uid for n in node.children] + local_idx = node.index + entry = _TreeUndoEntry(url=tab.url(), + history=history_data, + index=idx, + pinned=tab.data.pinned, + uid=uid, + parent_node_uid=parent_uid, + children_node_uids=children, + local_index=local_idx) + if new_undo or not self.undo_stack: + self.undo_stack.append([entry]) + else: + self.undo_stack[-1].append(entry) + else: + entries = [] + for descendent in node.traverse(notree.TraverseOrder.POST_R): + entries.append(_TreeUndoEntry.from_node(descendent, 0)) + # ensure descendent is not later saved as child as well + descendent.parent = None # FIXME: Find a way not to change + # the tree + if new_undo: + self.undo_stack.append(entries) + else: + self.undo_stack[-1] += entries + + def undo(self, depth=1): + """Undo removing of a tab or tabs.""" + # TODO find a way to remove dupe code + # probably by getting entries from undo stack, THEN calling super + # then post-processing the entries + + # save entries before super().undo() pops them + entries = list(self.undo_stack[-depth]) + new_tabs = super().undo(depth) + + for entry, tab in zip(reversed(entries), new_tabs): + if not isinstance(entry, _TreeUndoEntry): + continue + root = self.widget.tree_root + uid = entry.uid + parent_uid = entry.parent_node_uid + parent_node = root.get_descendent_by_uid(parent_uid) + if not parent_node: + parent_node = root + + children = [] + for child_uid in entry.children_node_uids: + child_node = root.get_descendent_by_uid(child_uid) + children.append(child_node) + tab.node.parent = None # Remove the node from the tree + tab.node = notree.Node(tab, parent_node, + children, uid) + + # correctly reposition the tab + local_idx = entry.local_index + if tab.node.parent: # should always be true + new_siblings = list(tab.node.parent.children) + new_siblings.remove(tab.node) + new_siblings.insert(local_idx, tab.node) + tab.node.parent.children = new_siblings + + self.widget.tree_tab_update() + + @pyqtSlot('QUrl') + @pyqtSlot('QUrl', bool) + @pyqtSlot('QUrl', bool, bool) + def tabopen( + self, url: QUrl = None, + background: bool = None, + related: bool = True, + sibling: bool = False, + idx: int = None, + ) -> browsertab.AbstractTab: + """Open a new tab with a given url. + + Args: + related: Whether to set the tab as a child of the currently focused + tab. Follows `tabs.new_position.tree.related`. + sibling: Whether to set the tab as a sibling of the currently + focused tab. Follows `tabs.new_position.tree.sibling`. + + """ + # pylint: disable=arguments-differ + # we save this now because super.tabopen also resets the focus + cur_tab = self.widget.currentWidget() + tab = super().tabopen(url, background, related, idx) + + tab.node.parent = self.widget.tree_root + if cur_tab is None or tab is cur_tab: + self.widget.tree_tab_update() + return tab + + # get pos + if related: + pos = config.val.tabs.new_position.tree.new_child + parent = cur_tab.node + # pos can only be first, last + elif sibling: + pos = config.val.tabs.new_position.tree.new_sibling + parent = cur_tab.node.parent + # pos can be first, last, prev, next + else: + pos = config.val.tabs.new_position.tree.new_toplevel + parent = self.widget.tree_root + + self._position_tab(cur_tab, tab, pos, parent, sibling, related, background) + + return tab + + def _position_tab( + self, + cur_tab: browsertab.AbstractTab, + tab: browsertab.AbstractTab, + pos: str, + parent: notree.Node, + sibling: bool = False, + related: bool = True, + background: bool = None, + ) -> None: + toplevel = not sibling and not related + siblings = list(parent.children) + if tab.node in siblings: # true if parent is tree_root + # remove it and add it later in the right position + siblings.remove(tab.node) + + if pos == 'first': + rel_idx = 0 + if config.val.tabs.new_position.stacking and related: + rel_idx += self._tree_tab_child_rel_idx + self._tree_tab_child_rel_idx += 1 + siblings.insert(rel_idx, tab.node) + elif pos in ['prev', 'next'] and (sibling or toplevel): + # pivot is the tab relative to which 'prev' or 'next' apply + # it is always a member of 'siblings' + pivot = cur_tab.node if sibling else cur_tab.node.path[1] + direction = -1 if pos == 'prev' else 1 + rel_idx = 0 if pos == 'prev' else 1 + tgt_idx = siblings.index(pivot) + rel_idx + if config.val.tabs.new_position.stacking: + if sibling: + tgt_idx += self._tree_tab_sibling_rel_idx + self._tree_tab_sibling_rel_idx += direction + elif toplevel: + tgt_idx += self._tree_tab_toplevel_rel_idx + self._tree_tab_toplevel_rel_idx += direction + siblings.insert(tgt_idx, tab.node) + else: # position == 'last' + siblings.append(tab.node) + parent.children = siblings + self.widget.tree_tab_update() + if not background: + self._reset_stack_counters() + + def _reset_stack_counters(self): + self._tree_tab_child_rel_idx = 0 + self._tree_tab_sibling_rel_idx = 0 + self._tree_tab_toplevel_rel_idx = 0 + + @pyqtSlot(int) + def _on_current_changed(self, idx): + super()._on_current_changed(idx) + self._reset_stack_counters() + + def cycle_hide_tab(self, node): + """Utility function for tree_tab_cycle_hide command.""" + # height = node.height # height is always rel_height + if node.collapsed: + node.collapsed = False + for descendent in node.traverse(render_collapsed=True): + descendent.collapsed = False + return + + def rel_depth(n): + return n.depth - node.depth + + levels: Dict[int, list] = collections.defaultdict(list) + for d in node.traverse(render_collapsed=False): + r_depth = rel_depth(d) + levels[r_depth].append(d) + + # Remove highest level because it's leaves (or already collapsed) + del levels[max(levels.keys())] + + target = 0 + for level in sorted(levels, reverse=True): + nodes = levels[level] + if not all(n.collapsed or not n.children for n in nodes): + target = level + break + for n in levels[target]: + if not n.collapsed and n.children: + n.collapsed = True diff --git a/qutebrowser/mainwindow/treetabwidget.py b/qutebrowser/mainwindow/treetabwidget.py new file mode 100644 index 00000000000..12de178c2ec --- /dev/null +++ b/qutebrowser/mainwindow/treetabwidget.py @@ -0,0 +1,105 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Giuseppe Stelluto (pinusc) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Extension of TabWidget for tree-tab functionality.""" + +from qutebrowser.mainwindow.tabwidget import TabWidget +from qutebrowser.misc.notree import Node +from qutebrowser.utils import log + + +class TreeTabWidget(TabWidget): + """Tab widget used in TabbedBrowser, with tree-functionality. + + Handles correct rendering of the tree as a tab field, and correct + positioning of tabs according to tree structure. + """ + + def __init__(self, win_id, parent=None): + # root of the tab tree, common for all tabs in the window + self.tree_root = Node(None) + super().__init__(win_id, parent) + self.tabBar().tabMoved.disconnect(self.update_tab_titles) + + def _init_config(self): + super()._init_config() + # For tree-tabs + self.update_tab_titles() # Must also be called when deactivating + self.tree_tab_update() + + def get_tab_fields(self, idx): + """Add tree field data to normal tab field data.""" + fields = super().get_tab_fields(idx) + + tab = self.widget(idx) + fields['collapsed'] = '[...] ' if tab.node.collapsed else '' + + # we remove the first two chars because every tab is child of tree + # root and that gets rendered as well + rendered_tree = self.tree_root.render() + try: + pre, _ = rendered_tree[idx+1] + tree_prefix = pre[2:] + except IndexError: # window or first tab are not initialized yet + tree_prefix = "" + log.misc.error("tree_prefix is empty!") + + fields['tree'] = tree_prefix + return fields + + def update_tree_tab_positions(self): + """Update tab positions according to the tree structure.""" + nodes = self.tree_root.traverse(render_collapsed=False) + for idx, node in enumerate(nodes): + if idx > 0: + cur_idx = self.indexOf(node.value) + self.tabBar().moveTab(cur_idx, idx-1) + + def update_tree_tab_visibility(self): + """Hide collapsed tabs and show uncollapsed ones. + + Sync the internal tree to the tabs the user can actually see. + """ + for node in self.tree_root.traverse(): + if node.value is None: + continue + if any(ancestor.collapsed for ancestor in node.path[:-1]): + if self.indexOf(node.value) != -1: + # node should be hidden but is shown + cur_tab = node.value + idx = self.indexOf(cur_tab) + if idx != -1: + self.removeTab(idx) + else: + if self.indexOf(node.value) == -1: + # node should be shown but is hidden + parent = node.parent + tab = node.value + name = tab.title() + icon = tab.icon() + if node.parent is not None: + parent_idx = self.indexOf(node.parent.value) + self.insertTab(parent_idx + 1, tab, icon, name) + tab.node.parent = parent # insertTab resets node + + def tree_tab_update(self): + """Update titles and positions.""" + self.update_tree_tab_visibility() + self.update_tree_tab_positions() + self.update_tab_titles() diff --git a/qutebrowser/misc/notree.py b/qutebrowser/misc/notree.py new file mode 100644 index 00000000000..9d180ad4ce7 --- /dev/null +++ b/qutebrowser/misc/notree.py @@ -0,0 +1,354 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2019 Giuseppe Stelluto (pinusc) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tree library for tree-tabs. + +The fundamental unit is the Node class. + +Create a tree with with Node(value, parent): +root = Node('foo') +child = Node('bar', root) +child2 = Node('baz', root) +child3 = Node('lorem', child) + +You can also assign parent after instantiation, or even reassign it: +child4 = Node('ipsum') +child4.parent = root + +Assign children: +child.children = [] +child2.children = [child4, child3] +child3.parent +> Node('foo/bar/baz') + +Render a tree with render_tree(root_node): +render_tree(root) + +> ('', 'foo') +> ('├─', 'bar') +> ('│ ├─', 'lorem') +> ('│ └─', 'ipsum') +> ('└─', 'baz') +""" +import enum +from typing import Optional, TypeVar, Sequence, List, Tuple, Iterable, Generic +import itertools + +# For Node.render +CORNER = '└─' +INTERSECTION = '├─' +PIPE = '│' + + +class TreeError(RuntimeError): + """Exception used for tree-related errors.""" + + +class TraverseOrder(enum.Enum): + """To be used as argument to traverse(). + + Implemented orders are pre-order and post-order. + Attributes: + PRE: pre-order (parents before children). Same as in Node.render + POST: children of a node are always yield before their parent. + POST_R: Like POST, but children are yield in reverse order + """ + + PRE = 'pre-order' + POST = 'post-order' + POST_R = 'post-order-reverse' + + +uid_gen = itertools.count(0) + +# generic type of value held by Node +T = TypeVar('T') # pylint: disable=invalid-name + + +class Node(Generic[T]): + """Fundamental unit of notree library. + + Attributes: + value: The element (ususally a tab) the node represents + parent: Node's parent. + children: Node's children elements. + siblings: Children of parent node that are not self. + path: List of nodes from root of tree to self value, parent, and + children can all be set by user. Everything else will be updated + accordingly, so that if `node.parent = root_node`, then `node in + root_node.children` will be True. + """ + + sep: str = '/' + __parent: Optional['Node[T]'] = None + # this is a global 'static' class attribute + + def __init__(self, + value: T, + parent: Optional['Node[T]'] = None, + childs: Sequence['Node[T]'] = (), + uid: Optional[int] = None) -> None: + if uid is not None: + self.__uid = uid + else: + self.__uid = next(uid_gen) + + self.value = value + # set initial values so there's no need for AttributeError checks + self.__parent: Optional['Node[T]'] = None + self.__children: List['Node[T]'] = [] + + # For render memoization + self.__modified = False + self.__set_modified() # not the same as line above + self.__rendered: Optional[List[Tuple[str, 'Node[T]']]] = None + + if parent: + self.parent = parent # calls setter + if childs: + self.children = childs # this too + + self.__collapsed = False + + @property + def uid(self) -> int: + return self.__uid + + @property + def parent(self) -> Optional['Node[T]']: + return self.__parent + + @parent.setter + def parent(self, value: 'Node[T]') -> None: + """Set parent property. Also adds self to value.children.""" + # pylint: disable=protected-access + assert(value is None or isinstance(value, Node)) + if self.__parent: + self.__parent.__disown(self) + self.__parent = None + if value is not None: + value.__add_child(self) + self.__parent = value + self.__set_modified() + + @property + def children(self) -> Sequence['Node[T]']: + return tuple(self.__children) + + @children.setter + def children(self, value: Sequence['Node[T]']) -> None: + """Set children property, preserving order. + + Also sets n.parent = self for n in value. Does not allow duplicates. + """ + seen = set(value) + if len(seen) != len(value): + raise TreeError("A duplicate item is present in in %r" % value) + new_children = list(value) + for child in new_children: + if child.parent is not self: + child.parent = self + self.__children = new_children + self.__set_modified() + + @property + def path(self) -> List['Node[T]']: + """Get a list of all nodes from the root node to self.""" + if self.parent is None: + return [self] + else: + return self.parent.path + [self] + + @property + def depth(self) -> int: + """Get the number of nodes between self and the root node.""" + return len(self.path) - 1 + + @property + def index(self) -> int: + """Get self's position among its siblings (self.parent.children).""" + if self.parent is not None: + return self.parent.children.index(self) + else: + raise TreeError('Node has no parent.') + + @property + def collapsed(self) -> bool: + return self.__collapsed + + @collapsed.setter + def collapsed(self, val: bool) -> None: + self.__collapsed = val + self.__set_modified() + + def __set_modified(self) -> None: + """If self is modified, every ancestor is modified as well.""" + for node in self.path: + node.__modified = True # pylint: disable=protected-access + + def render(self) -> List[Tuple[str, 'Node[T]']]: + """Render a tree with ascii symbols. + + Tabs appear in the same order as in traverse() with TraverseOrder.PRE + Args: + node; the root of the tree to render + + Return: list of tuples where the first item is the symbol, + and the second is the node it refers to + """ + if not self.__modified and self.__rendered is not None: + return self.__rendered + + result = [('', self)] + for child in self.children: + if child.children: + subtree = child.render() + if child is not self.children[-1]: + subtree = [(PIPE + ' ' + c, n) for c, n in subtree] + char = INTERSECTION + else: + subtree = [(' ' + c, n) for c, n in subtree] + char = CORNER + subtree[0] = (char, subtree[0][1]) + if child.collapsed: + result += [subtree[0]] + else: + result += subtree + else: + if child is self.children[-1]: + result.append((CORNER, child)) + else: + result.append((INTERSECTION, child)) + self.__modified = False + self.__rendered = list(result) + return list(result) + + def traverse(self, order: TraverseOrder = TraverseOrder.PRE, + render_collapsed: bool = True) -> Iterable['Node']: + """Generator for all descendants of `self`. + + Args: + order: a TraverseOrder object. See TraverseOrder documentation. + render_collapsed: whether to yield children of collapsed nodes + Even if render_collapsed is False, collapsed nodes are be rendered. + It's their children that won't. + """ + if order == TraverseOrder.PRE: + yield self + + if self.collapsed and not render_collapsed: + if order != TraverseOrder.PRE: + yield self + return + + f = reversed if order is TraverseOrder.POST_R else lambda x: x + for child in f(self.children): + if render_collapsed or not child.collapsed: + yield from child.traverse(order, render_collapsed) + else: + yield child + if order in [TraverseOrder.POST, TraverseOrder.POST_R]: + yield self + + def __add_child(self, node: 'Node[T]') -> None: + if node not in self.__children: + self.__children.append(node) + + def __disown(self, value: 'Node[T]') -> None: + self.__set_modified() + if value in self.__children: + self.__children.remove(value) + + def get_descendent_by_uid(self, uid: int) -> Optional['Node[T]']: + """Return descendent identified by the provided uid. + + Returns None if there is no such descendent. + + Args: + uid: The uid of the node to return + """ + for descendent in self.traverse(): + if descendent.uid == uid: + return descendent + return None + + def promote(self, times: int = 1, to: str = 'first') -> None: + """Makes self a child of its grandparent, i.e. sibling of its parent. + + Args: + times: How many levels to promote the tab to. to: One of 'next', + 'prev', 'first', 'last'. Determines the position among siblings + after being promoted. 'next' and 'prev' are relative to the current + parent. + + """ + if to not in ['first', 'last', 'next', 'prev']: + raise Exception("Invalid value supplied for 'to': " + to) + position = {'first': 0, 'last': -1}.get(to, None) + diff = {'next': 1, 'prev': 0}.get(to, 1) + count = times + while count > 0: + if self.parent is None or self.parent.parent is None: + raise TreeError("Tab has no parent!") + grandparent = self.parent.parent + if position is not None: + idx = position + else: # diff is necessarily not none + idx = self.parent.index + diff + self.parent = None + + siblings = list(grandparent.children) + if idx != -1: + siblings.insert(idx, self) + else: + siblings.append(self) + grandparent.children = siblings + count -= 1 + + def demote(self, to: str = 'last') -> None: + """Demote a tab making it a child of its previous adjacent sibling.""" + if self.parent is None or self.parent.children is None: + raise TreeError("Tab has no siblings!") + siblings = list(self.parent.children) + + # we want previous node in the same subtree as current node + rel_idx = siblings.index(self) - 1 + + if rel_idx >= 0: + parent = siblings[rel_idx] + new_siblings = list(parent.children) + position = {'first': 0, 'last': -1}.get(to, -1) + if position == 0: + new_siblings.insert(0, self) + else: + new_siblings.append(self) + parent.children = new_siblings + else: + raise TreeError("Tab has no previous sibling!") + + def __repr__(self) -> str: + try: + value = str(self.value.url().url()) # type: ignore + except Exception: + value = str(self.value) + return "" % (self.__uid, value) + + def __str__(self) -> str: + # return "" % self.value + return str(self.value) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 3608568766c..c8445e4ad4d 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -243,6 +243,9 @@ def _save_tab(self, tab, active, with_history=True): active: Whether the tab is currently active. with_history: Include the tab's history. """ + # FIXME understand why this happens + if tab is None: + return {} data: _JsonType = {'history': []} if active: data['active'] = True @@ -287,13 +290,29 @@ def _save_all(self, *, only_window=None, with_private=False, with_history=True): if getattr(active_window, 'win_id', None) == win_id: win_data['active'] = True win_data['geometry'] = bytes(main_window.saveGeometry()) - win_data['tabs'] = [] if tabbed_browser.is_private: win_data['private'] = True - for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.widget.currentIndex() - win_data['tabs'].append(self._save_tab(tab, active, - with_history=with_history)) + + if tabbed_browser.is_treetabbedbrowser: + # a dict where keys are node UIDs, and values are dicts + # with tab data (the result of _save_tab) and a list of + # children UIDs + tree_data = {} + root_node = tabbed_browser.widget.tree_root + for i, node in enumerate(root_node.traverse(), -1): + node_data = {} + active = i == tabbed_browser.widget.currentIndex() + node_data['tab'] = self._save_tab(node.value, active) + node_data['children'] = [c.uid for c in node.children] + node_data['collapsed'] = node.collapsed + tree_data[node.uid] = node_data + win_data['tree'] = tree_data + else: + win_data['tabs'] = [] + for i, tab in enumerate(tabbed_browser.widgets()): + active = i == tabbed_browser.widget.currentIndex() + win_data['tabs'].append(self._save_tab(tab, active, + with_history=with_history)) data['windows'].append(win_data) return data @@ -461,6 +480,45 @@ def _load_tab(self, new_tab, data): # noqa: C901 except ValueError as e: raise SessionError(e) + def _load_tree(self, tabbed_browser, tree_data): + tree_keys = list(tree_data.keys()) + if not tree_keys: + return None + + root_data = tree_data.get(tree_keys[0]) + if root_data is None: + return None + + root_node = tabbed_browser.widget.tree_root + tab_to_focus = None + index = -1 + + def recursive_load_node(uid): + nonlocal tab_to_focus + nonlocal index + index += 1 + node_data = tree_data[uid] + children_uids = node_data['children'] + + if tree_data[uid]['tab'].get('active'): + tab_to_focus = index + + tab_data = node_data['tab'] + new_tab = tabbed_browser.tabopen(background=False) + self._load_tab(new_tab, tab_data) + + new_tab.node.parent = root_node + children = [recursive_load_node(uid) for uid in children_uids] + new_tab.node.children = children + new_tab.node.collapsed = node_data['collapsed'] + return new_tab.node + + for child_uid in root_data['children']: + child = recursive_load_node(child_uid) + child.parent = root_node + + return tab_to_focus + def _load_window(self, win): """Turn yaml data into windows.""" window = mainwindow.MainWindow(geometry=win['geometry'], @@ -468,13 +526,29 @@ def _load_window(self, win): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) tab_to_focus = None - for i, tab in enumerate(win['tabs']): - new_tab = tabbed_browser.tabopen(background=False) - self._load_tab(new_tab, tab) - if tab.get('active', False): - tab_to_focus = i - if new_tab.data.pinned: - new_tab.set_pinned(True) + + # plain_tabs is used in case the saved session contains a tree and + # tree-tabs is not enabled, or if the saved session contains normal + # tabs + plain_tabs = win.get('tabs', None) + if win.get('tree'): + if tabbed_browser.is_treetabbedbrowser: + tree_data = win.get('tree') + tab_to_focus = self._load_tree(tabbed_browser, tree_data) + tabbed_browser.widget.tree_tab_update() + else: + tree = win.get('tree') + plain_tabs = [tree[i]['tab'] for i in tree if + tree[i]['tab']] + if plain_tabs: + for i, tab in enumerate(plain_tabs): + new_tab = tabbed_browser.tabopen(background=False) + self._load_tab(new_tab, tab) + if tab.get('active', False): + tab_to_focus = i + if new_tab.data.pinned: + new_tab.set_pinned(True) + if tab_to_focus is not None: tabbed_browser.widget.setCurrentIndex(tab_to_focus) diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 8ce8ba699a1..75bddfa0f8f 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -214,12 +214,15 @@ def open_path(quteproc, server, path): path = path.replace('(port)', str(server.port)) new_tab = False + related_tab = False new_bg_tab = False new_window = False private = False as_url = False wait = True + related_tab_suffix = ' in a new related tab' + related_background_tab_suffix = ' in a new related background tab' new_tab_suffix = ' in a new tab' new_bg_tab_suffix = ' in a new background tab' new_window_suffix = ' in a new window' @@ -231,6 +234,14 @@ def open_path(quteproc, server, path): if path.endswith(new_tab_suffix): path = path[:-len(new_tab_suffix)] new_tab = True + elif path.endswith(related_tab_suffix): + path = path[:-len(related_tab_suffix)] + new_tab = True + related_tab = True + elif path.endswith(related_background_tab_suffix): + path = path[:-len(related_background_tab_suffix)] + new_bg_tab = True + related_tab = True elif path.endswith(new_bg_tab_suffix): path = path[:-len(new_bg_tab_suffix)] new_bg_tab = True @@ -249,9 +260,9 @@ def open_path(quteproc, server, path): else: break - quteproc.open_path(path, new_tab=new_tab, new_bg_tab=new_bg_tab, - new_window=new_window, private=private, as_url=as_url, - wait=wait) + quteproc.open_path(path, related_tab=related_tab, new_tab=new_tab, + new_bg_tab=new_bg_tab, new_window=new_window, + private=private, as_url=as_url, wait=wait) @bdd.when(bdd.parsers.parse("I set {opt} to {value}")) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index c3194b8397e..a59a05ec222 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -651,16 +651,17 @@ def temp_setting(self, opt, value): self.set_setting(opt, old_value) def open_path(self, path, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, port=None, - https=False, wait=True): + related_tab=False, new_window=False, private=False, + as_url=False, port=None, https=False, wait=True): """Open the given path on the local webserver in qutebrowser.""" url = self.path_to_url(path, port=port, https=https) self.open_url(url, new_tab=new_tab, new_bg_tab=new_bg_tab, - new_window=new_window, private=private, as_url=as_url, - wait=wait) + related_tab=related_tab, new_window=new_window, + private=private, as_url=as_url, wait=wait) def open_url(self, url, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, wait=True): + related_tab=False, new_window=False, private=False, + as_url=False, wait=True): """Open the given url in qutebrowser.""" if sum(1 for opt in [new_tab, new_bg_tab, new_window, private, as_url] if opt) > 1: @@ -670,9 +671,15 @@ def open_url(self, url, *, new_tab=False, new_bg_tab=False, self.send_cmd(url, invalid=True) line = None elif new_tab: - line = self.send_cmd(':open -t ' + url) + if related_tab: + line = self.send_cmd(':open -t -r ' + url) + else: + line = self.send_cmd(':open -t ' + url) elif new_bg_tab: - line = self.send_cmd(':open -b ' + url) + if related_tab: + line = self.send_cmd(':open -b -r ' + url) + else: + line = self.send_cmd(':open -b ' + url) elif new_window: line = self.send_cmd(':open -w ' + url) elif private: diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 341d1de2a61..2dfb15916e9 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -337,6 +337,7 @@ class FakeCommand: hide: bool = False debug: bool = False deprecated: bool = False + tree_tab: bool = False completion: Any = None maxsplit: int = None takes_count: Callable[[], bool] = lambda: False diff --git a/tests/unit/misc/test_notree.py b/tests/unit/misc/test_notree.py new file mode 100644 index 00000000000..95f92f1a68f --- /dev/null +++ b/tests/unit/misc/test_notree.py @@ -0,0 +1,308 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2019 Florian Bruhin (The-Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . +"""Tests for misc.notree library.""" +import pytest + +from qutebrowser.misc.notree import TreeError, Node, TraverseOrder + + +@pytest.fixture +def tree(): + """Return an example tree. + + n1 + ├─n2 + │ ├─n4 + │ └─n5 + └─n3 + ├─n6 + │ ├─n7 + │ ├─n8 + │ └─n9 + │ └─n10 + └─n11 + """ + # these are actually used because they appear in expected strings + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) + n5 = Node('n5', n2) + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) + n8 = Node('n8', n6) + n9 = Node('n9', n6) + n10 = Node('n10', n9) + n11 = Node('n11', n3) + return n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 + + +@pytest.fixture +def node(tree): + return tree[0] + + +def test_creation(): + node = Node('foo') + assert node.value == 'foo' + + child = Node('bar', node) + assert child.parent == node + assert node.children == (child, ) + + +def test_attach_parent(): + n1 = Node('n1', None, []) + print(n1.children) + n2 = Node('n2', n1) + n3 = Node('n3') + + n2.parent = n3 + assert n2.parent == n3 + assert n3.children == (n2, ) + assert n1.children == () + + +def test_duplicate_child(): + p = Node('n1') + try: + c1 = Node('c1', p) + c2 = Node('c2', p) + p.children = [c1, c1, c2] + raise AssertionError("Can add duplicate child") + except TreeError: + pass + finally: + if len(p.children) == 3: + raise AssertionError("Can add duplicate child") + + +def test_replace_parent(): + p1 = Node('foo') + p2 = Node('bar') + _ = Node('_', p2) + c = Node('baz', p1) + c.parent = p2 + assert c.parent is p2 + assert c not in p1.children + assert c in p2.children + + +def test_replace_children(tree): + n2 = tree[1] + n3 = tree[2] + n6 = tree[5] + n11 = tree[10] + n3.children = [n11] + n2.children = (n6, ) + n2.children + assert n6.parent is n2 + assert n6 in n2.children + assert n11.parent is n3 + assert n11 in n3.children + assert n6 not in n3.children + assert len(n3.children) == 1 + + +def test_promote_to_first(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='first') + assert n6.parent is n1 + assert n1.children[0] is n6 + + +def test_promote_to_last(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='last') + assert n6.parent is n1 + assert n1.children[-1] is n6 + + +def test_promote_to_prev(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='prev') + assert n6.parent is n1 + assert n1.children[1] is n6 + + +def test_promote_to_next(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='next') + assert n6.parent is n1 + assert n1.children[2] is n6 + + +def test_demote_to_first(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='first') + assert n11.parent is n6 + assert n6.children[0] is n11 + + +def test_demote_to_last(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='last') + assert n11.parent is n6 + assert n6.children[-1] is n11 + + +def test_traverse(node): + len_traverse = len(list(node.traverse())) + len_render = len(node.render()) + assert len_traverse == len_render + + +def test_traverse_postorder(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n4, n5, n2, n7, n8, n10, n9, n6, n11, n3, n1] + + +def test_traverse_postorder_r(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST_R)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n11, n10, n9, n8, n7, n6, n3, n5, n4, n2, n1] + + +def test_render_tree(node): + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3', + ' ├─n6', + ' │ ├─n7', + ' │ ├─n8', + ' │ └─n9', + ' │ └─n10', + ' └─n11' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + +def test_uid(node): + uids = set() + for n in node.traverse(): + assert n not in uids + uids.add(n.uid) + # pylint: disable=unused-variable + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) # noqa: F841 + n5 = Node('n5', n2) # noqa: F841 + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) # noqa: F841 + n8 = Node('n8', n6) # noqa: F841 + n9 = Node('n9', n6) + n10 = Node('n10', n9) # noqa: F841 + n11 = Node('n11', n3) + # pylint: enable=unused-variable + for n in n1.traverse(): + assert n not in uids + uids.add(n.uid) + + n11_uid = n11.uid + assert n1.get_descendent_by_uid(n11_uid) is n11 + assert node.get_descendent_by_uid(n11_uid) is None + + +def test_collapsed(node): + pre_collapsed_traverse = list(node.traverse()) + to_collapse = node.children[1] + + # collapse + to_collapse.collapsed = True + assert to_collapse.collapsed is True + for n in node.traverse(render_collapsed=False): + assert to_collapse not in n.path[:-1] + + assert list(to_collapse.traverse(render_collapsed=False)) == [to_collapse] + + assert list(node.traverse()) == pre_collapsed_traverse + + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + # uncollapse + to_collapse.collapsed = False + + assert any(n for n in node.traverse(render_collapsed=False) if to_collapse + in n.path[:-1]) + + +def test_memoization(node): + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].parent = None + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + n2 = Node('ntest', parent=node) + assert node._Node__modified is True + assert n2._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].children[1].parent = None + assert node._Node__modified is True + assert node.children[0]._Node__modified is True + node.render() + assert node._Node__modified is False