This commit is contained in:
toofar 2026-01-07 01:03:12 +05:30 committed by GitHub
commit 81f67477ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3285 additions and 250 deletions

View File

@ -132,6 +132,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.
@ -1001,7 +1007,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.
@ -1013,6 +1019,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.
@ -1402,7 +1410,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.
@ -1412,6 +1420,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
@ -1425,10 +1434,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
@ -1439,7 +1452,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.
@ -1451,6 +1464,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).
@ -1483,8 +1497,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.
@ -1512,8 +1531,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.
@ -1551,6 +1575,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'+

View File

@ -334,6 +334,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.
@ -348,6 +353,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.
@ -374,7 +380,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]+
@ -481,7 +487,7 @@ This setting can only be set in config.py.
Type: <<types,Dict>>
Default:
Default:
- +pass:[caret]+:
@ -750,6 +756,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]+:
@ -807,7 +824,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;]+
@ -881,7 +898,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]+
@ -1836,7 +1853,7 @@ Valid values:
* +history+
* +filesystem+
Default:
Default:
- +pass:[searchengines]+
- +pass:[quickmarks]+
@ -1941,7 +1958,7 @@ Valid values:
* +downloads+: Show a confirmation if downloads are running
* +never+: Never show a confirmation.
Default:
Default:
- +pass:[never]+
@ -1972,7 +1989,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]+
@ -2017,7 +2034,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]+
@ -2402,7 +2419,7 @@ The following levels are valid: `none`, `debug`, `info`, `warning`, `error`.
Type: <<types,Dict>>
Default:
Default:
- +pass:[error]+: +pass:[debug]+
- +pass:[info]+: +pass:[debug]+
@ -2962,7 +2979,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
Default:
Default:
- +pass:[gvim]+
- +pass:[-f]+
@ -3024,7 +3041,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
Default:
Default:
- +pass:[xterm]+
- +pass:[-e]+
@ -3040,7 +3057,7 @@ The following placeholders are defined:
Type: <<types,ShellCommand>>
Default:
Default:
- +pass:[xterm]+
- +pass:[-e]+
@ -3395,7 +3412,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]+
@ -3410,7 +3427,7 @@ Padding (in pixels) for hints.
Type: <<types,Padding>>
Default:
Default:
- +pass:[bottom]+: +pass:[0]+
- +pass:[left]+: +pass:[3]+
@ -3423,7 +3440,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]+
@ -3458,7 +3475,7 @@ This setting can only be set in config.py.
Type: <<types,Dict>>
Default:
Default:
- +pass:[all]+:
@ -4196,7 +4213,7 @@ Padding (in pixels) for the statusbar.
Type: <<types,Padding>>
Default:
Default:
- +pass:[bottom]+: +pass:[1]+
- +pass:[left]+: +pass:[0]+
@ -4249,7 +4266,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]+
@ -4334,7 +4351,7 @@ Padding (in pixels) for tab indicators.
Type: <<types,Padding>>
Default:
Default:
- +pass:[bottom]+: +pass:[2]+
- +pass:[left]+: +pass:[0]+
@ -4434,6 +4451,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.
@ -4456,7 +4544,7 @@ Padding (in pixels) around text for tabs.
Type: <<types,Padding>>
Default:
Default:
- +pass:[bottom]+: +pass:[0]+
- +pass:[left]+: +pass:[5]+
@ -4574,6 +4662,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.
@ -4593,7 +4683,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
@ -4612,6 +4702,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).
@ -4674,7 +4774,7 @@ Valid values:
* +query+
* +anchor+
Default:
Default:
- +pass:[path]+
- +pass:[query]+
@ -4716,7 +4816,7 @@ term, e.g. `:open google qutebrowser`.
Type: <<types,Dict>>
Default:
Default:
- +pass:[DEFAULT]+: +pass:[https://duckduckgo.com/?q={}]+
@ -4734,7 +4834,7 @@ URL parameters to strip when yanking a URL.
Type: <<types,List of String>>
Default:
Default:
- +pass:[ref]+
- +pass:[utm_source]+
@ -4795,7 +4895,7 @@ Available zoom levels.
Type: <<types,List of Perc>>
Default:
Default:
- +pass:[25%]+
- +pass:[33%]+
@ -4872,6 +4972,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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

219
doc/treetabs.md Normal file
View File

@ -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?

View File

@ -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(

View File

@ -9,6 +9,8 @@
import os.path
import shlex
import functools
import urllib.parse
import inspect
from typing import cast, Union, Optional
from collections.abc import Callable
@ -24,7 +26,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
@ -105,6 +107,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.
@ -116,6 +119,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
@ -128,10 +132,12 @@ 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 sibling:
self._ensure_tree_tabs("--sibling")
tabbed_browser.tabopen(url, background=background,
related=related, sibling=sibling)
else:
widget = self._current_widget()
widget.load_url(url)
@ -213,7 +219,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, recursive=False):
"""Helper function for tab_close be able to handle message.async.
Args:
@ -229,17 +236,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, recursive=recursive)
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, recursive=recursive)
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:
@ -248,15 +255,17 @@ 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 descendants (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)
close = functools.partial(self._tab_close, tab, prev,
next_, opposite, True, recursive)
tabbed_browser.tab_close_prompt_if_pinned(tab, force, close)
@cmdutils.register(instance='command-dispatcher', scope='window',
name='tab-pin')
@ -281,8 +290,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.
@ -296,6 +306,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.
@ -314,8 +326,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:
@ -454,11 +466,42 @@ class CommandDispatcher:
if not keep:
tabbed_browser.close_tab(tab, add_undo=False, transfer=True)
def _tree_tab_give(self, tabbed_browser, keep):
"""Recursive tab-give, move current tab and children to tabbed_browser."""
new_tab_map = {} # old_uid -> new tab
current_node = self._current_widget().node
for node in current_node.traverse(
notree.TraverseOrder.PRE,
render_collapsed=True
):
tab = tabbed_browser.tabopen(
node.value.url(),
related=False,
background=True,
)
new_tab_map[node.uid] = tab
if node.collapsed:
tab.node.collapsed = True
if node != current_node: # top level node has no parent
parent = new_tab_map[node.parent.uid].node
parent.children += (tab.node,)
tabbed_browser.widget.setCurrentWidget(new_tab_map[current_node.uid])
if not keep:
self._tabbed_browser.close_tab(
current_node.value,
add_undo=False,
transfer=True,
recursive=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.
@ -467,6 +510,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:
@ -498,14 +542,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,
@ -862,35 +910,71 @@ 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.
"""
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.
"""
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.
@ -972,7 +1056,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,
@ -983,11 +1068,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.
"""
@ -997,6 +1086,23 @@ class CommandDispatcher:
assert isinstance(index, str)
self._tab_focus_stack(index)
return
elif index == 'parent':
self._ensure_tree_tabs("parent")
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, "
@ -1022,7 +1128,11 @@ class CommandDispatcher:
@cmdutils.register(instance="command-dispatcher", scope="window")
@cmdutils.argument("index", choices=["+", "-", "start", "end"])
@cmdutils.argument("count", value=cmdutils.Value.count)
def tab_move(self, index: Union[str, int] = None, count: int = None) -> None:
def tab_move( # noqa: C901
self,
index: Union[str, int] = None,
count: int = None,
) -> None:
"""Move the current tab according to the argument and [count].
If neither is given, move it to the first position.
@ -1040,13 +1150,25 @@ class CommandDispatcher:
# 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
@ -1069,7 +1191,32 @@ 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:
tab = self._current_widget()
tree_root = self._tabbed_browser.widget.tree_root
# Lookup target nodes from display order list to match what the
# user sees in the tab bar.
nodes = list(tree_root.traverse(render_collapsed=False))[1:]
target_node = nodes[new_idx]
if tab.node in target_node.path:
raise cmdutils.CommandError("Can't move tab to a descendent"
" of itself")
tab.node.parent = None # detach the node now to avoid duplicate errors
target_siblings = list(target_node.parent.children)
new_idx_relative = target_siblings.index(target_node)
if cur_idx < new_idx:
# If moving the tab to a higher number, insert if after the
# target node to account for all the tabs shifting down.
new_idx_relative += 1
target_siblings.insert(new_idx_relative, tab.node)
target_node.parent.children = target_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)
@ -1884,3 +2031,149 @@ class CommandDispatcher:
log.misc.debug('state before fullscreen: {}'.format(
debug.qflags_key(Qt, window.state_before_fullscreen)))
def _ensure_tree_tabs(self, arg_name: Optional[str] = None):
"""Check if we are on a tree tabs enabled browser."""
if not self._tabbed_browser.is_treetabbedbrowser:
# Potentially fragile code to get the name of the command the user
# called. Get the calling functions via inspect, lookup the
# command object by looking for a command with the related unbound
# functions as its handler.
# Alternate options:
# 1. stash the cmd object on the function
# 2. duplicate the slugification of the function name (it's just _->-)
# 3. move this check into the Command object somehow (easy for
# disallowed commands, hard for disallowed args)
# 4. save the currently executing command somewhere
bound_func = getattr(self, inspect.stack()[1].function)
cmds = [
name
for name, cmd
in objects.commands.items()
if cmd.handler == bound_func.__func__
]
assert len(cmds) == 1
cmd_name = cmds[0]
arg_part = ""
if arg_name:
arg_part = f"argument `{arg_name}` "
msg = f"{cmd_name}: {arg_part}requires a window with tree tabs"
raise cmdutils.CommandError(msg)
@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
"""
self._ensure_tree_tabs()
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.
"""
self._ensure_tree_tabs()
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
"""
self._ensure_tree_tabs()
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.
"""
self._ensure_tree_tabs()
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.
"""
self._ensure_tree_tabs()
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)

