From 09261e63f804b89eaf462da7e6d6b137304701cb Mon Sep 17 00:00:00 2001 From: Gabriel Fontes Date: Thu, 27 Jul 2023 00:58:50 -0300 Subject: [PATCH] home/qutebrowser: add tree tabs patch --- overlays/default.nix | 16 +- overlays/qutebrowser-tree-tabs.diff | 2785 +++++++++++++++++++++++++++ 2 files changed, 2799 insertions(+), 2 deletions(-) create mode 100644 overlays/qutebrowser-tree-tabs.diff diff --git a/overlays/default.nix b/overlays/default.nix index 4199aee2..bdf19080 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -3,7 +3,8 @@ let addPatches = pkg: patches: pkg.overrideAttrs (oldAttrs: { patches = (oldAttrs.patches or [ ]) ++ patches; }); -in { +in +{ # For every flake input, aliases 'pkgs.inputs.${flake}' to # 'inputs.${flake}.packages.${pkgs.system}' or # 'inputs.${flake}.legacyPackages.${pkgs.system}' or @@ -37,7 +38,7 @@ in { # nix = addPatches prev.nix [ ./nix-make-installables-expr-context.patch ]; # https://gitlab.com/CalcProgrammer1/OpenRGB/-/issues/2918 - openrgb = addPatches prev.openrgb [ ./openrgb-all-devices.diff ]; + openrgb = addPatches prev.openrgb [ ./openrgb-all-devices.diff ]; xdg-utils-spawn-terminal = addPatches prev.xdg-utils [ ./xdg-open-spawn-terminal.diff ]; @@ -56,6 +57,17 @@ in { # Sane default values and crash avoidance (https://github.com/k-vernooy/trekscii/pull/1) trekscii = addPatches prev.trekscii [ ./trekscii.patch ]; + qutebrowser-qt6 = prev.qutebrowser-qt6.overrideAttrs (oldAttrs: { + version = "unstable-2023-07-25"; + src = final.fetchFromGitHub { + owner = "qutebrowser"; + repo = "qutebrowser"; + rev = "6d84462d68ec1ce4cde459297e0c52ad3c2bf29f"; + hash = "sha256-oAjhzTeZ7TNBR6lxBpNdDGJHnTdnWU16lZtlrvZhfE0="; + }; + patches = (oldAttrs.patches or [ ]) ++ [ ./qutebrowser-tree-tabs.diff ]; + }); + scgit = prev.cgit-pink.overrideAttrs (_: { pname = "scgit"; version = "0.1"; diff --git a/overlays/qutebrowser-tree-tabs.diff b/overlays/qutebrowser-tree-tabs.diff new file mode 100644 index 00000000..59caf99b --- /dev/null +++ b/overlays/qutebrowser-tree-tabs.diff @@ -0,0 +1,2785 @@ +diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc +index 7f0abc71d..43df2a768 100644 +--- a/doc/help/commands.asciidoc ++++ b/doc/help/commands.asciidoc +@@ -130,6 +130,12 @@ possible to run or bind multiple commands by separating them with `;;`. + |<>|Switch to the previous tab, or switch [count] tabs back. + |<>|Select tab by index or url/title best match. + |<>|Take a tab from another window. ++|<>|Wrapper around :open qute://treegroup/name. Correctly escapes names. ++|<>|Hides levels of descendents: children, grandchildren, and so on. ++|<>|Demote a tab making it children of its previous adjacent sibling. ++|<>|Promote a tab so it becomes next sibling of its parent. ++|<>|Suspends all descendent of a tab to reduce memory usage. ++|<>|If the current tab's children are shown hide them, and vice-versa. + |<>|Unbind a keychain. + |<>|Re-open the last closed tab(s) or window. + |<>|Show version information. +@@ -930,7 +936,7 @@ Do nothing. + + [[open]] + === open +-Syntax: +:open [*--related*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ ++Syntax: +:open [*--related*] [*--sibling*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ + + Open a URL in the current/[count]th tab. + +@@ -942,6 +948,8 @@ If the URL contains newlines, each line gets opened in its own tab. + ==== optional arguments + * +*-r*+, +*--related*+: If opening a new tab, position the tab as related to the current one (like clicking on a link). + ++* +*-S*+, +*--sibling*+: If opening a new tab, position the as a sibling of the current one. ++ + * +*-b*+, +*--bg*+: Open in a new background tab. + * +*-t*+, +*--tab*+: Open in a new tab. + * +*-w*+, +*--window*+: Open in a new window. +@@ -1394,7 +1402,7 @@ Duplicate the current tab. + + [[tab-close]] + === tab-close +-Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*]+ ++Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*] [*--recursive*]+ + + Close the current/[count]th tab. + +@@ -1404,6 +1412,7 @@ Close the current/[count]th tab. + * +*-o*+, +*--opposite*+: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. + + * +*-f*+, +*--force*+: Avoid confirmation for pinned tabs. ++* +*-r*+, +*--recursive*+: Close all descendents (tree-tabs) as well as current tab + + ==== count + The tab index to close +@@ -1417,10 +1426,14 @@ Select the tab given as argument/[count]. + If neither count nor index are given, it behaves like tab-next. If both are given, use count. + + ==== positional arguments +-* +'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. + + + ==== optional arguments +@@ -1431,7 +1444,7 @@ The tab index to focus, starting with 1. + + [[tab-give]] + === tab-give +-Syntax: +:tab-give [*--keep*] [*--private*] ['win-id']+ ++Syntax: +:tab-give [*--keep*] [*--private*] [*--recursive*] ['win-id']+ + + Give the current tab to a new or existing window if win_id given. + +@@ -1443,6 +1456,7 @@ If no win_id is given, the tab will get detached into a new window. + ==== optional arguments + * +*-k*+, +*--keep*+: If given, keep the old tab around. + * +*-p*+, +*--private*+: If the tab should be detached into a private instance. ++* +*-r*+, +*--recursive*+: Whether to move the entire subtree starting at the tab. + + ==== count + Overrides win_id (index starts at 1 for win_id=0). +@@ -1475,8 +1489,13 @@ The tab index to mute or unmute + + [[tab-next]] + === tab-next ++Syntax: +:tab-next [*--sibling*]+ ++ + Switch to the next tab, or switch [count] tabs forward. + ++==== optional arguments ++* +*-s*+, +*--sibling*+: Whether to focus the next tree sibling. ++ + ==== count + How many tabs to switch forward. + +@@ -1504,8 +1523,13 @@ The tab index to pin or unpin + + [[tab-prev]] + === tab-prev ++Syntax: +:tab-prev [*--sibling*]+ ++ + Switch to the previous tab, or switch [count] tabs back. + ++==== optional arguments ++* +*-s*+, +*--sibling*+: Whether to focus the previous tree sibling. ++ + ==== count + How many tabs to switch back. + +@@ -1543,6 +1567,59 @@ Take a tab from another window. + ==== note + * This command does not split arguments after the last argument and handles quotes literally. + ++[[tree-tab-create-group]] ++=== tree-tab-create-group ++Syntax: +:tree-tab-create-group [*--related*] [*--background*] 'name' ['name' ...]+ ++ ++Wrapper around :open qute://treegroup/name. Correctly escapes names. ++ ++Example: `:tree-tab-create-group Foo Bar` calls `:open qute://treegroup/Foo%20Bar` ++ ++==== positional arguments ++* +'name'+: Name of the group to create ++ ++==== optional arguments ++* +*-r*+, +*--related*+: whether to open as a child of current tab or under root ++* +*-b*+, +*--background*+: whether to open in a background tab ++ ++[[tree-tab-cycle-hide]] ++=== tree-tab-cycle-hide ++Hides levels of descendents: children, grandchildren, and so on. ++ ++==== count ++How many levels to hide. ++ ++[[tree-tab-demote]] ++=== tree-tab-demote ++Demote a tab making it children of its previous adjacent sibling. ++ ++Observes tabs.new_position.tree.demote in positioning the tab among new siblings. ++ ++[[tree-tab-promote]] ++=== tree-tab-promote ++Promote a tab so it becomes next sibling of its parent. ++ ++Observes tabs.new_position.tree.promote in positioning the tab among new siblings. ++ ++==== count ++How many levels the tabs should be promoted to ++ ++[[tree-tab-suspend-children]] ++=== tree-tab-suspend-children ++Suspends all descendent of a tab to reduce memory usage. ++ ++==== count ++Target tab. ++ ++[[tree-tab-toggle-hide]] ++=== tree-tab-toggle-hide ++If the current tab's children are shown hide them, and vice-versa. ++ ++This toggles the current tab's node's `collapsed` attribute. ++ ++==== count ++Which tab to collapse ++ + [[unbind]] + === unbind + Syntax: +:unbind [*--mode* 'mode'] 'key'+ +diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc +index 7767f1eea..9547cf24c 100644 +--- a/doc/help/settings.asciidoc ++++ b/doc/help/settings.asciidoc +@@ -333,6 +333,11 @@ + |<>|Switch between tabs using the mouse wheel. + |<>|Position of new tabs opened from another tab. + |<>|Stack related tabs on top of each other when opened consecutively. ++|<>|Position at which a tab is placed among its new siblings after being demoted with `:tree-tab-demote` ++|<>|Position of new children among siblings, e.g. after calling `:open --relative ...` or following a link. ++|<>|Position of siblings, e.g. after calling `:open --sibling ...`. ++|<>|Position of new top-level tabs related to the topmost ancestor of current tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. ++|<>|Position at which a tab is placed among its new siblings after being promoted with `:tree-tab-promote` + |<>|Position of new tabs which are not opened from another tab. + |<>|Padding (in pixels) around text for tabs. + |<>|Force pinned tabs to stay at fixed URL. +@@ -749,6 +754,17 @@ Default: + * +pass:[yp]+: +pass:[yank pretty-url]+ + * +pass:[yt]+: +pass:[yank title]+ + * +pass:[yy]+: +pass:[yank]+ ++* +pass:[zG]+: +pass:[set-cmd-text -s :tree-tab-create-group]+ ++* +pass:[zH]+: +pass:[tree-tab-promote]+ ++* +pass:[zJ]+: +pass:[tab-next -s]+ ++* +pass:[zK]+: +pass:[tab-prev -s]+ ++* +pass:[zL]+: +pass:[tree-tab-demote]+ ++* +pass:[zO]+: +pass:[set-cmd-text --space :open -tS]+ ++* +pass:[za]+: +pass:[tree-tab-toggle-hide]+ ++* +pass:[zd]+: +pass:[tab-close -r]+ ++* +pass:[zg]+: +pass:[set-cmd-text -s :tree-tab-create-group -r]+ ++* +pass:[zo]+: +pass:[set-cmd-text --space :open -tr]+ ++* +pass:[zp]+: +pass:[tab-focus parent]+ + * +pass:[{{]+: +pass:[navigate prev -t]+ + * +pass:[}}]+: +pass:[navigate next -t]+ + - +pass:[passthrough]+: +@@ -4405,6 +4421,77 @@ Type: <> + + Default: +pass:[true]+ + ++[[tabs.new_position.tree.demote]] ++=== tabs.new_position.tree.demote ++Position at which a tab is placed among its new siblings after being demoted with `:tree-tab-demote` ++ ++Type: <> ++ ++Valid values: ++ ++ * +first+: At the beginning. ++ * +last+: At the end. ++ ++Default: +pass:[last]+ ++ ++[[tabs.new_position.tree.new_child]] ++=== tabs.new_position.tree.new_child ++Position of new children among siblings, e.g. after calling `:open --relative ...` or following a link. ++ ++Type: <> ++ ++Valid values: ++ ++ * +first+: At the beginning. ++ * +last+: At the end. ++ ++Default: +pass:[first]+ ++ ++[[tabs.new_position.tree.new_sibling]] ++=== tabs.new_position.tree.new_sibling ++Position of siblings, e.g. after calling `:open --sibling ...`. ++ ++Type: <> ++ ++Valid values: ++ ++ * +prev+: Before the current tab. ++ * +next+: After the current tab. ++ * +first+: At the beginning. ++ * +last+: At the end. ++ ++Default: +pass:[first]+ ++ ++[[tabs.new_position.tree.new_toplevel]] ++=== tabs.new_position.tree.new_toplevel ++Position of new top-level tabs related to the topmost ancestor of current tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. ++ ++Type: <> ++ ++Valid values: ++ ++ * +prev+: Before the current tab. ++ * +next+: After the current tab. ++ * +first+: At the beginning. ++ * +last+: At the end. ++ ++Default: +pass:[last]+ ++ ++[[tabs.new_position.tree.promote]] ++=== tabs.new_position.tree.promote ++Position at which a tab is placed among its new siblings after being promoted with `:tree-tab-promote` ++ ++Type: <> ++ ++Valid values: ++ ++ * +prev+: Before the current tab. ++ * +next+: After the current tab. ++ * +first+: At the beginning. ++ * +last+: At the end. ++ ++Default: +pass:[next]+ ++ + [[tabs.new_position.unrelated]] + === tabs.new_position.unrelated + Position of new tabs which are not opened from another tab. +@@ -4545,6 +4632,8 @@ 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. +@@ -4564,7 +4653,7 @@ The following placeholders are defined: + + Type: <> + +-Default: +pass:[{audio}{index}: {current_title}]+ ++Default: +pass:[{tree}{collapsed}{audio}{index}: {current_title}]+ + + [[tabs.title.format_pinned]] + === tabs.title.format_pinned +@@ -4583,6 +4672,15 @@ Type: <> + + Default: +pass:[true]+ + ++[[tabs.tree_tabs]] ++=== tabs.tree_tabs ++Enable tree-tabs mode. ++This setting requires a restart. ++ ++Type: <> ++ ++Default: +pass:[false]+ ++ + [[tabs.undo_stack_size]] + === tabs.undo_stack_size + Number of closed tabs (per window) and closed windows to remember for :undo (-1 for no maximum). +@@ -4842,6 +4940,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v + When setting from a string, pass a json-like list, e.g. `["one", "two"]`. + |ListOrValue|A list of values, or a single value. + |LogLevel|A logging level. ++|NewChildPosition|How new children are positioned. + |NewTabPosition|How new tabs are positioned. + |Padding|Setting for paddings around elements. + |Perc|A percentage. +diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py +index b6cc303cf..78548d08b 100644 +--- a/qutebrowser/browser/browsertab.py ++++ b/qutebrowser/browser/browsertab.py +@@ -39,6 +39,8 @@ if TYPE_CHECKING: + 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"] +@@ -1051,6 +1053,11 @@ class AbstractTab(QWidget): + 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 7efb69511..6e1b99059 100644 +--- a/qutebrowser/browser/commands.py ++++ b/qutebrowser/browser/commands.py +@@ -7,6 +7,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 +@@ -21,7 +22,7 @@ from qutebrowser.keyinput import modeman, keyutils + 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 + +@@ -99,6 +100,7 @@ class CommandDispatcher: + background: bool = False, + window: bool = False, + related: bool = False, ++ sibling: bool = False, + private: Optional[bool] = None, + ) -> None: + """Helper function to open a page. +@@ -110,6 +112,7 @@ class CommandDispatcher: + 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 +@@ -122,10 +125,16 @@ class CommandDispatcher: + 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) +@@ -207,7 +216,8 @@ class CommandDispatcher: + "{!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: +@@ -223,17 +233,17 @@ class CommandDispatcher: + 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: +@@ -242,15 +252,37 @@ class CommandDispatcher: + 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') +@@ -275,8 +307,9 @@ class CommandDispatcher: + @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. +@@ -290,6 +323,8 @@ class CommandDispatcher: + 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. +@@ -308,8 +343,8 @@ class CommandDispatcher: + 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: +@@ -448,11 +483,40 @@ class CommandDispatcher: + 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. +@@ -461,6 +525,7 @@ class CommandDispatcher: + 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: +@@ -492,13 +557,15 @@ class CommandDispatcher: + 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, *, +@@ -876,43 +943,79 @@ class CommandDispatcher: + + @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. +@@ -992,7 +1095,8 @@ class CommandDispatcher: + 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, +@@ -1003,11 +1107,15 @@ class CommandDispatcher: + 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. + """ +@@ -1017,6 +1125,22 @@ class CommandDispatcher: + 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, " +@@ -1056,17 +1180,31 @@ class CommandDispatcher: + 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 +@@ -1089,7 +1227,34 @@ class CommandDispatcher: + 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) +@@ -1885,3 +2050,123 @@ class CommandDispatcher: + + 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 dae862b8b..ff8394899 100644 +--- a/qutebrowser/browser/qutescheme.py ++++ b/qutebrowser/browser/qutescheme.py +@@ -582,6 +582,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 5e4281474..79f875541 100644 +--- a/qutebrowser/commands/command.py ++++ b/qutebrowser/commands/command.py +@@ -52,6 +52,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. +@@ -65,7 +66,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: +@@ -94,6 +95,7 @@ class Command: + 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 492e1b2e5..4957dc6c9 100644 +--- a/qutebrowser/completion/models/util.py ++++ b/qutebrowser/completion/models/util.py +@@ -8,6 +8,7 @@ from typing import Callable, Sequence + + from qutebrowser.utils import usertypes + from qutebrowser.misc import objects ++from qutebrowser.config import config + + + DeleteFuncType = Callable[[Sequence[str]], None] +@@ -31,8 +32,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 0b9d669dc..05526260e 100644 +--- a/qutebrowser/config/configdata.yml ++++ b/qutebrowser/config/configdata.yml +@@ -2224,6 +2224,40 @@ tabs.new_position.stacking: + Only applies for `next` and `prev` values of `tabs.new_position.related` + and `tabs.new_position.unrelated`. + ++tabs.new_position.tree.new_child: ++ default: first ++ type: NewChildPosition ++ desc: >- ++ Position of new children among siblings, e.g. after calling `:open ++ --relative ...` or following a link. ++ ++tabs.new_position.tree.new_sibling: ++ default: first ++ type: NewTabPosition ++ desc: >- ++ Position of siblings, e.g. after calling `:open --sibling ...`. ++ ++tabs.new_position.tree.new_toplevel: ++ default: last ++ type: NewTabPosition ++ desc: >- ++ Position of new top-level tabs related to the topmost ancestor of current ++ tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. ++ ++tabs.new_position.tree.promote: ++ default: next ++ type: NewTabPosition ++ desc: >- ++ Position at which a tab is placed among its new siblings after being ++ promoted with `:tree-tab-promote` ++ ++tabs.new_position.tree.demote: ++ default: last ++ type: NewChildPosition ++ desc: >- ++ Position at which a tab is placed among its new siblings after being ++ demoted with `:tree-tab-demote` ++ + tabs.padding: + default: + top: 0 +@@ -2289,7 +2323,7 @@ tabs.title.elide: + desc: Position of ellipsis in truncated title of tabs. + + tabs.title.format: +- default: '{audio}{index}: {current_title}' ++ default: '{tree}{collapsed}{audio}{index}: {current_title}' + type: + name: FormatString + fields: +@@ -2307,12 +2341,16 @@ tabs.title.format: + - current_url + - protocol + - audio ++ - collapsed ++ - tree + none_ok: true + desc: | + Format to use for the tab title. + The following placeholders are defined: + + * `{perc}`: Percentage as a string like `[10%]`. ++ * `{collapsed}`: If children tabs are hidden, the string `[...]`, empty otherwise ++ * `{tree}`: The ASCII tree prefix of current tab. + * `{perc_raw}`: Raw percentage, e.g. `10`. + * `{current_title}`: Title of the current web page. + * `{title_sep}`: The string `" - "` if a title is set, empty otherwise. +@@ -2348,6 +2386,8 @@ tabs.title.format_pinned: + - current_url + - protocol + - audio ++ - collapsed ++ - tree + none_ok: true + desc: Format to use for the tab title for pinned tabs. The same placeholders + like for `tabs.title.format` are defined. +@@ -2446,6 +2486,12 @@ tabs.wrap: + type: Bool + desc: Wrap when changing tabs. + ++tabs.tree_tabs: ++ default: false ++ type: Bool ++ desc: Enable tree-tabs mode. ++ restart: true ++ + tabs.focus_stack_size: + default: 10 + type: +@@ -3831,6 +3877,17 @@ bindings.default: + all no-3rdparty never ;; reload + tCu: config-cycle -p -u {url} content.cookies.accept + all no-3rdparty never ;; reload ++ zH: tree-tab-promote ++ zL: tree-tab-demote ++ zJ: tab-next -s ++ zK: tab-prev -s ++ zd: tab-close -r ++ zg: set-cmd-text -s :tree-tab-create-group -r ++ zG: set-cmd-text -s :tree-tab-create-group ++ za: tree-tab-toggle-hide ++ zp: tab-focus parent ++ zo: set-cmd-text --space :open -tr ++ zO: set-cmd-text --space :open -tS + insert: + : edit-text + : insert-text -- {primary} +diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py +index e789437d3..41f139e14 100644 +--- a/qutebrowser/config/configtypes.py ++++ b/qutebrowser/config/configtypes.py +@@ -1942,6 +1942,21 @@ class NewTabPosition(String): + ('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 000000000..b3717e52b +--- /dev/null ++++ b/qutebrowser/html/tree_group.html +@@ -0,0 +1,65 @@ ++{% extends "base.html" %} ++{% block style %} ++h1, p { ++ margin-left: 3rem; ++} ++ ++pre { ++ margin-left: 6em; ++} ++{% endblock %} ++{% block content %} ++

++ {{ title }} ++

++

++ Group for tree tabs ++

++
++{% raw %}
++                       _.
++                _~.:'^%^ >@~.
++             ,-~              ? =*=
++           $^_`  `  , '   ,    +   -.,
++        (*-^. , *            ;'       >
++      >.      ,>    .     ' .,.,.      %-.,_ ,.-,
++      #    ' ` " - "     *    .,.      *   .^    `
++     *@!    ., *    ' '    ,    ;'   '         .  %!
++      &       "  .`      :'              `   '   . `~,
++     &    '        .`  '   '  .     '":   :          +.
++     ^      .",  ,       `      '   `   * , '   `      |
++      ]     *   .   , ""]   ..    ` . , `  , "  . . '  ,;,
++      %  '         ::,  ,   /    ,            '   ,     ;
++    .* ,*    /       *%     \  .  .  *'  `    ,    '     '.
++   ?     > .   ,      ::. :;^^.     %`      '        `     @
++  /   '   `/      `    &#@%^^      `&``   `     %;;       %
++ ;:       :%   *  *  :$%)\      '  `@%$        @%^      ,).
++ .    #    %&^     (!*^ .\,.   `      ^@%^  $#%%^  `   >
++ \           :#$%  #^&#   :   `  *      %###$%@!       &
++  |  '  *     %$#@)$*}]           `     `#@#%%^      *^
++   :     *'  *  @%&&^:$   `  '   `%%.  #$$$^^-,     7
++    &;            @#$~~   '   `     @#$%&      $,*.-
++     *...*^  .._   %$$#@!   @ .,  *&&#@
++         :..^   -   !%&@}{#&     @#$@%
++                 --_..%#%%$#&% #&%$#;:
++                        $%#^%#@@%%*&;;
++                         a%##@%%@% ;:;
++                           %####j#:::;
++                            &#%Rj;%;;:
++                            &#%%#;::;
++                            $#%##:%::
++                            "#%%#;:;
++                           ."$###:::
++                           #&$%%#:;:
++                           %&#%%#::;
++                           %&%###;::
++                           &&#%%#:;;
++                          *@&#%#};:;
++                          $#%#%%^:::
++                         *@#$#%#;::;:
++                        %%@#$####@$:;:
++                    ...%###pinusc@$%%:._____
++{% endraw %}
++
++
++{% endblock %} +diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py +index da951da1b..a6cd570e8 100644 +--- a/qutebrowser/mainwindow/mainwindow.py ++++ b/qutebrowser/mainwindow/mainwindow.py +@@ -8,7 +8,7 @@ import binascii + 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, +@@ -195,7 +195,7 @@ class MainWindow(QWidget): + 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) +@@ -224,8 +224,14 @@ class MainWindow(QWidget): + + 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() +@@ -497,8 +503,10 @@ class MainWindow(QWidget): + 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 85f683133..17c4732f9 100644 +--- a/qutebrowser/mainwindow/tabbedbrowser.py ++++ b/qutebrowser/mainwindow/tabbedbrowser.py +@@ -9,8 +9,8 @@ import functools + 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 +@@ -194,6 +194,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): +@@ -481,6 +482,7 @@ class TabbedBrowser(QWidget): + crashed: Whether we're closing a tab with crashed renderer process. + """ + idx = self.widget.indexOf(tab) ++ + if idx == -1: + if crashed: + return +@@ -549,17 +551,26 @@ class TabbedBrowser(QWidget): + 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): +@@ -647,7 +658,7 @@ class TabbedBrowser(QWidget): + + 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 2e90c46c4..a7e92daa4 100644 +--- a/qutebrowser/mainwindow/tabwidget.py ++++ b/qutebrowser/mainwindow/tabwidget.py +@@ -13,7 +13,7 @@ from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint, + 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 +@@ -23,7 +23,6 @@ from qutebrowser.browser import browsertab + + + class TabWidget(QTabWidget): +- + """The tab widget used for TabbedBrowser. + + Signals: +@@ -46,14 +45,14 @@ class TabWidget(QTabWidget): + 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) + +@@ -79,8 +78,9 @@ class TabWidget(QTabWidget): + 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 +@@ -176,6 +176,9 @@ class TabWidget(QTabWidget): + 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 +@@ -334,7 +337,7 @@ class TabWidget(QTabWidget): + 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 000000000..c7e10ab18 +--- /dev/null ++++ b/qutebrowser/mainwindow/treetabbedbrowser.py +@@ -0,0 +1,350 @@ ++# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: ++ ++# Copyright 2014-2018 Giuseppe Stelluto (pinusc) ++# ++# This file is part of qutebrowser. ++# ++# qutebrowser is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# qutebrowser is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with qutebrowser. If not, see . ++ ++"""Subclass of TabbedBrowser to provide tree-tab functionality.""" ++ ++import collections ++import dataclasses ++import datetime ++from typing import List, Dict ++from qutebrowser.qt.widgets import QSizePolicy ++from qutebrowser.qt.core import pyqtSlot, QUrl ++ ++from qutebrowser.config import config ++from qutebrowser.mainwindow.tabbedbrowser import TabbedBrowser ++from qutebrowser.mainwindow.treetabwidget import TreeTabWidget ++from qutebrowser.browser import browsertab ++from qutebrowser.misc import notree ++from qutebrowser.utils import log ++ ++ ++@dataclasses.dataclass ++class _TreeUndoEntry(): ++ """Information needed for :undo.""" ++ ++ url: QUrl ++ history: bytes ++ index: int ++ pinned: bool ++ uid: int ++ parent_node_uid: int ++ children_node_uids: List[int] ++ local_index: int # index of the tab relative to its siblings ++ created_at: datetime.datetime = dataclasses.field( ++ default_factory=datetime.datetime.now) ++ ++ @staticmethod ++ def from_node(node, idx): ++ """Make a TreeUndoEntry from a Node.""" ++ url = node.value.url() ++ try: ++ history_data = node.value.history.private_api.serialize() ++ except browsertab.WebTabError: ++ history_data = [] ++ pinned = node.value.data.pinned ++ uid = node.uid ++ parent_uid = node.parent.uid ++ children = [n.uid for n in node.children] ++ local_idx = node.index ++ return _TreeUndoEntry(url=url, ++ history=history_data, ++ index=idx, ++ pinned=pinned, ++ uid=uid, ++ parent_node_uid=parent_uid, ++ children_node_uids=children, ++ local_index=local_idx) ++ ++ ++class TreeTabbedBrowser(TabbedBrowser): ++ """Subclass of TabbedBrowser to provide tree-tab functionality. ++ ++ Extends TabbedBrowser methods (mostly tabopen, undo, and _remove_tab) so ++ that the internal tree is updated after every action. ++ ++ Provides methods to hide and show subtrees, and to cycle visibility. ++ """ ++ ++ is_treetabbedbrowser = True ++ ++ def __init__(self, *, win_id, private, parent=None): ++ super().__init__(win_id=win_id, private=private, parent=parent) ++ self.is_treetabbedbrowser = True ++ self.widget = TreeTabWidget(win_id, parent=self) ++ self.widget.tabCloseRequested.connect(self.on_tab_close_requested) ++ self.widget.new_tab_requested.connect(self.tabopen) ++ self.widget.currentChanged.connect(self._on_current_changed) ++ self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide) ++ self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) ++ self._reset_stack_counters() ++ ++ def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): ++ """Handle children positioning after a tab is removed.""" ++ node = tab.node ++ # FIXME after the fixme in _add_undo_entry is resolved, no need ++ # to save descendents ++ descendents = tuple(node.traverse(render_collapsed=True)) ++ ++ super()._remove_tab(tab, add_undo=False, new_undo=False, ++ crashed=crashed) ++ ++ if not tab.url().isEmpty() and tab.url().isValid() and add_undo: ++ idx = self.widget.indexOf(tab) ++ self._add_undo_entry(tab, idx, new_undo) ++ ++ parent = node.parent ++ ++ if node.collapsed: ++ # node will already be removed from tree ++ # but we need to manually close the tab processes ++ for descendent in descendents: ++ tab = descendent.value ++ tab.private_api.shutdown() ++ tab.deleteLater() ++ tab.layout().unwrap() ++ elif parent: ++ siblings = list(parent.children) ++ children = node.children ++ ++ if children: ++ next_node = children[0] ++ ++ for n in children[1:]: ++ n.parent = next_node ++ ++ # swap nodes ++ node_idx = siblings.index(node) ++ siblings[node_idx] = next_node ++ ++ parent.children = tuple(siblings) ++ node.children = () ++ ++ node.parent = None ++ self.widget.tree_tab_update() ++ ++ def _add_undo_entry(self, tab, idx, new_undo): ++ """Save undo entry with tree information. ++ ++ This function was removed in tabbedbrowser, but it is still useful here because ++ the mechanism is quite a bit more complex ++ """ ++ # TODO see if it's possible to remove duplicate code from ++ # super()._add_undo_entry ++ try: ++ history_data = tab.history.private_api.serialize() ++ except browsertab.WebTabError: ++ pass # special URL ++ else: ++ node = tab.node ++ uid = node.uid ++ parent_uid = node.parent.uid ++ if not node.collapsed: ++ children = [n.uid for n in node.children] ++ local_idx = node.index ++ entry = _TreeUndoEntry(url=tab.url(), ++ history=history_data, ++ index=idx, ++ pinned=tab.data.pinned, ++ uid=uid, ++ parent_node_uid=parent_uid, ++ children_node_uids=children, ++ local_index=local_idx) ++ if new_undo or not self.undo_stack: ++ self.undo_stack.append([entry]) ++ else: ++ self.undo_stack[-1].append(entry) ++ else: ++ entries = [] ++ for descendent in node.traverse(notree.TraverseOrder.POST_R): ++ entries.append(_TreeUndoEntry.from_node(descendent, 0)) ++ # ensure descendent is not later saved as child as well ++ descendent.parent = None # FIXME: Find a way not to change ++ # the tree ++ if new_undo: ++ self.undo_stack.append(entries) ++ else: ++ self.undo_stack[-1] += entries ++ ++ def undo(self, depth=1): ++ """Undo removing of a tab or tabs.""" ++ # TODO find a way to remove dupe code ++ # probably by getting entries from undo stack, THEN calling super ++ # then post-processing the entries ++ ++ # save entries before super().undo() pops them ++ entries = list(self.undo_stack[-depth]) ++ new_tabs = super().undo(depth) ++ ++ for entry, tab in zip(reversed(entries), new_tabs): ++ if not isinstance(entry, _TreeUndoEntry): ++ continue ++ root = self.widget.tree_root ++ uid = entry.uid ++ parent_uid = entry.parent_node_uid ++ parent_node = root.get_descendent_by_uid(parent_uid) ++ if not parent_node: ++ parent_node = root ++ ++ children = [] ++ for child_uid in entry.children_node_uids: ++ child_node = root.get_descendent_by_uid(child_uid) ++ children.append(child_node) ++ tab.node.parent = None # Remove the node from the tree ++ tab.node = notree.Node(tab, parent_node, ++ children, uid) ++ ++ # correctly reposition the tab ++ local_idx = entry.local_index ++ if tab.node.parent: # should always be true ++ new_siblings = list(tab.node.parent.children) ++ new_siblings.remove(tab.node) ++ new_siblings.insert(local_idx, tab.node) ++ tab.node.parent.children = new_siblings ++ ++ self.widget.tree_tab_update() ++ ++ @pyqtSlot('QUrl') ++ @pyqtSlot('QUrl', bool) ++ @pyqtSlot('QUrl', bool, bool) ++ def tabopen( ++ self, url: QUrl = None, ++ background: bool = None, ++ related: bool = True, ++ sibling: bool = False, ++ idx: int = None, ++ ) -> browsertab.AbstractTab: ++ """Open a new tab with a given url. ++ ++ Args: ++ related: Whether to set the tab as a child of the currently focused ++ tab. Follows `tabs.new_position.tree.related`. ++ sibling: Whether to set the tab as a sibling of the currently ++ focused tab. Follows `tabs.new_position.tree.sibling`. ++ ++ """ ++ # pylint: disable=arguments-differ ++ # we save this now because super.tabopen also resets the focus ++ cur_tab = self.widget.currentWidget() ++ tab = super().tabopen(url, background, related, idx) ++ ++ tab.node.parent = self.widget.tree_root ++ if cur_tab is None or tab is cur_tab: ++ self.widget.tree_tab_update() ++ return tab ++ ++ # get pos ++ if related: ++ pos = config.val.tabs.new_position.tree.new_child ++ parent = cur_tab.node ++ # pos can only be first, last ++ elif sibling: ++ pos = config.val.tabs.new_position.tree.new_sibling ++ parent = cur_tab.node.parent ++ # pos can be first, last, prev, next ++ else: ++ pos = config.val.tabs.new_position.tree.new_toplevel ++ parent = self.widget.tree_root ++ ++ self._position_tab(cur_tab, tab, pos, parent, sibling, related, background) ++ ++ return tab ++ ++ def _position_tab( ++ self, ++ cur_tab: browsertab.AbstractTab, ++ tab: browsertab.AbstractTab, ++ pos: str, ++ parent: notree.Node, ++ sibling: bool = False, ++ related: bool = True, ++ background: bool = None, ++ ) -> None: ++ toplevel = not sibling and not related ++ siblings = list(parent.children) ++ if tab.node in siblings: # true if parent is tree_root ++ # remove it and add it later in the right position ++ siblings.remove(tab.node) ++ ++ if pos == 'first': ++ rel_idx = 0 ++ if config.val.tabs.new_position.stacking and related: ++ rel_idx += self._tree_tab_child_rel_idx ++ self._tree_tab_child_rel_idx += 1 ++ siblings.insert(rel_idx, tab.node) ++ elif pos in ['prev', 'next'] and (sibling or toplevel): ++ # pivot is the tab relative to which 'prev' or 'next' apply ++ # it is always a member of 'siblings' ++ pivot = cur_tab.node if sibling else cur_tab.node.path[1] ++ direction = -1 if pos == 'prev' else 1 ++ rel_idx = 0 if pos == 'prev' else 1 ++ tgt_idx = siblings.index(pivot) + rel_idx ++ if config.val.tabs.new_position.stacking: ++ if sibling: ++ tgt_idx += self._tree_tab_sibling_rel_idx ++ self._tree_tab_sibling_rel_idx += direction ++ elif toplevel: ++ tgt_idx += self._tree_tab_toplevel_rel_idx ++ self._tree_tab_toplevel_rel_idx += direction ++ siblings.insert(tgt_idx, tab.node) ++ else: # position == 'last' ++ siblings.append(tab.node) ++ parent.children = siblings ++ self.widget.tree_tab_update() ++ if not background: ++ self._reset_stack_counters() ++ ++ def _reset_stack_counters(self): ++ self._tree_tab_child_rel_idx = 0 ++ self._tree_tab_sibling_rel_idx = 0 ++ self._tree_tab_toplevel_rel_idx = 0 ++ ++ @pyqtSlot(int) ++ def _on_current_changed(self, idx): ++ super()._on_current_changed(idx) ++ self._reset_stack_counters() ++ ++ def cycle_hide_tab(self, node): ++ """Utility function for tree_tab_cycle_hide command.""" ++ # height = node.height # height is always rel_height ++ if node.collapsed: ++ node.collapsed = False ++ for descendent in node.traverse(render_collapsed=True): ++ descendent.collapsed = False ++ return ++ ++ def rel_depth(n): ++ return n.depth - node.depth ++ ++ levels: Dict[int, list] = collections.defaultdict(list) ++ for d in node.traverse(render_collapsed=False): ++ r_depth = rel_depth(d) ++ levels[r_depth].append(d) ++ ++ # Remove highest level because it's leaves (or already collapsed) ++ del levels[max(levels.keys())] ++ ++ target = 0 ++ for level in sorted(levels, reverse=True): ++ nodes = levels[level] ++ if not all(n.collapsed or not n.children for n in nodes): ++ target = level ++ break ++ for n in levels[target]: ++ if not n.collapsed and n.children: ++ n.collapsed = True +diff --git a/qutebrowser/mainwindow/treetabwidget.py b/qutebrowser/mainwindow/treetabwidget.py +new file mode 100644 +index 000000000..12de178c2 +--- /dev/null ++++ b/qutebrowser/mainwindow/treetabwidget.py +@@ -0,0 +1,105 @@ ++# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: ++ ++# Copyright 2014-2018 Giuseppe Stelluto (pinusc) ++# ++# This file is part of qutebrowser. ++# ++# qutebrowser is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# qutebrowser is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with qutebrowser. If not, see . ++ ++"""Extension of TabWidget for tree-tab functionality.""" ++ ++from qutebrowser.mainwindow.tabwidget import TabWidget ++from qutebrowser.misc.notree import Node ++from qutebrowser.utils import log ++ ++ ++class TreeTabWidget(TabWidget): ++ """Tab widget used in TabbedBrowser, with tree-functionality. ++ ++ Handles correct rendering of the tree as a tab field, and correct ++ positioning of tabs according to tree structure. ++ """ ++ ++ def __init__(self, win_id, parent=None): ++ # root of the tab tree, common for all tabs in the window ++ self.tree_root = Node(None) ++ super().__init__(win_id, parent) ++ self.tabBar().tabMoved.disconnect(self.update_tab_titles) ++ ++ def _init_config(self): ++ super()._init_config() ++ # For tree-tabs ++ self.update_tab_titles() # Must also be called when deactivating ++ self.tree_tab_update() ++ ++ def get_tab_fields(self, idx): ++ """Add tree field data to normal tab field data.""" ++ fields = super().get_tab_fields(idx) ++ ++ tab = self.widget(idx) ++ fields['collapsed'] = '[...] ' if tab.node.collapsed else '' ++ ++ # we remove the first two chars because every tab is child of tree ++ # root and that gets rendered as well ++ rendered_tree = self.tree_root.render() ++ try: ++ pre, _ = rendered_tree[idx+1] ++ tree_prefix = pre[2:] ++ except IndexError: # window or first tab are not initialized yet ++ tree_prefix = "" ++ log.misc.error("tree_prefix is empty!") ++ ++ fields['tree'] = tree_prefix ++ return fields ++ ++ def update_tree_tab_positions(self): ++ """Update tab positions according to the tree structure.""" ++ nodes = self.tree_root.traverse(render_collapsed=False) ++ for idx, node in enumerate(nodes): ++ if idx > 0: ++ cur_idx = self.indexOf(node.value) ++ self.tabBar().moveTab(cur_idx, idx-1) ++ ++ def update_tree_tab_visibility(self): ++ """Hide collapsed tabs and show uncollapsed ones. ++ ++ Sync the internal tree to the tabs the user can actually see. ++ """ ++ for node in self.tree_root.traverse(): ++ if node.value is None: ++ continue ++ if any(ancestor.collapsed for ancestor in node.path[:-1]): ++ if self.indexOf(node.value) != -1: ++ # node should be hidden but is shown ++ cur_tab = node.value ++ idx = self.indexOf(cur_tab) ++ if idx != -1: ++ self.removeTab(idx) ++ else: ++ if self.indexOf(node.value) == -1: ++ # node should be shown but is hidden ++ parent = node.parent ++ tab = node.value ++ name = tab.title() ++ icon = tab.icon() ++ if node.parent is not None: ++ parent_idx = self.indexOf(node.parent.value) ++ self.insertTab(parent_idx + 1, tab, icon, name) ++ tab.node.parent = parent # insertTab resets node ++ ++ def tree_tab_update(self): ++ """Update titles and positions.""" ++ self.update_tree_tab_visibility() ++ self.update_tree_tab_positions() ++ self.update_tab_titles() +diff --git a/qutebrowser/misc/notree.py b/qutebrowser/misc/notree.py +new file mode 100644 +index 000000000..9d180ad4c +--- /dev/null ++++ b/qutebrowser/misc/notree.py +@@ -0,0 +1,354 @@ ++# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: ++ ++# Copyright 2019 Giuseppe Stelluto (pinusc) ++# ++# This file is part of qutebrowser. ++# ++# qutebrowser is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# qutebrowser is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with qutebrowser. If not, see . ++ ++"""Tree library for tree-tabs. ++ ++The fundamental unit is the Node class. ++ ++Create a tree with with Node(value, parent): ++root = Node('foo') ++child = Node('bar', root) ++child2 = Node('baz', root) ++child3 = Node('lorem', child) ++ ++You can also assign parent after instantiation, or even reassign it: ++child4 = Node('ipsum') ++child4.parent = root ++ ++Assign children: ++child.children = [] ++child2.children = [child4, child3] ++child3.parent ++> Node('foo/bar/baz') ++ ++Render a tree with render_tree(root_node): ++render_tree(root) ++ ++> ('', 'foo') ++> ('├─', 'bar') ++> ('│ ├─', 'lorem') ++> ('│ └─', 'ipsum') ++> ('└─', 'baz') ++""" ++import enum ++from typing import Optional, TypeVar, Sequence, List, Tuple, Iterable, Generic ++import itertools ++ ++# For Node.render ++CORNER = '└─' ++INTERSECTION = '├─' ++PIPE = '│' ++ ++ ++class TreeError(RuntimeError): ++ """Exception used for tree-related errors.""" ++ ++ ++class TraverseOrder(enum.Enum): ++ """To be used as argument to traverse(). ++ ++ Implemented orders are pre-order and post-order. ++ Attributes: ++ PRE: pre-order (parents before children). Same as in Node.render ++ POST: children of a node are always yield before their parent. ++ POST_R: Like POST, but children are yield in reverse order ++ """ ++ ++ PRE = 'pre-order' ++ POST = 'post-order' ++ POST_R = 'post-order-reverse' ++ ++ ++uid_gen = itertools.count(0) ++ ++# generic type of value held by Node ++T = TypeVar('T') # pylint: disable=invalid-name ++ ++ ++class Node(Generic[T]): ++ """Fundamental unit of notree library. ++ ++ Attributes: ++ value: The element (ususally a tab) the node represents ++ parent: Node's parent. ++ children: Node's children elements. ++ siblings: Children of parent node that are not self. ++ path: List of nodes from root of tree to self value, parent, and ++ children can all be set by user. Everything else will be updated ++ accordingly, so that if `node.parent = root_node`, then `node in ++ root_node.children` will be True. ++ """ ++ ++ sep: str = '/' ++ __parent: Optional['Node[T]'] = None ++ # this is a global 'static' class attribute ++ ++ def __init__(self, ++ value: T, ++ parent: Optional['Node[T]'] = None, ++ childs: Sequence['Node[T]'] = (), ++ uid: Optional[int] = None) -> None: ++ if uid is not None: ++ self.__uid = uid ++ else: ++ self.__uid = next(uid_gen) ++ ++ self.value = value ++ # set initial values so there's no need for AttributeError checks ++ self.__parent: Optional['Node[T]'] = None ++ self.__children: List['Node[T]'] = [] ++ ++ # For render memoization ++ self.__modified = False ++ self.__set_modified() # not the same as line above ++ self.__rendered: Optional[List[Tuple[str, 'Node[T]']]] = None ++ ++ if parent: ++ self.parent = parent # calls setter ++ if childs: ++ self.children = childs # this too ++ ++ self.__collapsed = False ++ ++ @property ++ def uid(self) -> int: ++ return self.__uid ++ ++ @property ++ def parent(self) -> Optional['Node[T]']: ++ return self.__parent ++ ++ @parent.setter ++ def parent(self, value: 'Node[T]') -> None: ++ """Set parent property. Also adds self to value.children.""" ++ # pylint: disable=protected-access ++ assert(value is None or isinstance(value, Node)) ++ if self.__parent: ++ self.__parent.__disown(self) ++ self.__parent = None ++ if value is not None: ++ value.__add_child(self) ++ self.__parent = value ++ self.__set_modified() ++ ++ @property ++ def children(self) -> Sequence['Node[T]']: ++ return tuple(self.__children) ++ ++ @children.setter ++ def children(self, value: Sequence['Node[T]']) -> None: ++ """Set children property, preserving order. ++ ++ Also sets n.parent = self for n in value. Does not allow duplicates. ++ """ ++ seen = set(value) ++ if len(seen) != len(value): ++ raise TreeError("A duplicate item is present in in %r" % value) ++ new_children = list(value) ++ for child in new_children: ++ if child.parent is not self: ++ child.parent = self ++ self.__children = new_children ++ self.__set_modified() ++ ++ @property ++ def path(self) -> List['Node[T]']: ++ """Get a list of all nodes from the root node to self.""" ++ if self.parent is None: ++ return [self] ++ else: ++ return self.parent.path + [self] ++ ++ @property ++ def depth(self) -> int: ++ """Get the number of nodes between self and the root node.""" ++ return len(self.path) - 1 ++ ++ @property ++ def index(self) -> int: ++ """Get self's position among its siblings (self.parent.children).""" ++ if self.parent is not None: ++ return self.parent.children.index(self) ++ else: ++ raise TreeError('Node has no parent.') ++ ++ @property ++ def collapsed(self) -> bool: ++ return self.__collapsed ++ ++ @collapsed.setter ++ def collapsed(self, val: bool) -> None: ++ self.__collapsed = val ++ self.__set_modified() ++ ++ def __set_modified(self) -> None: ++ """If self is modified, every ancestor is modified as well.""" ++ for node in self.path: ++ node.__modified = True # pylint: disable=protected-access ++ ++ def render(self) -> List[Tuple[str, 'Node[T]']]: ++ """Render a tree with ascii symbols. ++ ++ Tabs appear in the same order as in traverse() with TraverseOrder.PRE ++ Args: ++ node; the root of the tree to render ++ ++ Return: list of tuples where the first item is the symbol, ++ and the second is the node it refers to ++ """ ++ if not self.__modified and self.__rendered is not None: ++ return self.__rendered ++ ++ result = [('', self)] ++ for child in self.children: ++ if child.children: ++ subtree = child.render() ++ if child is not self.children[-1]: ++ subtree = [(PIPE + ' ' + c, n) for c, n in subtree] ++ char = INTERSECTION ++ else: ++ subtree = [(' ' + c, n) for c, n in subtree] ++ char = CORNER ++ subtree[0] = (char, subtree[0][1]) ++ if child.collapsed: ++ result += [subtree[0]] ++ else: ++ result += subtree ++ else: ++ if child is self.children[-1]: ++ result.append((CORNER, child)) ++ else: ++ result.append((INTERSECTION, child)) ++ self.__modified = False ++ self.__rendered = list(result) ++ return list(result) ++ ++ def traverse(self, order: TraverseOrder = TraverseOrder.PRE, ++ render_collapsed: bool = True) -> Iterable['Node']: ++ """Generator for all descendants of `self`. ++ ++ Args: ++ order: a TraverseOrder object. See TraverseOrder documentation. ++ render_collapsed: whether to yield children of collapsed nodes ++ Even if render_collapsed is False, collapsed nodes are be rendered. ++ It's their children that won't. ++ """ ++ if order == TraverseOrder.PRE: ++ yield self ++ ++ if self.collapsed and not render_collapsed: ++ if order != TraverseOrder.PRE: ++ yield self ++ return ++ ++ f = reversed if order is TraverseOrder.POST_R else lambda x: x ++ for child in f(self.children): ++ if render_collapsed or not child.collapsed: ++ yield from child.traverse(order, render_collapsed) ++ else: ++ yield child ++ if order in [TraverseOrder.POST, TraverseOrder.POST_R]: ++ yield self ++ ++ def __add_child(self, node: 'Node[T]') -> None: ++ if node not in self.__children: ++ self.__children.append(node) ++ ++ def __disown(self, value: 'Node[T]') -> None: ++ self.__set_modified() ++ if value in self.__children: ++ self.__children.remove(value) ++ ++ def get_descendent_by_uid(self, uid: int) -> Optional['Node[T]']: ++ """Return descendent identified by the provided uid. ++ ++ Returns None if there is no such descendent. ++ ++ Args: ++ uid: The uid of the node to return ++ """ ++ for descendent in self.traverse(): ++ if descendent.uid == uid: ++ return descendent ++ return None ++ ++ def promote(self, times: int = 1, to: str = 'first') -> None: ++ """Makes self a child of its grandparent, i.e. sibling of its parent. ++ ++ Args: ++ times: How many levels to promote the tab to. to: One of 'next', ++ 'prev', 'first', 'last'. Determines the position among siblings ++ after being promoted. 'next' and 'prev' are relative to the current ++ parent. ++ ++ """ ++ if to not in ['first', 'last', 'next', 'prev']: ++ raise Exception("Invalid value supplied for 'to': " + to) ++ position = {'first': 0, 'last': -1}.get(to, None) ++ diff = {'next': 1, 'prev': 0}.get(to, 1) ++ count = times ++ while count > 0: ++ if self.parent is None or self.parent.parent is None: ++ raise TreeError("Tab has no parent!") ++ grandparent = self.parent.parent ++ if position is not None: ++ idx = position ++ else: # diff is necessarily not none ++ idx = self.parent.index + diff ++ self.parent = None ++ ++ siblings = list(grandparent.children) ++ if idx != -1: ++ siblings.insert(idx, self) ++ else: ++ siblings.append(self) ++ grandparent.children = siblings ++ count -= 1 ++ ++ def demote(self, to: str = 'last') -> None: ++ """Demote a tab making it a child of its previous adjacent sibling.""" ++ if self.parent is None or self.parent.children is None: ++ raise TreeError("Tab has no siblings!") ++ siblings = list(self.parent.children) ++ ++ # we want previous node in the same subtree as current node ++ rel_idx = siblings.index(self) - 1 ++ ++ if rel_idx >= 0: ++ parent = siblings[rel_idx] ++ new_siblings = list(parent.children) ++ position = {'first': 0, 'last': -1}.get(to, -1) ++ if position == 0: ++ new_siblings.insert(0, self) ++ else: ++ new_siblings.append(self) ++ parent.children = new_siblings ++ else: ++ raise TreeError("Tab has no previous sibling!") ++ ++ def __repr__(self) -> str: ++ try: ++ value = str(self.value.url().url()) # type: ignore ++ except Exception: ++ value = str(self.value) ++ return "" % (self.__uid, value) ++ ++ def __str__(self) -> str: ++ # return "" % self.value ++ return str(self.value) +diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py +index da447c747..33ffb6806 100644 +--- a/qutebrowser/misc/sessions.py ++++ b/qutebrowser/misc/sessions.py +@@ -230,6 +230,9 @@ class SessionManager(QObject): + 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 +@@ -274,13 +277,29 @@ class SessionManager(QObject): + 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 + +@@ -448,6 +467,45 @@ class SessionManager(QObject): + 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'], +@@ -455,13 +513,29 @@ class SessionManager(QObject): + 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 9dd497c0b..71546d7be 100644 +--- a/tests/end2end/features/conftest.py ++++ b/tests/end2end/features/conftest.py +@@ -201,12 +201,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' +@@ -218,6 +221,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 +@@ -236,9 +247,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 37989ecb5..6dc1c63d0 100644 +--- a/tests/end2end/fixtures/quteprocess.py ++++ b/tests/end2end/fixtures/quteprocess.py +@@ -638,16 +638,17 @@ class QuteProc(testprocess.Process): + 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: +@@ -657,9 +658,15 @@ class QuteProc(testprocess.Process): + 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 658b027cb..a4a54e7bc 100644 +--- a/tests/helpers/stubs.py ++++ b/tests/helpers/stubs.py +@@ -324,6 +324,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 000000000..95f92f1a6 +--- /dev/null ++++ b/tests/unit/misc/test_notree.py +@@ -0,0 +1,308 @@ ++# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: ++ ++# Copyright 2019 Florian Bruhin (The-Compiler) ++# ++# This file is part of qutebrowser. ++# ++# qutebrowser is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# qutebrowser is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with qutebrowser. If not, see . ++"""Tests for misc.notree library.""" ++import pytest ++ ++from qutebrowser.misc.notree import TreeError, Node, TraverseOrder ++ ++ ++@pytest.fixture ++def tree(): ++ """Return an example tree. ++ ++ n1 ++ ├─n2 ++ │ ├─n4 ++ │ └─n5 ++ └─n3 ++ ├─n6 ++ │ ├─n7 ++ │ ├─n8 ++ │ └─n9 ++ │ └─n10 ++ └─n11 ++ """ ++ # these are actually used because they appear in expected strings ++ n1 = Node('n1') ++ n2 = Node('n2', n1) ++ n4 = Node('n4', n2) ++ n5 = Node('n5', n2) ++ n3 = Node('n3', n1) ++ n6 = Node('n6', n3) ++ n7 = Node('n7', n6) ++ n8 = Node('n8', n6) ++ n9 = Node('n9', n6) ++ n10 = Node('n10', n9) ++ n11 = Node('n11', n3) ++ return n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 ++ ++ ++@pytest.fixture ++def node(tree): ++ return tree[0] ++ ++ ++def test_creation(): ++ node = Node('foo') ++ assert node.value == 'foo' ++ ++ child = Node('bar', node) ++ assert child.parent == node ++ assert node.children == (child, ) ++ ++ ++def test_attach_parent(): ++ n1 = Node('n1', None, []) ++ print(n1.children) ++ n2 = Node('n2', n1) ++ n3 = Node('n3') ++ ++ n2.parent = n3 ++ assert n2.parent == n3 ++ assert n3.children == (n2, ) ++ assert n1.children == () ++ ++ ++def test_duplicate_child(): ++ p = Node('n1') ++ try: ++ c1 = Node('c1', p) ++ c2 = Node('c2', p) ++ p.children = [c1, c1, c2] ++ raise AssertionError("Can add duplicate child") ++ except TreeError: ++ pass ++ finally: ++ if len(p.children) == 3: ++ raise AssertionError("Can add duplicate child") ++ ++ ++def test_replace_parent(): ++ p1 = Node('foo') ++ p2 = Node('bar') ++ _ = Node('_', p2) ++ c = Node('baz', p1) ++ c.parent = p2 ++ assert c.parent is p2 ++ assert c not in p1.children ++ assert c in p2.children ++ ++ ++def test_replace_children(tree): ++ n2 = tree[1] ++ n3 = tree[2] ++ n6 = tree[5] ++ n11 = tree[10] ++ n3.children = [n11] ++ n2.children = (n6, ) + n2.children ++ assert n6.parent is n2 ++ assert n6 in n2.children ++ assert n11.parent is n3 ++ assert n11 in n3.children ++ assert n6 not in n3.children ++ assert len(n3.children) == 1 ++ ++ ++def test_promote_to_first(tree): ++ n1 = tree[0] ++ n3 = tree[2] ++ n6 = tree[5] ++ assert n6.parent is n3 ++ assert n3.parent is n1 ++ n6.promote(to='first') ++ assert n6.parent is n1 ++ assert n1.children[0] is n6 ++ ++ ++def test_promote_to_last(tree): ++ n1 = tree[0] ++ n3 = tree[2] ++ n6 = tree[5] ++ assert n6.parent is n3 ++ assert n3.parent is n1 ++ n6.promote(to='last') ++ assert n6.parent is n1 ++ assert n1.children[-1] is n6 ++ ++ ++def test_promote_to_prev(tree): ++ n1 = tree[0] ++ n3 = tree[2] ++ n6 = tree[5] ++ assert n6.parent is n3 ++ assert n3.parent is n1 ++ assert n1.children[1] is n3 ++ n6.promote(to='prev') ++ assert n6.parent is n1 ++ assert n1.children[1] is n6 ++ ++ ++def test_promote_to_next(tree): ++ n1 = tree[0] ++ n3 = tree[2] ++ n6 = tree[5] ++ assert n6.parent is n3 ++ assert n3.parent is n1 ++ assert n1.children[1] is n3 ++ n6.promote(to='next') ++ assert n6.parent is n1 ++ assert n1.children[2] is n6 ++ ++ ++def test_demote_to_first(tree): ++ n11 = tree[10] ++ n6 = tree[5] ++ assert n11.parent is n6.parent ++ parent = n11.parent ++ assert parent.children.index(n11) == parent.children.index(n6) + 1 ++ n11.demote(to='first') ++ assert n11.parent is n6 ++ assert n6.children[0] is n11 ++ ++ ++def test_demote_to_last(tree): ++ n11 = tree[10] ++ n6 = tree[5] ++ assert n11.parent is n6.parent ++ parent = n11.parent ++ assert parent.children.index(n11) == parent.children.index(n6) + 1 ++ n11.demote(to='last') ++ assert n11.parent is n6 ++ assert n6.children[-1] is n11 ++ ++ ++def test_traverse(node): ++ len_traverse = len(list(node.traverse())) ++ len_render = len(node.render()) ++ assert len_traverse == len_render ++ ++ ++def test_traverse_postorder(tree): ++ n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree ++ actual = list(n1.traverse(TraverseOrder.POST)) ++ print('\n'.join([str(n) for n in actual])) ++ assert actual == [n4, n5, n2, n7, n8, n10, n9, n6, n11, n3, n1] ++ ++ ++def test_traverse_postorder_r(tree): ++ n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree ++ actual = list(n1.traverse(TraverseOrder.POST_R)) ++ print('\n'.join([str(n) for n in actual])) ++ assert actual == [n11, n10, n9, n8, n7, n6, n3, n5, n4, n2, n1] ++ ++ ++def test_render_tree(node): ++ expected = [ ++ 'n1', ++ '├─n2', ++ '│ ├─n4', ++ '│ └─n5', ++ '└─n3', ++ ' ├─n6', ++ ' │ ├─n7', ++ ' │ ├─n8', ++ ' │ └─n9', ++ ' │ └─n10', ++ ' └─n11' ++ ] ++ result = [char + str(n) for char, n in node.render()] ++ print('\n'.join(result)) ++ assert expected == result ++ ++ ++def test_uid(node): ++ uids = set() ++ for n in node.traverse(): ++ assert n not in uids ++ uids.add(n.uid) ++ # pylint: disable=unused-variable ++ n1 = Node('n1') ++ n2 = Node('n2', n1) ++ n4 = Node('n4', n2) # noqa: F841 ++ n5 = Node('n5', n2) # noqa: F841 ++ n3 = Node('n3', n1) ++ n6 = Node('n6', n3) ++ n7 = Node('n7', n6) # noqa: F841 ++ n8 = Node('n8', n6) # noqa: F841 ++ n9 = Node('n9', n6) ++ n10 = Node('n10', n9) # noqa: F841 ++ n11 = Node('n11', n3) ++ # pylint: enable=unused-variable ++ for n in n1.traverse(): ++ assert n not in uids ++ uids.add(n.uid) ++ ++ n11_uid = n11.uid ++ assert n1.get_descendent_by_uid(n11_uid) is n11 ++ assert node.get_descendent_by_uid(n11_uid) is None ++ ++ ++def test_collapsed(node): ++ pre_collapsed_traverse = list(node.traverse()) ++ to_collapse = node.children[1] ++ ++ # collapse ++ to_collapse.collapsed = True ++ assert to_collapse.collapsed is True ++ for n in node.traverse(render_collapsed=False): ++ assert to_collapse not in n.path[:-1] ++ ++ assert list(to_collapse.traverse(render_collapsed=False)) == [to_collapse] ++ ++ assert list(node.traverse()) == pre_collapsed_traverse ++ ++ expected = [ ++ 'n1', ++ '├─n2', ++ '│ ├─n4', ++ '│ └─n5', ++ '└─n3' ++ ] ++ result = [char + str(n) for char, n in node.render()] ++ print('\n'.join(result)) ++ assert expected == result ++ ++ # uncollapse ++ to_collapse.collapsed = False ++ ++ assert any(n for n in node.traverse(render_collapsed=False) if to_collapse ++ in n.path[:-1]) ++ ++ ++def test_memoization(node): ++ assert node._Node__modified is True ++ node.render() ++ assert node._Node__modified is False ++ ++ node.children[0].parent = None ++ assert node._Node__modified is True ++ node.render() ++ assert node._Node__modified is False ++ ++ n2 = Node('ntest', parent=node) ++ assert node._Node__modified is True ++ assert n2._Node__modified is True ++ node.render() ++ assert node._Node__modified is False ++ ++ node.children[0].children[1].parent = None ++ assert node._Node__modified is True ++ assert node.children[0]._Node__modified is True ++ node.render() ++ assert node._Node__modified is False