mirror of
https://github.com/gburd/nix-config.git
synced 2024-11-20 10:36:25 +00:00
2456 lines
93 KiB
Diff
2456 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
|