summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/help/commands.asciidoc91
-rw-r--r--doc/help/settings.asciidoc151
-rw-r--r--doc/img/treetabs/tree_tabs_new_tab_types.pngbin0 -> 4869 bytes
-rw-r--r--doc/img/treetabs/tree_tabs_overview_detail.pngbin0 -> 11259 bytes
-rw-r--r--doc/treetabs.md219
-rw-r--r--qutebrowser/browser/browsertab.py7
-rw-r--r--qutebrowser/browser/commands.py391
-rw-r--r--qutebrowser/browser/qutescheme.py12
-rw-r--r--qutebrowser/commands/command.py4
-rw-r--r--qutebrowser/completion/models/util.py4
-rw-r--r--qutebrowser/config/configdata.yml59
-rw-r--r--qutebrowser/config/configtypes.py15
-rw-r--r--qutebrowser/html/tree_group.html65
-rw-r--r--qutebrowser/mainwindow/mainwindow.py20
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py128
-rw-r--r--qutebrowser/mainwindow/tabwidget.py31
-rw-r--r--qutebrowser/mainwindow/treetabbedbrowser.py353
-rw-r--r--qutebrowser/mainwindow/treetabwidget.py138
-rw-r--r--qutebrowser/misc/notree.py346
-rw-r--r--qutebrowser/misc/sessions.py180
-rwxr-xr-xscripts/asciidoc2html.py10
-rw-r--r--tests/end2end/features/conftest.py202
-rw-r--r--tests/end2end/features/sessions.feature15
-rw-r--r--tests/end2end/features/test_treetabs_bdd.py6
-rw-r--r--tests/end2end/features/treetabs.feature208
-rw-r--r--tests/end2end/fixtures/quteprocess.py21
-rw-r--r--tests/helpers/stubs.py1
-rw-r--r--tests/unit/completion/test_models.py32
-rw-r--r--tests/unit/mainwindow/test_tabwidget.py10
-rw-r--r--tests/unit/mainwindow/test_treetabbedbrowser.py255
-rw-r--r--tests/unit/misc/test_notree.py293
31 files changed, 3021 insertions, 246 deletions
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 4d1610970..1c4c47e7f 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 `;;`.
|<<tab-prev,tab-prev>>|Switch to the previous tab, or switch [count] tabs back.
|<<tab-select,tab-select>>|Select tab by index or url/title best match.
|<<tab-take,tab-take>>|Take a tab from another window.
+|<<tree-tab-create-group,tree-tab-create-group>>|Wrapper around :open qute://treegroup/name. Correctly escapes names.
+|<<tree-tab-cycle-hide,tree-tab-cycle-hide>>|Hides levels of descendents: children, grandchildren, and so on.
+|<<tree-tab-demote,tree-tab-demote>>|Demote a tab making it children of its previous adjacent sibling.
+|<<tree-tab-promote,tree-tab-promote>>|Promote a tab so it becomes next sibling of its parent.
+|<<tree-tab-suspend-children,tree-tab-suspend-children>>|Suspends all descendent of a tab to reduce memory usage.
+|<<tree-tab-toggle-hide,tree-tab-toggle-hide>>|If the current tab's children are shown hide them, and vice-versa.
|<<unbind,unbind>>|Unbind a keychain.
|<<undo,undo>>|Re-open the last closed tab(s) or window.
|<<version,version>>|Show version information.
@@ -999,7 +1005,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.
@@ -1011,6 +1017,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.
@@ -1400,7 +1408,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.
@@ -1410,6 +1418,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
@@ -1423,10 +1432,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
@@ -1437,7 +1450,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.
@@ -1449,6 +1462,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).
@@ -1481,8 +1495,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.
@@ -1510,8 +1529,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.
@@ -1549,6 +1573,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 af76527c9..73646e541 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -332,6 +332,11 @@
|<<tabs.mousewheel_switching,tabs.mousewheel_switching>>|Switch between tabs using the mouse wheel.
|<<tabs.new_position.related,tabs.new_position.related>>|Position of new tabs opened from another tab.
|<<tabs.new_position.stacking,tabs.new_position.stacking>>|Stack related tabs on top of each other when opened consecutively.
+|<<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`
+|<<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.
+|<<tabs.new_position.tree.new_sibling,tabs.new_position.tree.new_sibling>>|Position of siblings, e.g. after calling `:open --sibling ...`.
+|<<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`.
+|<<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`
|<<tabs.new_position.unrelated,tabs.new_position.unrelated>>|Position of new tabs which are not opened from another tab.
|<<tabs.padding,tabs.padding>>|Padding (in pixels) around text for tabs.
|<<tabs.pinned.frozen,tabs.pinned.frozen>>|Force pinned tabs to stay at fixed URL.
@@ -346,6 +351,7 @@
|<<tabs.title.format,tabs.title.format>>|Format to use for the tab title.
|<<tabs.title.format_pinned,tabs.title.format_pinned>>|Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined.
|<<tabs.tooltips,tabs.tooltips>>|Show tooltips on tabs.
+|<<tabs.tree_tabs,tabs.tree_tabs>>|Enable tree-tabs mode.
|<<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).
|<<tabs.width,tabs.width>>|Width (in pixels or as percentage of the window) of the tab bar if it's vertical.
|<<tabs.wrap,tabs.wrap>>|Wrap when changing tabs.
@@ -372,7 +378,7 @@ The keys of the given dictionary are the aliases, while the values are the comma
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[q]+: +pass:[close]+
- +pass:[qa]+: +pass:[quit]+
@@ -479,7 +485,7 @@ This setting can only be set in config.py.
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[caret]+:
@@ -748,6 +754,17 @@ Default:
* +pass:[yp]+: +pass:[yank pretty-url]+
* +pass:[yt]+: +pass:[yank title]+
* +pass:[yy]+: +pass:[yank]+
+* +pass:[zG]+: +pass:[cmd-set-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:[cmd-set-text --space :open -tS]+
+* +pass:[za]+: +pass:[tree-tab-toggle-hide]+
+* +pass:[zd]+: +pass:[tab-close -r]+
+* +pass:[zg]+: +pass:[cmd-set-text -s :tree-tab-create-group -r]+
+* +pass:[zo]+: +pass:[cmd-set-text --space :open -tr]+
+* +pass:[zp]+: +pass:[tab-focus parent]+
* +pass:[{{]+: +pass:[navigate prev -t]+
* +pass:[}}]+: +pass:[navigate next -t]+
- +pass:[passthrough]+:
@@ -805,7 +822,7 @@ Note that when a key is bound (via `bindings.default` or `bindings.commands`), t
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[&lt;Ctrl-6&gt;]+: +pass:[&lt;Ctrl-^&gt;]+
- +pass:[&lt;Ctrl-Enter&gt;]+: +pass:[&lt;Ctrl-Return&gt;]+
@@ -879,7 +896,7 @@ May be a single color to use for all columns or a list of three colors, one for
Type: <<types,List of QtColor&#44; or QtColor>>
-Default:
+Default:
- +pass:[white]+
- +pass:[white]+
@@ -1833,7 +1850,7 @@ Valid values:
* +history+
* +filesystem+
-Default:
+Default:
- +pass:[searchengines]+
- +pass:[quickmarks]+
@@ -1938,7 +1955,7 @@ Valid values:
* +downloads+: Show a confirmation if downloads are running
* +never+: Never show a confirmation.
-Default:
+Default:
- +pass:[never]+
@@ -1969,7 +1986,7 @@ need to find the link to the raw `.txt` file (e.g. by extracting it from the
Type: <<types,List of Url>>
-Default:
+Default:
- +pass:[https://easylist.to/easylist/easylist.txt]+
- +pass:[https://easylist.to/easylist/easyprivacy.txt]+
@@ -2014,7 +2031,7 @@ The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists.
Type: <<types,List of Url>>
-Default:
+Default:
- +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+
@@ -2396,7 +2413,7 @@ The following levels are valid: `none`, `debug`, `info`, `warning`, `error`.
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[error]+: +pass:[debug]+
- +pass:[info]+: +pass:[debug]+
@@ -2957,7 +2974,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
-Default:
+Default:
- +pass:[gvim]+
- +pass:[-f]+
@@ -3019,7 +3036,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
-Default:
+Default:
- +pass:[xterm]+
- +pass:[-e]+
@@ -3035,7 +3052,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
-Default:
+Default:
- +pass:[xterm]+
- +pass:[-e]+
@@ -3390,7 +3407,7 @@ Comma-separated list of regular expressions to use for 'next' links.
Type: <<types,List of Regex>>
-Default:
+Default:
- +pass:[\bnext\b]+
- +pass:[\bmore\b]+
@@ -3405,7 +3422,7 @@ Padding (in pixels) for hints.
Type: <<types,Padding>>
-Default:
+Default:
- +pass:[bottom]+: +pass:[0]+
- +pass:[left]+: +pass:[3]+
@@ -3418,7 +3435,7 @@ Comma-separated list of regular expressions to use for 'prev' links.
Type: <<types,List of Regex>>
-Default:
+Default:
- +pass:[\bprev(ious)?\b]+
- +pass:[\bback\b]+
@@ -3453,7 +3470,7 @@ This setting can only be set in config.py.
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[all]+:
@@ -4158,7 +4175,7 @@ Padding (in pixels) for the statusbar.
Type: <<types,Padding>>
-Default:
+Default:
- +pass:[bottom]+: +pass:[1]+
- +pass:[left]+: +pass:[0]+
@@ -4211,7 +4228,7 @@ Valid values:
* +text:foo+: Display the static text after the colon, `foo` in the example.
* +clock+: Display current time. The format can be changed by adding a format string via `clock:...`. For supported format strings, see https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes[the Python datetime documentation].
-Default:
+Default:
- +pass:[keypress]+
- +pass:[search_match]+
@@ -4296,7 +4313,7 @@ Padding (in pixels) for tab indicators.
Type: <<types,Padding>>
-Default:
+Default:
- +pass:[bottom]+: +pass:[2]+
- +pass:[left]+: +pass:[0]+
@@ -4396,6 +4413,77 @@ Type: <<types,Bool>>
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: <<types,NewChildPosition>>
+
+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: <<types,NewChildPosition>>
+
+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: <<types,NewTabPosition>>
+
+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: <<types,NewTabPosition>>
+
+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: <<types,NewTabPosition>>
+
+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.
@@ -4418,7 +4506,7 @@ Padding (in pixels) around text for tabs.
Type: <<types,Padding>>
-Default:
+Default:
- +pass:[bottom]+: +pass:[0]+
- +pass:[left]+: +pass:[5]+
@@ -4536,6 +4624,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.
@@ -4555,7 +4645,7 @@ The following placeholders are defined:
Type: <<types,FormatString>>
-Default: +pass:[{audio}{index}: {current_title}]+
+Default: +pass:[{tree}{collapsed}{audio}{index}: {current_title}]+
[[tabs.title.format_pinned]]
=== tabs.title.format_pinned
@@ -4574,6 +4664,16 @@ Type: <<types,Bool>>
Default: +pass:[true]+
+[[tabs.tree_tabs]]
+=== tabs.tree_tabs
+Enable tree-tabs mode.
+
+This setting requires a restart.
+
+Type: <<types,Bool>>
+
+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).
@@ -4636,7 +4736,7 @@ Valid values:
* +query+
* +anchor+
-Default:
+Default:
- +pass:[path]+
- +pass:[query]+
@@ -4678,7 +4778,7 @@ term, e.g. `:open google qutebrowser`.
Type: <<types,Dict>>
-Default:
+Default:
- +pass:[DEFAULT]+: +pass:[https://duckduckgo.com/?q={}]+
@@ -4696,7 +4796,7 @@ URL parameters to strip with `:yank url`.
Type: <<types,List of String>>
-Default:
+Default:
- +pass:[ref]+
- +pass:[utm_source]+
@@ -4757,7 +4857,7 @@ Available zoom levels.
Type: <<types,List of Perc>>
-Default:
+Default:
- +pass:[25%]+
- +pass:[33%]+
@@ -4833,6 +4933,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/doc/img/treetabs/tree_tabs_new_tab_types.png b/doc/img/treetabs/tree_tabs_new_tab_types.png
new file mode 100644
index 000000000..fdca0e01d
--- /dev/null
+++ b/doc/img/treetabs/tree_tabs_new_tab_types.png
Binary files differ
diff --git a/doc/img/treetabs/tree_tabs_overview_detail.png b/doc/img/treetabs/tree_tabs_overview_detail.png
new file mode 100644
index 000000000..fc5610ecf
--- /dev/null
+++ b/doc/img/treetabs/tree_tabs_overview_detail.png
Binary files differ
diff --git a/doc/treetabs.md b/doc/treetabs.md
new file mode 100644
index 000000000..d52a08de3
--- /dev/null
+++ b/doc/treetabs.md
@@ -0,0 +1,219 @@
+# Tree Style Tabs
+
+## Intro
+
+Tree style tabs allow you to group and manage related tabs together. Related
+tabs will be shown in a hierarchical fashion in the tab bar when it is on the
+left or right side of the browser window. It can be enabled by setting
+`tabs.tree_tabs` to `true`. That setting only applies to new windows created
+after it is enabled (including via saving and loading a session or
+`:restart`).
+
+![](img/treetabs/tree_tabs_overview_detail.png)
+
+When a tab is being opened it will be classified as one of *unrelated*
+(default), *sibling* or *related* to the current tab.
+
+![](img/treetabs/tree_tabs_new_tab_types.png)
+
+* *unrelated* tabs are created at the top level of the tree for the current
+ browser window. They can be created by opening a new tab using `:open -t`.
+* *sibling* tabs are created at the same level as the current tab. They can be
+ created by running `:open -t -S`.
+* *related* tabs are created as children of the current tab. They can be
+ created by following a link in a new tab (middle click, `F` hinting mode) or
+ by running `:open -t -r`.
+
+## Enabling Tree Tabs
+
+TODO: more words here
+
+* `tabs.tree_tabs`
+* check default settings: title format, padding, elide
+* steps to take when downgrading if you don't want to lose settings
+
+## Manipulating the Tree
+
+todo: add animated illustrations?
+
+You can change how tabs relate to each other after they are created too.
+
+* `:open`, as described in the intro, has picked up some new behavior to
+ decide where in relation to the current tab a new one should go. It has a
+ new `--sibling` argument and the existing arguments `--related`, `--tab` and
+ `--background` have picked up some additional meaning to help with that.
+* `:tab-move` will move a tab and its children within the tree
+ * With a `+` or `-` argument tabs will only move within their siblings
+ (wrapping at the top or bottom)
+ * With a count or integer argument tabs will move to the absolute position
+ specified, which may include changing level in the hierarchy.
+* Tabs can be moved up and down a hierarchy with the commands
+ `:tree-tab-promote` and `:tree-tab-demote`
+* `:tab-give --recursive` will move a tab and its children to another window.
+ They will be placed at the top level.
+* Some methods of moving tabs do *not* yet understand tab groups, these are:
+ * `:tab-take`
+ * moving tabs with a mouse or other pointer
+
+Other pre-existing commands that understand tab groups are:
+
+* `:tab-close --recursive` will close a tab and all its children. If
+ `:tab-close` is used without `--recursive` the first of a tabs children will
+ be promoted in its place.
+* `:tab-focus parent` will switch focus to a tab's parent, so that you don't
+ have to cycle through a tab's siblings to get there.
+* `:tab-next --sibling` and `:tab-prev --sibling` will switch the focus to a
+ tab's sibling, skipping any child tabs.
+
+## Working with Tab Groups
+
+Beyond the commands above for manipulating the tree, there are a few new
+commands introduced to take advantage of the tab grouping feature.
+
+* `:tree-tab-create-group {name}` will create a new placeholder tab with a
+ title of `{name}`. This is a light weight way of creating a "named group" by
+ putting a tab with a meaningful title at the top level of it. It can
+ create tabs at the top level of the window or under the current tab with the
+ `--related` argument. The placeholder tab contains an ascii art picture of a
+ tree. The title of the tab comes from the URL path.
+* `:tree-tab-toggle-hide` will collapse, or reveal, a tab group, which will
+ hide any children tabs from the hierarchy shown in the tab bar as well as
+ making children unelectable via `:tab-focus`, `tab-select` and `:tab-take`.
+ The tabs will still be running in the background.
+* `:tree-tab-cycle-hide` will hide successive levels of a tab's hierarchy of
+ children. For example, the first time you run it will hide the outermost
+ generation of leaf nodes, the next time will hide the next level up and so
+ on.
+* `:tree-tab-suspend-children` will suspend all of the children of a tab via
+ the lazy load mechanism (`qute://back/`). Tabs will be un-suspended when
+ they are next focused. This apply for any children which are hidden too.
+
+## Settings
+
+There are some existing settings that will have modified behavior when tree
+tabs are enabled:
+
+* `tabs.new_position.related`: this is essentially replaced by
+ `tabs.new_position.new_child`
+* `tabs.new_position.unrelated`: this is essentially replaced by
+ `tabs.new_position.new_toplevel`
+* the settings `tabs.title.format`, `tabs.title.format_pinned` and
+ `window.title_format` have gained two new template variables: `{tree}` and
+ `{collapsed}`. These are for displaying the tree structure in the tab bar and
+ the default value for `tabs.title.format` now has `{tree}{collapsed}` at the
+ start of it.
+
+There are a few new settings introduced to control where tabs are places in
+the tree structure as a result of various operations. All of these settings
+accept the options `first`, `last`, `next` or `prev`; apart from `new_child`
+and `demote` which only accept `first` or `last`.
+
+* `tabs.new_position.promote`
+* `tabs.new_position.demote`
+* `tabs.new_position.new_toplevel`
+* `tabs.new_position.new_sibling`
+* `tabs.new_position.new_child`
+
+## Bindings
+
+There are various new default bindings introduced to make accessing the new
+and changed commands easy. They all start with the letter `z`:
+
+TODO: more words here? Are any of these bindings analogous to existing
+ones? Any theme to them?
+
+* `zH`: `tree-tab-promote`
+* `zL`: `tree-tab-demote`
+* `zK`: `tab-prev -s` - cycle tab focus upwards among siblings
+* `zJ`: `tab-next -s` - cycle tab focus downwards among siblings
+* `zd`: `tab-close -r` - r = recursive
+* `zg`: `cmd-set-text -s :tree-tab-create-group -r` - r = related
+* `zG`: `cmd-set-text -s :tree-tab-create-group`
+* `za`: `tree-tab-toggle-hide` - same binding as vim folds
+* `zp`: `tab-focus parent`
+* `zo`: `cmd-set-text --space :open -tr` - r = related
+* `zO`: `cmd-set-text --space :open -tS` - S = sibling
+
+## Implementation
+
+The core tree data structure is in `qutebrowser/misc/notree.py`, inspired by
+the `anytree` python library. It defines a `Node` type. A Node can have a
+parent, a list of child nodes, and `value` attribute - which in qutebrowser's
+case is always a browser tab. A tree of nodes is always modified by changing
+either the parent or children of a node via property setters. Beyond those two
+setters nodes have `promote()` and `demote()` helper functions used by the
+corresponding commands.
+
+Beyond those four methods to manipulate the tree structure nodes have
+methods for:
+
+* traversing the tree:
+ * `traverse()` return all descendant nodes (including self)
+ * `path()` return all nodes from self up to the tree root, inclusive
+ * `depth()` return depth in tree
+* collapsing a node
+ * this just sets an attribute on a node, the traversal function respects it
+ but beyond that it's up to callers to know that an un-collapsed node may
+ be hidden if a parent node is collapsed, there are a few pieces of
+ calling code which do implement different behavior for collapsed nodes
+* rendering unicode tree segments to be used in tab titles
+ * our tab bar itself doesn't understand the tree structure for now, it's
+ just being represented by drawing unicode line and angle characters to
+ the left of the tab titles which happen to line up
+ * this does generally put some restrictions on some tab bar related
+ settings. `tabs.title.format` needs to have `{tree}{collapsed}` in it,
+ `tabs.padding` needs to have 0 for the top and bottom padding,
+ `tabs.title.elide` can't be on the same side as the tree related format strings.
+
+Beyond the core data structure most of the changes are in places where tabs
+need to relate to each other. There are two new subclasses of existing core
+classes:
+
+*TreeTabbedBrowser* inherits the main TabbedBrowser and has overriden methods
+to make sure tabs are correctly positioned when opening a tab, closing a tab
+and undoing a tab close. After tabs are opened they are placed into the
+correct position in the tree based on the new `tabs.new_position.*` settings
+and then into order in the tab widget corresponding to the tree traversal
+order. When tabs are closed the new `--recursive` flag is handled, children
+are re-parented in the tree and extra details are added to undo entries. When
+a tab close is undone its position in the tree is restored, including demoting
+any child that was promoted when the tab was closed. TreeTabbedBrowsers will
+be created by MainWindow when the new `tabs.tree_tabs` setting is set.
+
+*TreeTabWidget* handles making sure the new `{tree}` and `{collapsed}` are
+filled in for the tab title template string, with a lot of help from the data
+structure. It also handles hiding or showing tabs for collapsed
+groups/branches. Hidden tabs are children of tabs with the `collapsed`
+property set, they remain in the tree structure (which is held by the tabbed
+browser) but they are removed entirely from the tab widget. The
+`tree_tab_update()` method, which is called from several places, also handles
+making sure tabs are moved to indices corresponding to their traversal order
+in the tree, in case any changes have been made to the tree structure.
+
+One place in the tab widget classes where tree tab specific code isn't
+contained entirely in TreeTabWidget is the `{tree}` and `{collapsed}`
+tab/window title format attributes. A key error will be thrown if a
+placeholder is in the format string but no value is supplied for it. So the
+parent class initialize them to an empty string in case users have them
+configured but have tree tabs turned off.
+
+A fair amount of tree tab specific code lives in *commands.py*. The six new
+commands have been added, as well as a customization so that these commands
+don't show up in the command completion if the tree tabs feature isn't
+enabled. The commands for manipulating the tree structure do very little but
+call out to other pieces of code, either the browser or the tree structure.
+Of note are the two commands `tree_tab_create_group()` and
+`tree_tab_suspend_children()` which use the scheme handlers `qute://treegroup`
+(new) and `qute://back` (existing).
+
+Beyond those six new commands quite a few existing commands to do with
+manipulating tabs have seen some tree tab specific code paths added, some of
+them quite complex and with little shared with the existing code paths. Common
+themes beyond handling new arguments are dealing with recursive operations and
+collapsed nodes.
+
+something something sessions.py
+
+Other stuff, like tree group page
+
+## Outstanding issues? Questions?
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 625046a9c..e7e56a061 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -40,6 +40,8 @@ if TYPE_CHECKING:
from qutebrowser.browser.webengine.webview import WebEngineView
from qutebrowser.browser.webkit.webview import WebView
+from qutebrowser.mainwindow.treetabwidget import TreeTabWidget
+from qutebrowser.misc.notree import Node
tab_id_gen = itertools.count(0)
_WidgetType = Union["WebView", "WebEngineView"]
@@ -1058,6 +1060,11 @@ class AbstractTab(QWidget):
self, parent=self)
self.backend: Optional[usertypes.Backend] = None
+ if parent is not None 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 83a846b85..6784f0c1d 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,36 @@ 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(descendent.value, force,
+ close)
+ new_undo = False
+ else:
+ tab = descendent.value
+ tab.private_api.shutdown()
+ tab.deleteLater()
+ 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 +306,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 +322,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 +342,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 +482,45 @@ 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."""
+ # first pass: open tabs and save the uids of the new nodes
+ uid_map = {} # old_uid -> new_uid
+ traversed = list(self._current_widget().node.traverse())
+ for node in traversed:
+ tab = tabbed_browser.tabopen(
+ node.value.url(),
+ related=False,
+ )
+ uid_map[node.uid] = tab.node.uid
+
+ # second pass: copy tree structure over
+ newroot = tabbed_browser.widget.tree_root
+ for node in traversed:
+ if node.parent.uid in uid_map:
+ 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, children first this time to
+ # avoid having to re-parent things when traversing.
+ if not keep:
+ for node in self._current_widget().node.traverse(
+ notree.TraverseOrder.POST_R,
+ render_collapsed=False,
+ ):
+ self._tabbed_browser.close_tab(node.value,
+ 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 +529,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,14 +561,18 @@ class CommandDispatcher:
raise cmdutils.CommandError(
"The window with id {} is not private".format(win_id))
- tabbed_browser.tabopen(self._current_url())
+ if recursive and tabbed_browser.is_treetabbedbrowser:
+ self._tree_tab_give(tabbed_browser, keep)
+ else:
+ tabbed_browser.tabopen(self._current_url())
+ if not keep:
+ self._tabbed_browser.close_tab(self._current_widget(),
+ add_undo=False,
+ transfer=True)
+ # Make sure the tabbed browser is shown in case we created a new one
+ # when detaching.
tabbed_browser.window().show()
- if not keep:
- self._tabbed_browser.close_tab(self._current_widget(),
- add_undo=False,
- transfer=True)
-
def _back_forward(
self, *,
tab: bool,
@@ -876,43 +949,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 +1101,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 +1113,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 +1131,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 +1186,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 +1233,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)
@@ -1905,3 +2076,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 508d510d7..e7bb27d8d 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -579,6 +579,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 effdcc9b0..aaf1763e2 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.
@@ -66,7 +67,7 @@ class Command:
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,
): # pylint: disable=too-many-arguments
if modes is not None and not_modes is not None:
raise ValueError("Only modes or not_modes can be given!")
@@ -96,6 +97,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 ca92f96c1..8abc0b7d2 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -2272,6 +2272,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
@@ -2337,7 +2371,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:
@@ -2355,12 +2389,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.
@@ -2396,6 +2434,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.
@@ -2494,6 +2534,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:
@@ -3834,6 +3880,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: cmd-set-text -s :tree-tab-create-group -r
+ zG: cmd-set-text -s :tree-tab-create-group
+ za: tree-tab-toggle-hide
+ zp: tab-focus parent
+ zo: cmd-set-text --space :open -tr
+ zO: cmd-set-text --space :open -tS
insert:
<Ctrl-E>: edit-text
<Shift-Ins>: insert-text -- {primary}
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index 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 %}
+<h1>
+ {{ title }}
+</h1>
+<p>
+ <em>Group for tree tabs</em>
+</p>
+<pre>
+{% raw %}
+ _.
+ _~.:'^%^ >@~.
+ ,-~ ? =*=
+ $^_` ` , ' , + -.,
+ (*-^. , * ;' >
+ >. ,> . ' .,.,. %-.,_ ,.-,
+ # ' ` " - " * .,. * .^ `
+ *@! ., * ' ' , ;' ' . %!
+ & " .` :' ` ' . `~,
+ & ' .` ' ' . '": : +.
+ ^ .", , ` ' ` * , ' ` |
+ ] * . , ""] .. ` . , ` , " . . ' ,;,
+ % ' ::, , / , ' , ;
+ .* ,* / *% \ . . *' ` , ' '.
+ ? > . , ::. :;^^. %` ' ` @
+ / ' `/ ` &#@%^^ `&`` ` %;; %
+ ;: :% * * :$%)\ ' `@%$ @%^ ,).
+ . # %&^ (!*^ .\,. ` ^@%^ $#%%^ ` >
+ \ :#$% #^&# : ` * %###$%@! &
+ | ' * %$#@)$*}] ` `#@#%%^ *^
+ : *' * @%&&^:$ ` ' `%%. #$$$^^-, 7
+ &; @#$~~ ' ` @#$%& $,*.-
+ *...*^ .._ %$$#@! @ ., *&&#@
+ :..^ - !%&@}{#& @#$@%
+ --_..%#%%$#&% #&%$#;:
+ $%#^%#@@%%*&;;
+ a%##@%%@% ;:;
+ %####j#:::;
+ &#%Rj;%;;:
+ &#%%#;::;
+ $#%##:%::
+ "#%%#;:;
+ ."$###:::
+ #&$%%#:;:
+ %&#%%#::;
+ %&%###;::
+ &&#%%#:;;
+ *@&#%#};:;
+ $#%#%%^:::
+ *@#$#%#;::;:
+ %%@#$####@$:;:
+ ...%###pinusc@$%%:._____
+{% endraw %}
+
+</pre>
+{% endblock %}
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index e39ac4f9a..9251131e3 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()
@@ -499,8 +505,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 28f32c4fd..a635d803e 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, Union,
+ MutableMapping, MutableSequence, Optional, Tuple)
from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication
from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint
@@ -34,7 +34,32 @@ class _UndoEntry:
index: int
pinned: bool
created_at: datetime.datetime = dataclasses.field(
- default_factory=datetime.datetime.now)
+ default_factory=datetime.datetime.now,
+ init=False, # WORKAROUND until py3.10 with kw_only: https://www.trueblade.com/blogs/news/python-3-10-new-dataclass-features
+ )
+
+ def restore_into_tab(self, tab: browsertab.AbstractTab) -> None:
+ """Set the url, history and state of `tab` from this undo entry."""
+ tab.history.private_api.deserialize(self.history)
+ tab.set_pinned(self.pinned)
+ tab.setFocus()
+
+ @classmethod
+ def from_tab(
+ cls, tab: browsertab.AbstractTab, idx: int
+ ) -> Union["_UndoEntry", List["_UndoEntry"]]:
+ """Generate an undo entry from `tab`."""
+ try:
+ history_data = tab.history.private_api.serialize()
+ except browsertab.WebTabError:
+ return None # special URL
+
+ return cls(
+ url=tab.url(),
+ history=history_data,
+ index=idx,
+ pinned=tab.data.pinned,
+ )
UndoStackType = MutableSequence[MutableSequence[_UndoEntry]]
@@ -194,17 +219,20 @@ class TabbedBrowser(QWidget):
resized = pyqtSignal('QRect')
current_tab_changed = pyqtSignal(browsertab.AbstractTab)
new_tab = pyqtSignal(browsertab.AbstractTab, int)
+ is_treetabbedbrowser = False
shutting_down = pyqtSignal()
+ _undo_class = _UndoEntry
def __init__(self, *, win_id, private, parent=None):
if private:
assert not qtutils.is_single_process()
super().__init__(parent)
- self.widget = tabwidget.TabWidget(win_id, parent=self)
self._win_id = win_id
self._tab_insert_idx_left = 0
self._tab_insert_idx_right = -1
self.is_shutting_down = False
+
+ self.widget = self._create_tab_widget()
self.widget.tabCloseRequested.connect(self.on_tab_close_requested)
self.widget.new_tab_requested.connect(
self.tabopen) # type: ignore[arg-type,unused-ignore]
@@ -242,6 +270,9 @@ class TabbedBrowser(QWidget):
config.instance.changed.connect(self._on_config_changed)
quitter.instance.shutting_down.connect(self.shutdown)
+ def _create_tab_widget(self):
+ return tabwidget.TabWidget(self._win_id, parent=self)
+
def _update_stack_size(self):
newsize = config.instance.get('tabs.undo_stack_size')
if newsize < 0:
@@ -280,9 +311,11 @@ class TabbedBrowser(QWidget):
raise TabDeletedError("index is -1!")
return idx
- def widgets(self):
+ def widgets(self) -> List[browsertab.AbstractTab]:
"""Get a list of open tab widgets.
+ Consider using `tabs()` instead of this method.
+
We don't implement this as generator so we can delete tabs while
iterating over the list.
"""
@@ -295,6 +328,18 @@ class TabbedBrowser(QWidget):
widgets.append(widget)
return widgets
+ def tabs(
+ self,
+ include_hidden: bool = False, # pylint: disable=unused-argument
+ ) -> List[browsertab.AbstractTab]:
+ """Get a list of tabs in this browser.
+
+ Args:
+ include_hidden: Include child tabs which are not currently in the
+ tab bar.
+ """
+ return self.widgets()
+
def _update_window_title(self, field=None):
"""Change the window title to match the current tab.
@@ -376,7 +421,7 @@ class TabbedBrowser(QWidget):
tab.history_item_triggered.connect(
history.web_history.add_from_tab)
- def _current_tab(self) -> browsertab.AbstractTab:
+ def current_tab(self) -> browsertab.AbstractTab:
"""Get the current browser tab.
Note: The assert ensures the current tab is never None.
@@ -483,6 +528,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
@@ -493,36 +539,46 @@ class TabbedBrowser(QWidget):
tab.pending_removal = True
+ if add_undo:
+ self._add_undo_entry(tab, new_undo=new_undo)
+
+ tab.private_api.shutdown()
+ self.widget.removeTab(idx)
+
+ tab.deleteLater()
+
+ def _add_undo_entry(self, tab, new_undo):
if tab.url().isEmpty():
# There are some good reasons why a URL could be empty
# (target="_blank" with a download, see [1]), so we silently ignore
# this.
# [1] https://github.com/qutebrowser/qutebrowser/issues/163
- pass
- elif not tab.url().isValid():
+ return
+
+ if not tab.url().isValid():
# We display a warning for URLs which are not empty but invalid -
# but we don't return here because we want the tab to close either
# way.
urlutils.invalid_url_error(tab.url(), "saving tab")
- elif add_undo:
- try:
- history_data = tab.history.private_api.serialize()
- except browsertab.WebTabError:
- pass # special URL
- else:
- entry = _UndoEntry(url=tab.url(),
- history=history_data,
- index=idx,
- pinned=tab.data.pinned)
- if new_undo or not self.undo_stack:
- self.undo_stack.append([entry])
- else:
- self.undo_stack[-1].append(entry)
+ return
- tab.private_api.shutdown()
- self.widget.removeTab(idx)
+ idx = self.widget.indexOf(tab)
+ entry = self._undo_class.from_tab(tab, idx)
+ if not entry:
+ return
- tab.deleteLater()
+ if isinstance(entry, self._undo_class):
+ if new_undo or not self.undo_stack:
+ self.undo_stack.append([entry])
+ else:
+ self.undo_stack[-1].append(entry)
+ else:
+ assert len(entry) > 0
+ entries = entry
+ if new_undo:
+ self.undo_stack.append(entries)
+ else:
+ self.undo_stack[-1].extend(entries)
def undo(self, depth=1):
"""Undo removing of a tab or tabs."""
@@ -557,11 +613,14 @@ class TabbedBrowser(QWidget):
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)
- newtab.setFocus()
+ entry.restore_into_tab(newtab)
@pyqtSlot('QUrl', bool)
def load_url(self, url, newtab):
@@ -575,7 +634,7 @@ class TabbedBrowser(QWidget):
if newtab or self.widget.currentWidget() is None:
self.tabopen(url, background=False)
else:
- self._current_tab().load_url(url)
+ self.current_tab().load_url(url)
@pyqtSlot(int)
def on_tab_close_requested(self, idx):
@@ -605,6 +664,7 @@ class TabbedBrowser(QWidget):
background: bool = None,
related: bool = True,
idx: int = None,
+ sibling: bool = False, # pylint: disable=unused-argument
) -> browsertab.AbstractTab:
"""Open a new tab with a given URL.
@@ -649,7 +709,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)
@@ -660,7 +720,7 @@ class TabbedBrowser(QWidget):
# Make sure the background tab has the correct initial size.
# With a foreground tab, it's going to be resized correctly by the
# layout anyways.
- current_widget = self._current_tab()
+ current_widget = self.current_tab()
tab.resize(current_widget.size())
self.widget.tab_index_changed.emit(self.widget.currentIndex(),
self.widget.count())
@@ -1075,7 +1135,7 @@ class TabbedBrowser(QWidget):
if key != "'":
message.error("Failed to set mark: url invalid")
return
- point = self._current_tab().scroller.pos_px()
+ point = self.current_tab().scroller.pos_px()
if key.isupper():
self._global_marks[key] = point, url
@@ -1096,7 +1156,7 @@ class TabbedBrowser(QWidget):
except qtutils.QtValueError:
urlkey = None
- tab = self._current_tab()
+ tab = self.current_tab()
if key.isupper():
if key in self._global_marks:
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index 42c31c97e..be1c696cf 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:
@@ -42,18 +41,20 @@ class TabWidget(QTabWidget):
def __init__(self, win_id, parent=None):
super().__init__(parent)
+ self._tabbed_browser = parent
bar = TabBar(win_id, self)
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._tab_title_update_disabled = False
+
self._init_config()
config.instance.changed.connect(self._init_config)
@@ -79,7 +80,7 @@ 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:
@@ -124,6 +125,12 @@ class TabWidget(QTabWidget):
field: A field name which was updated. If given, the title
is only set if the given field is in the template.
"""
+ if self._tab_title_update_disabled:
+ return
+
+ if self._tabbed_browser.is_shutting_down:
+ return
+
assert idx != -1
tab = self._tab_by_idx(idx)
assert tab is not None
@@ -176,6 +183,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
@@ -238,8 +248,17 @@ class TabWidget(QTabWidget):
bar.setVisible(True)
bar.setUpdatesEnabled(True)
+ @contextlib.contextmanager
+ def _disable_tab_title_updates(self):
+ self._tab_title_update_disabled = True
+ yield
+ self._tab_title_update_disabled = False
+
def update_tab_titles(self):
"""Update all texts."""
+ if self._tab_title_update_disabled:
+ return
+
with self._toggle_visibility():
for idx in range(self.count()):
self.update_tab_title(idx)
@@ -334,7 +353,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..ddbe91c97
--- /dev/null
+++ b/qutebrowser/mainwindow/treetabbedbrowser.py
@@ -0,0 +1,353 @@
+# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Subclass of TabbedBrowser to provide tree-tab functionality."""
+
+import collections
+import dataclasses
+from typing import List, Dict, Union
+from qutebrowser.qt.core import pyqtSlot, QUrl
+
+from qutebrowser.config import config
+from qutebrowser.mainwindow.tabbedbrowser import TabbedBrowser, _UndoEntry
+from qutebrowser.mainwindow.treetabwidget import TreeTabWidget
+from qutebrowser.browser import browsertab
+from qutebrowser.misc import notree
+
+
+@dataclasses.dataclass
+class _TreeUndoEntry(_UndoEntry):
+ """Information needed for :undo."""
+
+ uid: int
+ parent_node_uid: int
+ children_node_uids: List[int]
+ local_index: int # index of the tab relative to its siblings
+
+ def restore_into_tab(self, tab: browsertab.AbstractTab) -> None:
+ super().restore_into_tab(tab)
+
+ root = tab.node.path[0]
+ uid = self.uid
+ parent_uid = self.parent_node_uid
+ parent_node = root.get_descendent_by_uid(parent_uid)
+ if not parent_node:
+ parent_node = root
+
+ children = []
+ for child_uid in self.children_node_uids:
+ child_node = root.get_descendent_by_uid(child_uid)
+ if child_node:
+ 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 = self.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
+
+ @classmethod
+ def from_tab(
+ cls,
+ tab: browsertab.AbstractTab,
+ idx: int,
+ recursing: bool = False,
+ ) -> Union["_TreeUndoEntry", List["_TreeUndoEntry"]]:
+ """Make a TreeUndoEntry from a Node."""
+ node = tab.node
+ url = node.value.url()
+ try:
+ history_data = tab.history.private_api.serialize()
+ except browsertab.WebTabError:
+ return None # special URL
+
+ if not recursing and node.collapsed:
+ entries = [
+ cls.from_tab(descendent.value, idx+1, recursing=True)
+ for descendent in
+ node.traverse(notree.TraverseOrder.POST_R)
+ ]
+ entries = [entry for entry in entries if entry]
+ return entries
+
+ pinned = node.value.data.pinned
+ uid = node.uid
+ parent_uid = node.parent.uid
+ if recursing:
+ # Recursively removed nodes will never have any existing children
+ # to re-parent in the tree they are being added into, children
+ # will always be added later as the undo stack is worked through.
+ # So remove child IDs here so we don't confuse undo() later.
+ children = []
+ else:
+ children = [n.uid for n in node.children]
+ local_idx = node.index
+ return cls(
+ url=url,
+ history=history_data,
+ # The index argument is redundant given the parent and local index
+ # info, but required by the parent class.
+ 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
+ _undo_class = _TreeUndoEntry
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._tree_tab_child_rel_idx = 0
+ self._tree_tab_sibling_rel_idx = 0
+ self._tree_tab_toplevel_rel_idx = 0
+
+ def _create_tab_widget(self):
+ """Return the tab widget that can display a tree structure."""
+ return TreeTabWidget(self._win_id, parent=self)
+
+ def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False):
+ """Handle children positioning after a tab is removed."""
+ if not tab.url().isEmpty() and tab.url().isValid() and add_undo:
+ self._add_undo_entry(tab, new_undo)
+
+ node = tab.node
+ parent = node.parent
+
+ if node.collapsed:
+ # Collapsed nodes have already been removed from the TabWidget so
+ # we can't ask super() to dispose of them and need to do it
+ # ourselves.
+ for descendent in node.traverse(
+ order=notree.TraverseOrder.POST_R,
+ render_collapsed=True
+ ):
+ descendent.parent = None
+ descendent_tab = descendent.value
+ descendent_tab.private_api.shutdown()
+ descendent_tab.deleteLater()
+ elif parent:
+ siblings = list(parent.children)
+ children = node.children
+
+ if children:
+ # Promote first child,
+ # make that promoted node the parent of our other children
+ # give the promoted node our position in our siblings list.
+ 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)
+ assert not node.children
+
+ node.parent = None
+
+ super()._remove_tab(tab, add_undo=False, new_undo=False,
+ crashed=crashed)
+
+ self.widget.tree_tab_update()
+
+ def undo(self, depth=1):
+ """Undo removing of a tab or tabs."""
+ super().undo(depth)
+ self.widget.tree_tab_update()
+
+ def tabs(
+ self,
+ include_hidden: bool = False,
+ ) -> List[browsertab.AbstractTab]:
+ """Get a list of tabs in this browser.
+
+ Args:
+ include_hidden: Include child tabs which are not currently in the
+ tab bar.
+ """
+ return [
+ node.value
+ for node
+ in self.widget.tree_root.traverse(
+ render_collapsed=include_hidden,
+ )
+ if node.value
+ ]
+
+ @pyqtSlot('QUrl')
+ @pyqtSlot('QUrl', bool)
+ @pyqtSlot('QUrl', bool, bool)
+ def tabopen(
+ self, url: QUrl = None,
+ background: bool = None,
+ related: bool = True,
+ idx: int = None,
+ sibling: bool = False,
+ ) -> 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`.
+
+ """
+ # Save the current tab now before letting super create the new tab
+ # (and possibly give it focus). To insert the new tab correctly in the
+ # tree structure later we may need to know which tab it was opened
+ # from (for the `related` and `sibling` cases).
+ cur_tab = self.widget.currentWidget()
+ tab = super().tabopen(url, background, related, idx)
+
+ # Some trivial cases where we don't need to do positioning:
+
+ # 1. this is the first tab in the window.
+ if cur_tab is None:
+ assert self.widget.count() == 1
+ assert tab.node.parent == self.widget.tree_root
+ return tab
+
+ if (
+ config.val.tabs.tabs_are_windows or # 2. one tab per window
+ tab is cur_tab # 3. opening URL in existing tab
+ ):
+ return tab
+
+ # Some sanity checking to make sure the tab super created was set up
+ # as a tree style tab correctly. We don't have a TreeTab so this is
+ # heuristic to highlight any problems elsewhere in the application
+ # logic.
+ assert tab.node.parent, (
+ f"Node for new tab doesn't have a parent: {tab.node}"
+ )
+
+ # We may also be able to skip the positioning code below if the `idx`
+ # arg is passed in. Semgrep says that arg is used from undo() and
+ # SessionManager, both cases are updating the tree structure
+ # themselves after opening the new tab. On the other hand the only
+ # downside is we move the tab and update the tree twice. Although that
+ # may actually make loading large sessions a bit slower.
+
+ 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.node, tab.node, pos, parent, sibling, related, background)
+
+ return tab
+
+ def _position_tab(
+ self,
+ cur_node: notree.Node,
+ new_node: notree.Node,
+ 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 new_node.parent == parent:
+ # Remove the current node from its parent's children list to avoid
+ # potentially adding it as a duplicate later.
+ siblings.remove(new_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, new_node)
+ elif pos in ['prev', 'next'] and (sibling or toplevel):
+ # Pivot is the tab relative to which 'prev' or 'next' apply to.
+ # Either the current node or top of the current tree.
+ pivot = cur_node if sibling else cur_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, new_node)
+ else: # position == 'last'
+ siblings.append(new_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..8e8b6f29e
--- /dev/null
+++ b/qutebrowser/mainwindow/treetabwidget.py
@@ -0,0 +1,138 @@
+# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""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)
+
+ def get_tab_fields(self, idx):
+ """Add tree field data to normal tab field data."""
+ fields = super().get_tab_fields(idx)
+
+ if len(self.tree_root.children) == 0:
+ # Presumably the window is still being initialized
+ log.misc.vdebug(f"Tree root has no children. Are we starting up? fields={fields}")
+ return fields
+
+ rendered_tree = self.tree_root.render()
+ tab = self.widget(idx)
+ found = [
+ prefix
+ for prefix, node in rendered_tree
+ if node.value == tab
+ ]
+
+ if len(found) == 1:
+ # we remove the first two chars because every tab is child of tree
+ # root and that gets rendered as well
+ fields['tree'] = found[0][2:]
+ fields['collapsed'] = '[...] ' if tab.node.collapsed else ''
+ return fields
+
+ # Beyond here we have a mismatch between the tab widget and the tree.
+ # Try to identify known situations where this happens precisely and
+ # handle them gracefully. Blow up on unknown situations so we don't
+ # miss them.
+
+ # Just sanity checking, we haven't seen this yet.
+ assert len(found) == 0, (
+ "Found multiple tree nodes with the same tab as value: tab={tab}"
+ )
+
+ # Having more tabs in the widget when loading a session with a
+ # collapsed group in is a known case. Check for it with a heuristic
+ # (for now) and assert if that doesn't look like that's how we got
+ # here.
+ all_nodes = self.tree_root.traverse()
+ node = [n for n in all_nodes if n.value == tab][0]
+ is_hidden = any(n.collapsed for n in node.path)
+
+ tabs = [str(self.widget(idx)) for idx in range(self.count())]
+ difference = len(rendered_tree) - 1 - len(tabs)
+ # empty_urls here is a proxy for "there is a session being loaded into
+ # this window"
+ empty_urls = all(
+ not self.widget(idx).url().toString() for idx in range(self.count())
+ )
+ if empty_urls and is_hidden:
+ # All tabs will be added to the tab widget during session load
+ # and they will only be removed later when the widget is
+ # updated from the tree. Meanwhile, if we get here we'll have
+ # hidden tabs present in the widget but absent from the node.
+ # To detect this situation more clearly we could do something like
+ # have a is_starting_up or is_loading_session attribute on the
+ # tabwidget/tabbbedbrowser. Or have the session manager add all
+ # nodes to the tree uncollapsed initially and then go through and
+ # collapse them.
+ log.misc.vdebug(
+ "get_tab_fields() called with different amount of tabs in "
+ f"widget vs in the tree: difference={difference} "
+ f"tree={rendered_tree[1:]} tabs={tabs}"
+ )
+ else:
+ # If we get here then we have another case to investigate.
+ assert difference == 0, (
+ "Different amount of nodes in tree than widget. "
+ f"difference={difference} tree={rendered_tree[1:]} tabs={tabs}"
+ )
+
+ 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
+
+ should_be_hidden = any(ancestor.collapsed for ancestor in node.path[:-1])
+ is_shown = self.indexOf(node.value) != -1
+ if should_be_hidden and is_shown:
+ # node should be hidden but is shown
+ cur_tab = node.value
+ idx = self.indexOf(cur_tab)
+ if idx != -1:
+ self.removeTab(idx)
+ elif not should_be_hidden and not is_shown:
+ # 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."""
+ with self._disable_tab_title_updates():
+ 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..6388a3e77
--- /dev/null
+++ b/qutebrowser/misc/notree.py
@@ -0,0 +1,346 @@
+# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""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):
+ """Tree traversal order for Node.traverse().
+
+ All traversals are depth first.
+ See https://en.wikipedia.org/wiki/Depth-first_search#Vertex_orderings
+
+ Attributes:
+ PRE: pre-order: parent then children, leftmost nodes first. Same as in Node.render().
+ POST: post-order: children then parent, leftmost nodes first, then parent.
+ POST_R: post-order-reverse: like POST but rightmost nodes first.
+ """
+
+ PRE = 'pre-order' # pylint: disable=invalid-name
+ POST = 'post-order' # pylint: disable=invalid-name
+ POST_R = 'post-order-reverse' # pylint: disable=invalid-name
+
+
+uid_gen = itertools.count(0)
+
+# generic type of value held by Node
+T = TypeVar('T')
+
+
+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 %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,unused-private-member
+
+ 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
+ elif 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( # pylint: disable=unused-private-member
+ self,
+ node: 'Node[T]',
+ ) -> None:
+ if node not in self.__children:
+ self.__children.append(node)
+
+ def __disown( # pylint: disable=unused-private-member
+ 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 ValueError("Invalid value supplied for 'to': " + to)
+ position = {'first': 0, 'last': -1}.get(to, None)
+ diff = {'next': 1, 'prev': 0}.get(to, 1)
+ count = times
+ while count > 0:
+ if self.parent is None or self.parent.parent is None:
+ raise TreeError("Tab has no parent!")
+ grandparent = self.parent.parent
+ if position is not None:
+ idx = position
+ else: # diff is necessarily not none
+ idx = self.parent.index + diff
+ self.parent = None
+
+ siblings = list(grandparent.children)
+ if idx != -1:
+ siblings.insert(idx, self)
+ else:
+ siblings.append(self)
+ grandparent.children = siblings
+ count -= 1
+
+ def demote(self, to: str = 'last') -> None:
+ """Demote a tab making it a child of its previous adjacent sibling."""
+ if self.parent is None or self.parent.children is None:
+ raise TreeError("Tab has no siblings!")
+ siblings = list(self.parent.children)
+
+ # we want previous node in the same subtree as current node
+ rel_idx = siblings.index(self) - 1
+
+ if rel_idx >= 0:
+ parent = siblings[rel_idx]
+ new_siblings = list(parent.children)
+ position = {'first': 0, 'last': -1}.get(to, -1)
+ if position == 0:
+ new_siblings.insert(0, self)
+ else:
+ new_siblings.append(self)
+ parent.children = new_siblings
+ else:
+ raise TreeError("Tab has no previous sibling!")
+
+ def __repr__(self) -> str:
+ try:
+ value = str(self.value.url().url()) # type: ignore
+ except Exception:
+ value = str(self.value)
+ return "<Node -%d- '%s'>" % (self.__uid, value)
+
+ def __str__(self) -> str:
+ # return "<Node '%s'>" % self.value
+ return str(self.value)
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index dd63904cd..35c36f203 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -121,6 +121,42 @@ class TabHistoryItem:
last_visited=self.last_visited)
+def reconstruct_tree_data(window_data):
+ """Return a dict usable as a tree from a window.
+
+ Returns a dict like:
+ {
+ 1: {'children': [2]},
+ 2: {
+ ...tab,
+ "treetab_node_data": {
+ "children": [],
+ "collapsed": False,
+ "parent": 1,
+ "uid": 2,
+ }
+ }
+ }
+
+ Which you can traverse by starting at the node with no "treetab_node_data"
+ attribute (the root) and pulling successive levels of children from the
+ dict using their `uid`s as keys.
+
+ The ...tab part represents the usual attributes for a tab when saved in a
+ session.
+ """
+ tree_data = {}
+ root = window_data['treetab_root']
+ tree_data[root['uid']] = {
+ 'children': root['children'],
+ 'tab': {},
+ 'collapsed': False
+ }
+ for tab in window_data['tabs']:
+ tree_data[tab['treetab_node_data']['uid']] = tab
+ return tree_data
+
+
class SessionManager(QObject):
"""Manager for sessions.
@@ -281,13 +317,31 @@ 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))
+
+ win_data['tabs'] = []
+ for tab in tabbed_browser.tabs(include_hidden=True):
+ active = tab == tabbed_browser.current_tab()
+ tab_data = self._save_tab(tab,
+ active,
+ with_history=with_history)
+ if tabbed_browser.is_treetabbedbrowser:
+ node = tab.node
+ node_data = {
+ 'parent': node.parent.uid,
+ 'children': [c.uid for c in node.children],
+ 'collapsed': node.collapsed,
+ 'uid': node.uid
+ }
+ tab_data['treetab_node_data'] = node_data
+ win_data['tabs'].append(tab_data)
+ if tabbed_browser.is_treetabbedbrowser:
+ root = tabbed_browser.widget.tree_root
+ win_data['treetab_root'] = {
+ 'children': [c.uid for c in root.children],
+ 'uid': root.uid
+ }
data['windows'].append(win_data)
return data
@@ -455,6 +509,86 @@ class SessionManager(QObject):
except ValueError as e:
raise SessionError(e)
+ def _load_tree(self, tabbed_browser, tree_data, legacy=False):
+ 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
+ if legacy:
+ node_data = tree_data[uid]
+ tab_data = node_data['tab']
+ else:
+ tab_data = tree_data[uid]
+ node_data = tab_data['treetab_node_data']
+ children_uids = node_data['children']
+
+ if tab_data.get('active'):
+ tab_to_focus = index
+
+ new_tab = tabbed_browser.tabopen(
+ background=False,
+ related=False,
+ idx=index,
+ )
+ 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
+
+ # Make sure any collapsed tabs are removed from the widget.
+ # Since we only set the "collapsed" attribute after loading the tab,
+ # and the tree only gets updated in the above loop on tab loads. So if
+ # the last set of tabs we load is a collapsed group this children
+ # won't know they are support to be hidden yet.
+ tabbed_browser.widget.tree_tab_update()
+
+ return tab_to_focus
+
+ def _load_legacy_tree_tabs(self, win, tabbed_browser):
+ """Load the "legacy" tree session format.
+
+ For a number of years (pull #4602) tree tabs used a session format
+ that wasn't backwards compatible with prior session formats. In that
+ tab data was a child of a tree node. Now it's been switched to the
+ other way around. This method (along with a conditional in
+ `_load_tree()`) handle loading the old format for early adopters who
+ have session they don't want to have to rebuild.
+
+ Returns:
+ a. `(None, None)` if tree tabs where loaded, or
+ b. or a tuple of the tab index to focus and a flat list of tabs that
+ need loading if tree tabs is turned off.
+ """
+ plain_tabs = None
+ tab_to_focus = None
+ tree_data = win.get('tree')
+ if tabbed_browser.is_treetabbedbrowser:
+ tab_to_focus = self._load_tree(tabbed_browser, tree_data, legacy=True)
+ tabbed_browser.widget.tree_tab_update()
+ else:
+ plain_tabs = [tree_data[i]['tab'] for i in tree_data if
+ tree_data[i]['tab']]
+ return tab_to_focus, plain_tabs
+
def _load_window(self, win):
"""Turn yaml data into windows."""
window = mainwindow.MainWindow(geometry=win['geometry'],
@@ -462,13 +596,35 @@ 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)
+
+ legacy_tree_loaded = False
+ if win.get('tree'):
+ tab_to_focus, tabs = self._load_legacy_tree_tabs(win, tabbed_browser)
+ legacy_tree_loaded = not tabs
+ else:
+ tabs = win['tabs']
+
+ # restore a tab tree only if the session contains treetab
+ # data and tree tabs are enabled.
+ # Otherwise, restore tabs "flat"
+ load_tree_tabs = 'treetab_root' in win.keys() and \
+ tabbed_browser.is_treetabbedbrowser
+ if load_tree_tabs:
+ tree_data = reconstruct_tree_data(win)
+ tab_to_focus = self._load_tree(tabbed_browser, tree_data)
+ elif not legacy_tree_loaded:
+ for i, tab in enumerate(tabs):
+ new_tab = tabbed_browser.tabopen(
+ background=False,
+ related=False,
+ idx=i,
+ )
+ 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/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index 1d0249d9a..836d02d87 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -98,11 +98,11 @@ class AsciiDoc:
"""Copy image files to qutebrowser/html/doc."""
print("Copying files...")
dst_path = DOC_DIR / 'img'
- dst_path.mkdir(exist_ok=True)
- for filename in ['cheatsheet-big.png', 'cheatsheet-small.png']:
- src = REPO_ROOT / 'doc' / 'img' / filename
- dst = dst_path / filename
- shutil.copy(src, dst)
+ try:
+ shutil.rmtree(dst_path)
+ except FileNotFoundError:
+ pass
+ shutil.copytree(REPO_ROOT / 'doc' / 'img', dst_path)
def _build_website_file(self, root: pathlib.Path, filename: str) -> None:
"""Build a single website file."""
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
index 082b999b1..ce31125b6 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -20,6 +20,7 @@ import pytest
import pytest_bdd as bdd
import qutebrowser
+from qutebrowser.misc import sessions
from qutebrowser.utils import log, utils, docutils, version
from qutebrowser.browser import pdfjs
from end2end.fixtures import testprocess
@@ -125,14 +126,14 @@ def set_setting_given(quteproc, server, opt, value):
@bdd.given(bdd.parsers.parse("I open {path}"))
-def open_path_given(quteproc, path):
+def open_path_given(quteproc, server, path):
"""Open a URL.
This is available as "Given:" step so it can be used as "Background:".
It always opens a new tab, unlike "When I open ..."
"""
- quteproc.open_path(path, new_tab=True)
+ open_path(quteproc, server, path, default_kwargs={"new_tab": True})
@bdd.given(bdd.parsers.parse("I run {command}"))
@@ -188,7 +189,7 @@ def clear_log_lines(quteproc):
@bdd.when(bdd.parsers.parse("I open {path}"))
-def open_path(quteproc, server, path):
+def open_path(quteproc, server, path, default_kwargs: dict = None):
"""Open a URL.
- If used like "When I open ... in a new tab", the URL is opened in a new
@@ -200,45 +201,41 @@ def open_path(quteproc, server, path):
path = path.replace('(port)', str(server.port))
path = testutils.substitute_testdata(path)
- new_tab = False
- new_bg_tab = False
- new_window = False
- private = False
- as_url = False
- wait = True
+ suffixes = {
+ "in a new tab": "new_tab",
+ "in a new related tab": ("new_tab", "related_tab"),
+ "in a new sibling tab": ("new_tab", "sibling_tab"),
+ "in a new related background tab": ("new_bg_tab", "related_tab"),
+ "in a new background tab": "new_bg_tab",
+ "in a new window": "new_window",
+ "in a private window": "private",
+ "without waiting": {"wait": False},
+ "as a URL": "as_url",
+ }
- new_tab_suffix = ' in a new tab'
- new_bg_tab_suffix = ' in a new background tab'
- new_window_suffix = ' in a new window'
- private_suffix = ' in a private window'
- do_not_wait_suffix = ' without waiting'
- as_url_suffix = ' as a URL'
+ def update_from_value(value, kwargs):
+ if isinstance(value, str):
+ kwargs[value] = True
+ elif isinstance(value, (tuple, list)):
+ for i in value:
+ update_from_value(i, kwargs)
+ elif isinstance(value, dict):
+ kwargs.update(value)
+ kwargs = {}
while True:
- if path.endswith(new_tab_suffix):
- path = path[:-len(new_tab_suffix)]
- new_tab = True
- elif path.endswith(new_bg_tab_suffix):
- path = path[:-len(new_bg_tab_suffix)]
- new_bg_tab = True
- elif path.endswith(new_window_suffix):
- path = path[:-len(new_window_suffix)]
- new_window = True
- elif path.endswith(private_suffix):
- path = path[:-len(private_suffix)]
- private = True
- elif path.endswith(as_url_suffix):
- path = path[:-len(as_url_suffix)]
- as_url = True
- elif path.endswith(do_not_wait_suffix):
- path = path[:-len(do_not_wait_suffix)]
- wait = False
+ for suffix, value in suffixes.items():
+ if path.endswith(suffix):
+ path = path[:-len(suffix) - 1]
+ update_from_value(value, kwargs)
+ break
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)
+ if not kwargs and default_kwargs:
+ kwargs.update(default_kwargs)
+
+ quteproc.open_path(path, **kwargs)
@bdd.when(bdd.parsers.parse("I set {opt} to {value}"))
@@ -609,55 +606,104 @@ def check_contents_json(quteproc, text):
assert actual == expected
-@bdd.then(bdd.parsers.parse("the following tabs should be open:\n{tabs}"))
-def check_open_tabs(quteproc, request, tabs):
- """Check the list of open tabs in the session.
+@bdd.then(bdd.parsers.parse("the following tabs should be open:\n{expected_tabs}"))
+def check_open_tabs(quteproc, request, expected_tabs):
+ """Check the list of open tabs in a one window session.
This is a lightweight alternative for "The session should look like: ...".
- It expects a list of URLs, with an optional "(active)" suffix.
+ It expects a tree of URLs in the form:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active)
+
+ Where the indentation is optional (but if present the indent should be two
+ spaces) and the suffix can be one or more of:
+
+ (active)
+ (pinned)
+ (collapsed)
"""
session = quteproc.get_session()
+ expected_tabs = expected_tabs.splitlines()
+ assert len(session['windows']) == 1
+ window = session['windows'][0]
+ assert len(window['tabs']) == len(expected_tabs)
+
active_suffix = ' (active)'
pinned_suffix = ' (pinned)'
- tabs = tabs.splitlines()
- assert len(session['windows']) == 1
- assert len(session['windows'][0]['tabs']) == len(tabs)
-
- # If we don't have (active) anywhere, don't check it
- has_active = any(active_suffix in line for line in tabs)
- has_pinned = any(pinned_suffix in line for line in tabs)
-
- for i, line in enumerate(tabs):
- line = line.strip()
- assert line.startswith('- ')
- line = line[2:] # remove "- " prefix
-
- active = False
- pinned = False
-
- while line.endswith(active_suffix) or line.endswith(pinned_suffix):
- if line.endswith(active_suffix):
- # active
- line = line[:-len(active_suffix)]
- active = True
- else:
- # pinned
- line = line[:-len(pinned_suffix)]
- pinned = True
-
- session_tab = session['windows'][0]['tabs'][i]
- current_page = session_tab['history'][-1]
- assert current_page['url'] == quteproc.path_to_url(line)
- if active:
- assert session_tab['active']
- elif has_active:
- assert 'active' not in session_tab
-
- if pinned:
- assert current_page['pinned']
- elif has_pinned:
- assert not current_page['pinned']
+ collapsed_suffix = ' (collapsed)'
+ # Don't check for states in the session if they aren't in the expected
+ # text.
+ has_active = any(active_suffix in line for line in expected_tabs)
+ has_pinned = any(pinned_suffix in line for line in expected_tabs)
+ has_collapsed = any(collapsed_suffix in line for line in expected_tabs)
+
+ def tab_to_str(tab, prefix="", collapsed=False):
+ """Convert a tab from a session file into a one line string."""
+ current = [
+ entry
+ for entry in tab["history"]
+ if entry.get("active")
+ ][0]
+ text = f"{prefix}- {current['url']}"
+ for suffix, state in {
+ active_suffix: tab.get("active") and has_active,
+ collapsed_suffix: collapsed and has_collapsed,
+ pinned_suffix: current["pinned"] and has_pinned,
+ }.items():
+ if state:
+ text += suffix
+ return text
+
+ def tree_to_str(node, tree_data, indentation=-1):
+ """Traverse a tree turning each node into an indented string."""
+ tree_node = node.get("treetab_node_data")
+ if tree_node: # root node doesn't have treetab_node_data
+ yield tab_to_str(
+ node,
+ prefix=" " * indentation,
+ collapsed=tree_node["collapsed"],
+ )
+ else:
+ tree_node = node
+
+ for uid in tree_node["children"]:
+ yield from tree_to_str(tree_data[uid], tree_data, indentation + 1)
+
+ is_tree_tab_window = "treetab_root" in window
+ if is_tree_tab_window:
+ tree_data = sessions.reconstruct_tree_data(window)
+ root = [node for node in tree_data.values() if "treetab_node_data" not in node][0]
+ actual = list(tree_to_str(root, tree_data))
+ else:
+ actual = [tab_to_str(tab) for tab in window["tabs"]]
+
+ def normalize(line):
+ """Normalize expected lines to match session lines.
+
+ Turn paths into URLs and sort suffixes.
+ """
+ prefix, rest = line.split("- ", maxsplit=1)
+ path = rest.split(" ", maxsplit=1)
+ path[0] = quteproc.path_to_url(path[0])
+ if len(path) == 2:
+ suffixes = path[1].split()
+ for s in suffixes:
+ assert s[0] == "("
+ assert s[-1] == ")"
+ path[1] = " ".join(sorted(suffixes))
+ return "- ".join((prefix, " ".join(path)))
+
+ expected_tabs = [
+ normalize(line)
+ for line in expected_tabs
+ ]
+ # Removed the hyphens from the start of lines so they don't get mixed in
+ # with the diff markers.
+ expected_tabs = [line.replace("- ", "") for line in expected_tabs]
+ actual = [line.replace("- ", "") for line in actual]
+ for idx, expected in enumerate(expected_tabs):
+ assert expected == actual[idx]
@bdd.then(bdd.parsers.re(r'the (?P<what>primary selection|clipboard) should '
diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature
index 9a61baf61..2f121132f 100644
--- a/tests/end2end/features/sessions.feature
+++ b/tests/end2end/features/sessions.feature
@@ -440,3 +440,18 @@ Feature: Saving and loading sessions
- data/numbers/2.txt (active) (pinned)
- data/numbers/4.txt
- data/numbers/3.txt
+
+ # Make sure the new_position.related setting doesn't change the tab order
+ # when loading from a session.
+ Scenario: Loading a session with tabs.new_position.related=prev
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new tab
+ And I open data/numbers/3.txt in a new tab
+ And I run :session-save foo
+ And I set tabs.new_position.related to prev
+ And I run :session-load -c foo
+ And I wait until data/numbers/3.txt is loaded
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt
+ - data/numbers/3.txt (active)
diff --git a/tests/end2end/features/test_treetabs_bdd.py b/tests/end2end/features/test_treetabs_bdd.py
new file mode 100644
index 000000000..9cbb315d7
--- /dev/null
+++ b/tests/end2end/features/test_treetabs_bdd.py
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import pytest_bdd as bdd
+bdd.scenarios("treetabs.feature")
diff --git a/tests/end2end/features/treetabs.feature b/tests/end2end/features/treetabs.feature
new file mode 100644
index 000000000..bf99e0f66
--- /dev/null
+++ b/tests/end2end/features/treetabs.feature
@@ -0,0 +1,208 @@
+Feature: Tree tab management
+ Tests for various :tree-tab-* commands.
+
+ Background:
+ # Open a new tree tab enabled window, close everything else
+ Given I set tabs.tabs_are_windows to false
+ And I set tabs.tree_tabs to true
+ And I open about:blank?starting%20page in a new window
+ And I clean up open tabs
+ And I clear the log
+
+ Scenario: Focus previous sibling tab
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new tab
+ And I run :tab-prev --sibling
+ Then the following tabs should be open:
+ - data/numbers/1.txt (active)
+ - data/numbers/2.txt
+ - data/numbers/3.txt
+
+ Scenario: Focus next sibling tab
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new tab
+ And I run :tab-focus 1
+ And I run :tab-next --sibling
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt
+ - data/numbers/3.txt (active)
+
+ Scenario: Closing a tab promotes the first child in its place
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I open data/numbers/4.txt in a new tab
+ And I run :tab-focus 1
+ And I run :tab-close
+ Then the following tabs should be open:
+ - data/numbers/2.txt
+ - data/numbers/3.txt
+ - data/numbers/4.txt
+
+ Scenario: Focus a parent tab
+ When I open data/numbers/1.txt
+ And I open data/numbers/3.txt in a new related tab
+ And I open data/numbers/2.txt in a new sibling tab
+ And I run :tab-focus parent
+ Then the following tabs should be open:
+ - data/numbers/1.txt (active)
+ - data/numbers/2.txt
+ - data/numbers/3.txt
+
+ Scenario: :tab-close --recursive
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I open data/numbers/4.txt in a new tab
+ And I run :tab-focus 1
+ And I run :tab-close --recursive
+ Then the following tabs should be open:
+ - data/numbers/4.txt
+
+ Scenario: Open a child tab
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active)
+
+ Scenario: Move a tab to the given index
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new tab
+ And I open data/numbers/4.txt in a new related tab
+ And I run :tab-focus 3
+ And I run :tab-move 1
+ Then the following tabs should be open:
+ - data/numbers/3.txt
+ - data/numbers/4.txt
+ - data/numbers/1.txt
+ - data/numbers/2.txt
+
+ Scenario: Collapse a subtree
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I run :tab-focus 2
+ And I run :tree-tab-toggle-hide
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active) (collapsed)
+ - data/numbers/3.txt
+
+ Scenario: Load a collapsed subtree
+ # Same setup as above
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I run :tab-focus 2
+ And I run :tree-tab-toggle-hide
+ # Now actually load the saved session
+ And I run :session-save foo
+ And I run :session-load -c foo
+ And I wait until data/numbers/1.txt is loaded
+ And I wait until data/numbers/2.txt is loaded
+ And I wait until data/numbers/3.txt is loaded
+ # And of course the same assertion as above too
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active) (collapsed)
+ - data/numbers/3.txt
+
+ Scenario: Uncollapse a subtree
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I run :tab-focus 2
+ And I run :tree-tab-toggle-hide
+ And I run :tree-tab-toggle-hide
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active)
+ - data/numbers/3.txt
+
+ # Same as a test in sessions.feature but tree tabs and the related
+ # settings.
+ Scenario: TreeTabs: Loading a session with tabs.new_position.related=prev
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt in a new related tab
+ And I open data/numbers/3.txt in a new related tab
+ And I open data/numbers/4.txt in a new tab
+ And I run :tab-focus 2
+ And I run :tree-tab-toggle-hide
+ And I run :session-save foo
+ And I set tabs.new_position.related to prev
+ And I set tabs.new_position.tree.new_child to last
+ And I set tabs.new_position.tree.new_toplevel to prev
+ And I run :session-load -c foo
+ And I wait until data/numbers/1.txt is loaded
+ And I wait until data/numbers/2.txt is loaded
+ And I wait until data/numbers/3.txt is loaded
+ And I wait until data/numbers/4.txt is loaded
+ Then the following tabs should be open:
+ - data/numbers/1.txt
+ - data/numbers/2.txt (active) (collapsed)
+ - data/numbers/3.txt
+ - data/numbers/4.txt
+
+ Scenario: Undo a tab close restores tree structure
+ # Restored node should be put back in the right place in the tree with
+ # same parent and child.
+ When I open about:blank?grandparent
+ And I open about:blank?parent in a new related tab
+ And I open about:blank?child in a new related tab
+ And I run :tab-select ?parent
+ And I run :tab-close
+ And I run :undo
+ Then the following tabs should be open:
+ - about:blank?grandparent
+ - about:blank?parent (active)
+ - about:blank?child
+
+ Scenario: Undo a tab close when the parent has already been closed
+ # Close the child first, then the parent. When the child is restored
+ # it should be placed back at the root.
+ When I open about:blank?grandparent
+ And I open about:blank?parent in a new related tab
+ And I open about:blank?child in a new related tab
+ And I run :tab-close
+ And I run :tab-close
+ And I run :undo 2
+ Then the following tabs should be open:
+ - about:blank?child (active)
+ - about:blank?grandparent
+
+ Scenario: Undo a tab close when the parent has already been closed - with children
+ # Close the child first, then the parent. When the child is restored
+ # it should be placed back at the root, and its previous child should
+ # be re-attached to it. (Not sure if this is the best behavior.)
+ When I open about:blank?grandparent
+ And I open about:blank?parent in a new related tab
+ And I open about:blank?child in a new related tab
+ And I open about:blank?leaf in a new related tab
+ And I run :tab-select ?child
+ And I run :tab-close
+ And I run :tab-select ?parent
+ And I run :tab-close
+ And I run :undo 2
+ Then the following tabs should be open:
+ - about:blank?child (active)
+ - about:blank?leaf
+ - about:blank?grandparent
+
+ Scenario: Undo a tab close when the child has already been closed
+ # Close the parent first, then the child. Make sure we don't crash
+ # when trying to re-parent the child.
+ When I open about:blank?grandparent
+ And I open about:blank?parent in a new related tab
+ And I open about:blank?child in a new related tab
+ And I run :tab-select ?parent
+ And I run :tab-close
+ And I run :tab-close
+ And I run :undo 2
+ Then the following tabs should be open:
+ - about:blank?grandparent
+ - about:blank?parent (active)
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
index b1e4bbaab..e77757880 100644
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -672,16 +672,19 @@ 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, sibling_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,
+ related_tab=related_tab, sibling_tab=sibling_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, sibling_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:
@@ -691,9 +694,17 @@ 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)
+ elif sibling_tab:
+ line = self.send_cmd(':open -t -S ' + 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/completion/test_models.py b/tests/unit/completion/test_models.py
index e0bce8f04..7c51a6a62 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -1410,14 +1410,15 @@ def test_forward_completion(tab_with_history, info):
def test_undo_completion(tabbed_browser_stubs, info):
"""Test :undo completion."""
entry1 = tabbedbrowser._UndoEntry(url=QUrl('https://example.org/'),
- history=None, index=None, pinned=None,
- created_at=datetime(2020, 1, 1))
+ history=None, index=None, pinned=None)
entry2 = tabbedbrowser._UndoEntry(url=QUrl('https://example.com/'),
- history=None, index=None, pinned=None,
- created_at=datetime(2020, 1, 2))
+ history=None, index=None, pinned=None)
entry3 = tabbedbrowser._UndoEntry(url=QUrl('https://example.net/'),
- history=None, index=None, pinned=None,
- created_at=datetime(2020, 1, 2))
+ history=None, index=None, pinned=None)
+
+ entry1.created_at = datetime(2020, 1, 1)
+ for entry in [entry2, entry3]:
+ entry.created_at = datetime(2020, 1, 2)
# Most recently closed is at the end
tabbed_browser_stubs[0].undo_stack = [
@@ -1442,19 +1443,22 @@ def test_undo_completion(tabbed_browser_stubs, info):
})
-def undo_completion_retains_sort_order(tabbed_browser_stubs, info):
+def test_undo_completion_retains_sort_order(tabbed_browser_stubs, info):
"""Test :undo completion sort order with > 10 entries."""
created_dt = datetime(2020, 1, 1)
- created_str = "2020-01-02 00:00"
+ created_str = "2020-01-01 00:00"
tabbed_browser_stubs[0].undo_stack = [
- tabbedbrowser._UndoEntry(
- url=QUrl(f'https://example.org/{idx}'),
- history=None, index=None, pinned=None,
- created_at=created_dt,
- )
- for idx in range(1, 11)
+ [
+ tabbedbrowser._UndoEntry(
+ url=QUrl(f'https://example.org/{idx}'),
+ history=None, index=None, pinned=None,
+ )
+ ]
+ for idx in reversed(range(1, 11))
]
+ for entries in tabbed_browser_stubs[0].undo_stack:
+ entries[0].created_at = created_dt
model = miscmodels.undo(info=info)
model.set_pattern('')
diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py
index a249a6d1c..59defeb50 100644
--- a/tests/unit/mainwindow/test_tabwidget.py
+++ b/tests/unit/mainwindow/test_tabwidget.py
@@ -11,6 +11,7 @@ import pytest
from unittest.mock import Mock
from qutebrowser.qt.gui import QIcon, QPixmap
+from qutebrowser.qt.widgets import QWidget
from qutebrowser.mainwindow import tabwidget
from qutebrowser.utils import usertypes
@@ -21,7 +22,14 @@ class TestTabWidget:
@pytest.fixture
def widget(self, qtbot, monkeypatch, config_stub):
- w = tabwidget.TabWidget(0)
+ class DummyParent(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.is_shutting_down = False
+ self.show()
+
+ w = tabwidget.TabWidget(0, parent=DummyParent())
+ w.resize(640, 480)
qtbot.add_widget(w)
monkeypatch.setattr(tabwidget.objects, 'backend',
usertypes.Backend.QtWebKit)
diff --git a/tests/unit/mainwindow/test_treetabbedbrowser.py b/tests/unit/mainwindow/test_treetabbedbrowser.py
new file mode 100644
index 000000000..3ae2b3b00
--- /dev/null
+++ b/tests/unit/mainwindow/test_treetabbedbrowser.py
@@ -0,0 +1,255 @@
+# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import pytest
+
+from qutebrowser.config.configtypes import NewTabPosition, NewChildPosition
+from qutebrowser.misc.notree import Node
+from qutebrowser.mainwindow import treetabbedbrowser, treetabwidget
+
+
+@pytest.fixture
+def mock_browser(mocker):
+ # Mock browser used as `self` below because we are actually testing mostly
+ # standalone functionality apart from the tab stack related counters.
+ # Which are also only defined in __init__, not on the class, so mock
+ # doesn't see them. Hence specifying them manually here.
+ browser = mocker.Mock(
+ spec=treetabbedbrowser.TreeTabbedBrowser,
+ widget=mocker.Mock(spec=treetabwidget.TreeTabWidget),
+ _tree_tab_child_rel_idx=0,
+ _tree_tab_sibling_rel_idx=0,
+ _tree_tab_toplevel_rel_idx=0,
+ )
+
+ # Sad little workaround to create a bound method on a mock, because
+ # _position_tab calls a method on self but we are using a mock as self to
+ # avoid initializing the whole tabbed browser class.
+ def reset_passthrough():
+ return treetabbedbrowser.TreeTabbedBrowser._reset_stack_counters(
+ browser
+ )
+ browser._reset_stack_counters = reset_passthrough
+
+ return browser
+
+
+class TestPositionTab:
+ """Test TreeTabbedBrowser._position_tab()."""
+
+ @pytest.mark.parametrize(
+ " relation, cur_node, pos, expected", [
+ ("sibling", "three", "first", "one",),
+ ("sibling", "three", "prev", "two",),
+ ("sibling", "three", "next", "three",),
+ ("sibling", "three", "last", "six",),
+ ("sibling", "one", "first", "root",),
+ ("sibling", "one", "prev", "root",),
+ ("sibling", "one", "next", "one",),
+ ("sibling", "one", "last", "seven",),
+
+ ("related", "one", "first", "one",),
+ ("related", "one", "last", "six",),
+ ("related", "two", "first", "two",),
+ ("related", "two", "last", "two",),
+
+ (None, "five", "first", "root",),
+ (None, "five", "prev", "root",),
+ (None, "five", "next", "one",),
+ (None, "five", "last", "seven",),
+ (None, "seven", "prev", "one",),
+ (None, "seven", "next", "seven",),
+ ]
+ )
+ def test_position_tab(
+ self,
+ config_stub,
+ mock_browser,
+ # parameterized
+ relation,
+ cur_node,
+ pos,
+ expected,
+ ):
+ """Test tree tab positioning.
+
+ How to use the parameters above:
+ * refer to the tree structure being passed to create_tree() below, that's
+ our starting state
+ * specify how the new node should be related to the current one
+ * specify cur_node by value, which is the tab currently focused when the
+ new tab is opened and the one the "sibling" and "related" arguments
+ refer to
+ * set "pos" which is the position of the new node in the list of
+ siblings it's going to end up in. It should be one of first, list, prev,
+ next (except the "related" relation doesn't support prev and next)
+ * specify the expected preceding node (the preceding sibling if there is
+ one, otherwise the parent) after the new node is positioned, "root" is
+ a valid value for this
+
+ Having the expectation being the preceding tab (sibling or parent) is
+ a bit limited, in particular if the new tab somehow ends up as a child
+ instead of the next sibling you wouldn't be able to tell those
+ situations apart. But I went this route to avoid having to specify
+ multiple trees in the parameters.
+ """
+ root = self.create_tree(
+ """
+ - one
+ - two
+ - three
+ - four
+ - five
+ - six
+ - seven
+ """,
+ )
+ new_node = Node("new", parent=root)
+
+ config_stub.val.tabs.new_position.stacking = False
+ self.call_position_tab(
+ mock_browser,
+ root,
+ cur_node,
+ new_node,
+ pos,
+ relation,
+ )
+
+ preceding_node = None
+ if new_node.parent.children[0] == new_node:
+ preceding_node = new_node.parent
+ else:
+ for n in new_node.parent.children:
+ if n.value == "new":
+ break
+ preceding_node = n
+ else:
+ pytest.fail("new tab not found")
+
+ assert preceding_node.value == expected
+
+ def call_position_tab(
+ self,
+ mock_browser,
+ root,
+ cur_node,
+ new_node,
+ pos,
+ relation,
+ background=False,
+ ):
+ sibling = related = False
+ if relation == "sibling":
+ sibling = True
+ elif relation == "related":
+ related = True
+ elif relation == "background":
+ background = True
+ elif relation is not None:
+ pytest.fail(
+ "Valid values for relation are: "
+ "sibling, related, background, None"
+ )
+
+ # This relation -> parent mapping is copied from
+ # TreeTabbedBrowser.tabopen().
+ cur_node = next(n for n in root.traverse() if n.value == cur_node)
+ assert not (related and sibling)
+ if related:
+ parent = cur_node
+ NewChildPosition().from_str(pos)
+ elif sibling:
+ parent = cur_node.parent
+ NewTabPosition().from_str(pos)
+ else:
+ parent = root
+ NewTabPosition().from_str(pos)
+
+ treetabbedbrowser.TreeTabbedBrowser._position_tab(
+ mock_browser,
+ cur_node=cur_node,
+ new_node=new_node,
+ pos=pos,
+ parent=parent,
+ sibling=sibling,
+ related=related,
+ background=background,
+ )
+
+ def create_tree(self, tree_str):
+ # Construct a notree.Node tree from the test string.
+ root = Node("root")
+ previous_indent = ''
+ previous_node = root
+ for line in tree_str.splitlines():
+ if not line.strip():
+ continue
+ indent, value = line.split("-")
+ node = Node(value.strip())
+ if len(indent) > len(previous_indent):
+ node.parent = previous_node
+ elif len(indent) == len(previous_indent):
+ node.parent = previous_node.parent
+ else:
+ # TODO: handle going up in jumps of more than one rank
+ node.parent = previous_node.parent.parent
+ previous_indent = indent
+ previous_node = node
+ return root
+
+ @pytest.mark.parametrize(
+ " test_tree, relation, pos, expected", [
+ ("tree_one", "sibling", "next", "one,two,new1,new2,new3",),
+ ("tree_one", "sibling", "prev", "one,new3,new2,new1,two",),
+ ("tree_one", None, "next", "one,two,new1,new2,new3",),
+ ("tree_one", None, "prev", "new3,new2,new1,one,two",),
+ ("tree_one", "related", "first", "one,two,new1,new2,new3",),
+ ("tree_one", "related", "last", "one,two,new1,new2,new3",),
+ ]
+ )
+ def test_position_tab_stacking(
+ self,
+ config_stub,
+ mock_browser,
+ # parameterized
+ test_tree,
+ relation,
+ pos,
+ expected,
+ ):
+ """Test tree tab positioning with tab stacking enabled.
+
+ With tab stacking enabled the first background tab should be opened
+ beside the current one, successive background tabs should be opened on
+ the other side of prior opened tabs, not beside the current tab.
+ This test covers what is currently implemented, I'm not sure all the
+ desired behavior is implemented currently though.
+ """
+ # Simpler tree here to make the assert string a bit simpler.
+ # Tab "two" is hardcoded as cur_tab.
+ root = self.create_tree(
+ """
+ - one
+ - two
+ """,
+ )
+ config_stub.val.tabs.new_position.stacking = True
+
+ for val in ["new1", "new2", "new3"]:
+ new_node = Node(val, parent=root)
+
+ self.call_position_tab(
+ mock_browser,
+ root,
+ "two",
+ new_node,
+ pos,
+ relation,
+ background=True,
+ )
+
+ actual = ",".join([n.value for n in root.traverse()])
+ actual = actual[len("root,"):]
+ assert actual == expected
diff --git a/tests/unit/misc/test_notree.py b/tests/unit/misc/test_notree.py
new file mode 100644
index 000000000..192dc6ac7
--- /dev/null
+++ b/tests/unit/misc/test_notree.py
@@ -0,0 +1,293 @@
+# SPDX-FileCopyrightText: Florian Bruhin (The-Compiler) <me@the-compiler.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""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