View File

@ -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."""

View File

@ -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)

View File

@ -4,11 +4,13 @@
"""Models for the command completion."""
from typing import Optional
from typing import Optional, TYPE_CHECKING
from collections.abc import Sequence
from qutebrowser.completion.models.util import DeleteFuncType
from qutebrowser.qt.core import QAbstractItemModel
if TYPE_CHECKING:
from qutebrowser.completion.models.util import DeleteFuncType
class BaseCategory(QAbstractItemModel):
"""Abstract base class for categories of CompletionModels.
@ -21,4 +23,4 @@ class BaseCategory(QAbstractItemModel):
name: str
columns_to_filter: Sequence[int]
delete_func: Optional[DeleteFuncType] = None
delete_func: Optional["DeleteFuncType"] = None

View File

@ -8,6 +8,7 @@ from collections.abc import Sequence, Callable
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))

View File

@ -2311,6 +2311,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
@ -2376,7 +2410,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:
@ -2394,12 +2428,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.
@ -2435,6 +2473,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.
@ -2533,6 +2573,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:
@ -3876,6 +3922,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}

View File

@ -1954,6 +1954,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."""

View File

@ -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 %}

View File

@ -8,7 +8,7 @@ import binascii
import base64
import itertools
import functools
from typing import Optional, cast
from typing import Optional, cast, Union
from collections.abc import MutableSequence
from qutebrowser.qt import machinery
@ -196,7 +196,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)
@ -225,8 +225,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()
@ -500,8 +506,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

View File

@ -11,8 +11,7 @@ import functools
import weakref
import datetime
import dataclasses
from typing import (
Any, Optional)
from typing import Any, Optional, Union
from collections.abc import Mapping, MutableMapping, MutableSequence
from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication
@ -37,7 +36,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]]
@ -197,17 +221,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]
@ -245,6 +272,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:
@ -283,9 +313,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.
"""
@ -298,6 +330,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.
@ -379,7 +423,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.
@ -444,7 +488,7 @@ class TabbedBrowser(QWidget):
else:
yes_action()
def close_tab(self, tab, *, add_undo=True, new_undo=True, transfer=False):
def close_tab(self, tab, *, add_undo=True, new_undo=True, transfer=False, recursive=False):
"""Close a tab.
Args:
@ -463,7 +507,7 @@ class TabbedBrowser(QWidget):
if last_close == 'ignore' and count == 1:
return
self._remove_tab(tab, add_undo=add_undo, new_undo=new_undo)
self._remove_tab(tab, add_undo=add_undo, new_undo=new_undo, recursive=recursive)
if count == 1: # We just closed the last tab above.
if last_close == 'close':
@ -476,7 +520,15 @@ class TabbedBrowser(QWidget):
elif last_close == 'default-page':
self.load_url(config.val.url.default_page, newtab=True)
def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False):
def _remove_tab(
self,
tab,
*,
add_undo=True,
new_undo=True,
crashed=False,
recursive=False, # pylint: disable=unused-argument
):
"""Remove a tab from the tab list and delete it properly.
Args:
@ -486,6 +538,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
@ -496,37 +549,47 @@ class TabbedBrowser(QWidget):
tab.pending_removal = True
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():
# 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)
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
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")
return
idx = self.widget.indexOf(tab)
entry = self._undo_class.from_tab(tab, idx)
if not entry:
return
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."""
# Remove unused tab which may be created after the last tab is closed
@ -560,11 +623,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):
@ -578,7 +644,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):
@ -608,6 +674,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.
@ -652,7 +719,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)
@ -663,7 +730,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())
@ -1089,7 +1156,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
@ -1110,7 +1177,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:

View File

@ -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)

View File

@ -0,0 +1,409 @@
# 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
import functools
from typing import 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
from qutebrowser.qt.widgets import QTabBar
@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, recursive=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)
if recursive:
for descendent in tab.node.traverse(
order=notree.TraverseOrder.POST_R,
render_collapsed=False
):
self.tab_close_prompt_if_pinned(
descendent.value,
False,
functools.partial(
self._remove_tab,
descendent.value,
add_undo=add_undo,
new_undo=new_undo,
crashed=crashed,
recursive=False,
)
)
new_undo = False
return
node = tab.node
parent = node.parent
current_tab = self.current_tab()
# Override tabs.select_on_remove behavior to be tree aware.
# The default behavior is in QTabBar.removeTab(), by way of
# QTabWidget.removeTab(). But here we are detaching the tab from the
# tree before those methods get called, so if we want to have a tree
# aware behavior we need to implement that here by selecting the new
# tab before the closing the current one.
if tab == current_tab:
selection_behavior = self.widget.tabBar().selectionBehaviorOnRemove()
# Given a tree structure like:
# - one
# - two
# - three (active)
# If the setting is "prev" (aka left) we want to end up with tab
# "one" selected after closing tab "three". Switch to either the
# current tab's previous sibling or its parent.
if selection_behavior == QTabBar.SelectionBehavior.SelectLeftTab:
siblings = parent.children
rel_index = siblings.index(node)
if rel_index == 0:
next_tab = parent.value
else:
next_tab = siblings[rel_index-1].value
self.widget.setCurrentWidget(next_tab)
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, idx)
return tab
def _position_tab( # pylint: disable=too-many-positional-arguments
self,
cur_node: notree.Node,
new_node: notree.Node,
pos: str,
parent: notree.Node,
sibling: bool = False,
related: bool = True,
background: bool = None,
idx: int = 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 idx:
sibling_indices = [self.widget.indexOf(node.value) for node in siblings]
assert sibling_indices == sorted(sibling_indices)
sibling_indices.append(idx)
sibling_indices = sorted(sibling_indices)
rel_idx = sibling_indices.index(idx)
siblings.insert(rel_idx, new_node)
elif 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

View File

@ -0,0 +1,137 @@
# 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()
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()

345
qutebrowser/misc/notree.py Normal file
View File

@ -0,0 +1,345 @@
# 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, Generic
from collections.abc import Iterable, Sequence
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 (usually 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 `self` and all descendants.
Args:
order: a TraverseOrder object. See TraverseOrder documentation.
render_collapsed: whether to yield children of collapsed nodes
"""
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)

