nix-config/_/overlays/qutebrowser-tree-tabs.diff

2455 lines
93 KiB
Diff

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:
<Ctrl-E>: edit-text
<Shift-Ins>: 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 %}
+<h1>
+ {{ title }}
+</h1>
+<p>
+ <em>Group for tree tabs</em>
+</p>
+<pre>
+{% raw %}
+ _.
+ _~.:'^%^ >@~.
+ ,-~ ? =*=
+ $^_` ` , ' , + -.,
+ (*-^. , * ;' >
+ >. ,> . ' .,.,. %-.,_ ,.-,
+ # ' ` " - " * .,. * .^ `
+ *@! ., * ' ' , ;' ' . %!
+ & " .` :' ` ' . `~,
+ & ' .` ' ' . '": : +.
+ ^ .", , ` ' ` * , ' ` |
+ ] * . , ""] .. ` . , ` , " . . ' ,;,
+ % ' ::, , / , ' , ;
+ .* ,* / *% \ . . *' ` , ' '.
+ ? > . , ::. :;^^. %` ' ` @
+ / ' `/ ` &#@%^^ `&`` ` %;; %
+ ;: :% * * :$%)\ ' `@%$ @%^ ,).
+ . # %&^ (!*^ .\,. ` ^@%^ $#%%^ ` >
+ \ :#$% #^&# : ` * %###$%@! &
+ | ' * %$#@)$*}] ` `#@#%%^ *^
+ : *' * @%&&^:$ ` ' `%%. #$$$^^-, 7
+ &; @#$~~ ' ` @#$%& $,*.-
+ *...*^ .._ %$$#@! @ ., *&&#@
+ :..^ - !%&@}{#& @#$@%
+ --_..%#%%$#&% #&%$#;:
+ $%#^%#@@%%*&;;
+ a%##@%%@% ;:;
+ %####j#:::;
+ &#%Rj;%;;:
+ &#%%#;::;
+ $#%##:%::
+ "#%%#;:;
+ ."$###:::
+ #&$%%#:;:
+ %&#%%#::;
+ %&%###;::
+ &&#%%#:;;
+ *@&#%#};:;
+ $#%#%%^:::
+ *@#$#%#;::;:
+ %%@#$####@$:;:
+ ...%###pinusc@$%%:._____
+{% endraw %}
+
+</pre>
+{% 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) <giuseppe@gstelluto.com>
+#
+# 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 <https://www.gnu.org/licenses/>.
+
+"""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) <giuseppe@gstelluto.com>
+#
+# 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 <https://www.gnu.org/licenses/>.
+
+"""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) <giuseppe@gstelluto.com>
+#
+# 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 <https://www.gnu.org/licenses/>.
+
+"""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 "<Node -%d- '%s'>" % (self.__uid, value)
+
+ def __str__(self) -> str:
+ # return "<Node '%s'>" % 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) <me@the-compiler.org>
+#
+# 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 <https://www.gnu.org/licenses/>.
+"""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