View File

@ -122,6 +122,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.
@ -282,13 +318,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
@ -456,6 +510,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'],
@ -463,13 +597,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)

View File

@ -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."""

View File

@ -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.removesuffix(new_tab_suffix)
new_tab = True
elif path.endswith(new_bg_tab_suffix):
path = path.removesuffix(new_bg_tab_suffix)
new_bg_tab = True
elif path.endswith(new_window_suffix):
path = path.removesuffix(new_window_suffix)
new_window = True
elif path.endswith(private_suffix):
path = path.removesuffix(private_suffix)
private = True
elif path.endswith(as_url_suffix):
path = path.removesuffix(as_url_suffix)
as_url = True
elif path.endswith(do_not_wait_suffix):
path = path.removesuffix(do_not_wait_suffix)
wait = False
for suffix, value in suffixes.items():
if path.endswith(suffix):
path = path.removesuffix(f" {suffix}")
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}"))
@ -612,53 +609,102 @@ def check_contents_json(quteproc, docstring):
@bdd.then(bdd.parsers.parse("the following tabs should be open:"))
def check_open_tabs(quteproc, docstring):
"""Check the list of open tabs in the session.
"""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 = docstring.splitlines()
assert len(session['windows']) == 1
window = session['windows'][0]
assert len(window['tabs']) == len(expected_tabs)
active_suffix = ' (active)'
pinned_suffix = ' (pinned)'
tabs = docstring.splitlines()
assert len(session['windows']) == 1
assert len(session['windows'][0]['tabs']) == len(tabs)
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)
# 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)
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
for i, line in enumerate(tabs):
line = line.strip()
assert line.startswith('- ')
line = line[2:] # remove "- " prefix
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
active = False
pinned = False
for uid in tree_node["children"]:
yield from tree_to_str(tree_data[uid], tree_data, indentation + 1)
while line.endswith(active_suffix) or line.endswith(pinned_suffix):
if line.endswith(active_suffix):
# active
line = line.removesuffix(active_suffix)
active = True
else:
# pinned
line = line.removesuffix(pinned_suffix)
pinned = True
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"]]
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
def normalize(line):
"""Normalize expected lines to match session lines.
if pinned:
assert current_page['pinned']
elif has_pinned:
assert not current_page['pinned']
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 '

View File

@ -475,3 +475,20 @@ Feature: Saving and loading sessions
- 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)
"""

View File

@ -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")

View File

@ -0,0 +1,398 @@
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: :tab-close --recursive with pinned 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 related tab
And I open data/numbers/4.txt in a new tab
And I run :tab-focus 1
And I run :cmd-run-with-count 2 tab-pin
And I run :tab-close --recursive
And I wait for "Asking question *" in the log
And I run :prompt-accept yes
Then the following tabs should be open:
"""
- data/numbers/4.txt
"""
Scenario: :tab-close --recursive with collapsed 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 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 :tab-focus 1
And I run :tab-close --recursive
Then the following tabs should be open:
"""
- data/numbers/4.txt
"""
Scenario: :tab-give --recursive with collapsed 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 sibling tab
And I open data/numbers/4.txt in a new related tab
And I open data/numbers/5.txt in a new tab
And I run :tab-focus 2
And I run :tree-tab-toggle-hide
And I run :tab-focus 1
And I run :tab-give --recursive
And I wait until data/numbers/4.txt is loaded
Then the session should look like:
"""
windows:
- tabs:
- history:
- url: http://localhost:*/data/numbers/5.txt
- tabs:
- history:
- url: http://localhost:*/data/numbers/1.txt
- history:
- url: http://localhost:*/data/numbers/3.txt
- history:
- url: http://localhost:*/data/numbers/4.txt
- history:
- url: http://localhost:*/data/numbers/2.txt
"""
And I run :window-only
And the following tabs should be open:
"""
- data/numbers/1.txt (active)
- data/numbers/3.txt (collapsed)
- data/numbers/4.txt
- data/numbers/2.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 down 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: Move a tab up to 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-move 2
Then the following tabs should be open:
"""
- data/numbers/1.txt
- data/numbers/4.txt
- data/numbers/2.txt
- data/numbers/3.txt
"""
Scenario: Move a tab within siblings
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 sibling tab
And I run :tab-move +
Then the following tabs should be open:
"""
- data/numbers/1.txt
- data/numbers/2.txt
- data/numbers/3.txt
"""
Scenario: Move a tab to end
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 2
And I run :tab-move end
Then the following tabs should be open:
"""
- data/numbers/1.txt
- data/numbers/3.txt
- data/numbers/4.txt
- data/numbers/2.txt
"""
Scenario: Move a tab to start
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-move start
Then the following tabs should be open:
"""
- data/numbers/4.txt
- data/numbers/1.txt
- data/numbers/2.txt
- data/numbers/3.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)
"""
Scenario: Tabs.select_on_remove prev selects previous sibling
When I open about:blank?one
And I open about:blank?two in a new related tab
And I open about:blank?three in a new tab
And I run :set tabs.select_on_remove prev
And I run :tab-close
And I run :config-unset tabs.select_on_remove
Then the following tabs should be open:
"""
- about:blank?one (active)
- about:blank?two
"""
Scenario: Tabs.select_on_remove prev selects parent
When I open about:blank?one
And I open about:blank?two in a new related tab
And I open about:blank?three in a new sibling tab
And I run :set tabs.select_on_remove prev
And I run :tab-close
And I run :config-unset tabs.select_on_remove
Then the following tabs should be open:
"""
- about:blank?one (active)
- about:blank?two
"""
Scenario: Tabs.select_on_remove prev can be overridden
When I open about:blank?one
And I open about:blank?two in a new related tab
And I open about:blank?three in a new tab
And I run :tab-select ?two
And I run :set tabs.select_on_remove prev
And I run :tab-close --next
And I run :config-unset tabs.select_on_remove
Then the following tabs should be open:
"""
- about:blank?one
- about:blank?three (active)
"""

View File

@ -738,16 +738,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:
@ -757,9 +760,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:

View File

@ -325,6 +325,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

View File

@ -1412,14 +1412,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 = [
@ -1444,19 +1445,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('')

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,296 @@
# 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(tree):
n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree
actual = list(n1.traverse())
rendered = n1.render()
assert len(actual) == len(rendered)
print("\n".join('\t'.join((str(t[0]), t[1][0] + str(t[1][1]))) for t in zip(actual, rendered)))
assert actual == [n1, n2, n4, n5, n3, n6, n7, n8, n9, n10, n11]
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