diff options
25 files changed, 2210 insertions, 128 deletions
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 4d1610970..1c4c47e7f 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -130,6 +130,12 @@ possible to run or bind multiple commands by separating them with `;;`. |<<tab-prev,tab-prev>>|Switch to the previous tab, or switch [count] tabs back. |<<tab-select,tab-select>>|Select tab by index or url/title best match. |<<tab-take,tab-take>>|Take a tab from another window. +|<<tree-tab-create-group,tree-tab-create-group>>|Wrapper around :open qute://treegroup/name. Correctly escapes names. +|<<tree-tab-cycle-hide,tree-tab-cycle-hide>>|Hides levels of descendents: children, grandchildren, and so on. +|<<tree-tab-demote,tree-tab-demote>>|Demote a tab making it children of its previous adjacent sibling. +|<<tree-tab-promote,tree-tab-promote>>|Promote a tab so it becomes next sibling of its parent. +|<<tree-tab-suspend-children,tree-tab-suspend-children>>|Suspends all descendent of a tab to reduce memory usage. +|<<tree-tab-toggle-hide,tree-tab-toggle-hide>>|If the current tab's children are shown hide them, and vice-versa. |<<unbind,unbind>>|Unbind a keychain. |<<undo,undo>>|Re-open the last closed tab(s) or window. |<<version,version>>|Show version information. @@ -999,7 +1005,7 @@ Do nothing. [[open]] === open -Syntax: +:open [*--related*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ +Syntax: +:open [*--related*] [*--sibling*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ Open a URL in the current/[count]th tab. @@ -1011,6 +1017,8 @@ If the URL contains newlines, each line gets opened in its own tab. ==== optional arguments * +*-r*+, +*--related*+: If opening a new tab, position the tab as related to the current one (like clicking on a link). +* +*-S*+, +*--sibling*+: If opening a new tab, position the as a sibling of the current one. + * +*-b*+, +*--bg*+: Open in a new background tab. * +*-t*+, +*--tab*+: Open in a new tab. * +*-w*+, +*--window*+: Open in a new window. @@ -1400,7 +1408,7 @@ Duplicate the current tab. [[tab-close]] === tab-close -Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*]+ +Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*] [*--recursive*]+ Close the current/[count]th tab. @@ -1410,6 +1418,7 @@ Close the current/[count]th tab. * +*-o*+, +*--opposite*+: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. * +*-f*+, +*--force*+: Avoid confirmation for pinned tabs. +* +*-r*+, +*--recursive*+: Close all descendents (tree-tabs) as well as current tab ==== count The tab index to close @@ -1423,10 +1432,14 @@ Select the tab given as argument/[count]. If neither count nor index are given, it behaves like tab-next. If both are given, use count. ==== positional arguments -* +'index'+: The tab index to focus, starting with 1. The special value `last` focuses the last focused tab (regardless of count), - and `stack-prev`/`stack-next` traverse a stack of visited - tabs. Negative indices count from the end, such that -1 is - the last tab. +* +'index'+: The tab index to focus, starting with 1. Negative indices count from the end, such that -1 is the last tab. Special + values are: + - `last` focuses the last focused tab (regardless of + count). + - `parent` focuses the parent tab in the tree hierarchy, + if `tabs.tree_tabs` is enabled. + - `stack-prev`/`stack-next` traverse a stack of visited + tabs. ==== optional arguments @@ -1437,7 +1450,7 @@ The tab index to focus, starting with 1. [[tab-give]] === tab-give -Syntax: +:tab-give [*--keep*] [*--private*] ['win-id']+ +Syntax: +:tab-give [*--keep*] [*--private*] [*--recursive*] ['win-id']+ Give the current tab to a new or existing window if win_id given. @@ -1449,6 +1462,7 @@ If no win_id is given, the tab will get detached into a new window. ==== optional arguments * +*-k*+, +*--keep*+: If given, keep the old tab around. * +*-p*+, +*--private*+: If the tab should be detached into a private instance. +* +*-r*+, +*--recursive*+: Whether to move the entire subtree starting at the tab. ==== count Overrides win_id (index starts at 1 for win_id=0). @@ -1481,8 +1495,13 @@ The tab index to mute or unmute [[tab-next]] === tab-next +Syntax: +:tab-next [*--sibling*]+ + Switch to the next tab, or switch [count] tabs forward. +==== optional arguments +* +*-s*+, +*--sibling*+: Whether to focus the next tree sibling. + ==== count How many tabs to switch forward. @@ -1510,8 +1529,13 @@ The tab index to pin or unpin [[tab-prev]] === tab-prev +Syntax: +:tab-prev [*--sibling*]+ + Switch to the previous tab, or switch [count] tabs back. +==== optional arguments +* +*-s*+, +*--sibling*+: Whether to focus the previous tree sibling. + ==== count How many tabs to switch back. @@ -1549,6 +1573,59 @@ Take a tab from another window. ==== note * This command does not split arguments after the last argument and handles quotes literally. +[[tree-tab-create-group]] +=== tree-tab-create-group +Syntax: +:tree-tab-create-group [*--related*] [*--background*] 'name' ['name' ...]+ + +Wrapper around :open qute://treegroup/name. Correctly escapes names. + +Example: `:tree-tab-create-group Foo Bar` calls `:open qute://treegroup/Foo%20Bar` + +==== positional arguments +* +'name'+: Name of the group to create + +==== optional arguments +* +*-r*+, +*--related*+: whether to open as a child of current tab or under root +* +*-b*+, +*--background*+: whether to open in a background tab + +[[tree-tab-cycle-hide]] +=== tree-tab-cycle-hide +Hides levels of descendents: children, grandchildren, and so on. + +==== count +How many levels to hide. + +[[tree-tab-demote]] +=== tree-tab-demote +Demote a tab making it children of its previous adjacent sibling. + +Observes tabs.new_position.tree.demote in positioning the tab among new siblings. + +[[tree-tab-promote]] +=== tree-tab-promote +Promote a tab so it becomes next sibling of its parent. + +Observes tabs.new_position.tree.promote in positioning the tab among new siblings. + +==== count +How many levels the tabs should be promoted to + +[[tree-tab-suspend-children]] +=== tree-tab-suspend-children +Suspends all descendent of a tab to reduce memory usage. + +==== count +Target tab. + +[[tree-tab-toggle-hide]] +=== tree-tab-toggle-hide +If the current tab's children are shown hide them, and vice-versa. + +This toggles the current tab's node's `collapsed` attribute. + +==== count +Which tab to collapse + [[unbind]] === unbind Syntax: +:unbind [*--mode* 'mode'] 'key'+ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 9ef0a014d..611f2f98b 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -332,6 +332,11 @@ |<<tabs.mousewheel_switching,tabs.mousewheel_switching>>|Switch between tabs using the mouse wheel. |<<tabs.new_position.related,tabs.new_position.related>>|Position of new tabs opened from another tab. |<<tabs.new_position.stacking,tabs.new_position.stacking>>|Stack related tabs on top of each other when opened consecutively. +|<<tabs.new_position.tree.demote,tabs.new_position.tree.demote>>|Position at which a tab is placed among its new siblings after being demoted with `:tree-tab-demote` +|<<tabs.new_position.tree.new_child,tabs.new_position.tree.new_child>>|Position of new children among siblings, e.g. after calling `:open --relative ...` or following a link. +|<<tabs.new_position.tree.new_sibling,tabs.new_position.tree.new_sibling>>|Position of siblings, e.g. after calling `:open --sibling ...`. +|<<tabs.new_position.tree.new_toplevel,tabs.new_position.tree.new_toplevel>>|Position of new top-level tabs related to the topmost ancestor of current tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. +|<<tabs.new_position.tree.promote,tabs.new_position.tree.promote>>|Position at which a tab is placed among its new siblings after being promoted with `:tree-tab-promote` |<<tabs.new_position.unrelated,tabs.new_position.unrelated>>|Position of new tabs which are not opened from another tab. |<<tabs.padding,tabs.padding>>|Padding (in pixels) around text for tabs. |<<tabs.pinned.frozen,tabs.pinned.frozen>>|Force pinned tabs to stay at fixed URL. @@ -372,7 +377,7 @@ The keys of the given dictionary are the aliases, while the values are the comma Type: <<types,Dict>> -Default: +Default: - +pass:[q]+: +pass:[close]+ - +pass:[qa]+: +pass:[quit]+ @@ -479,7 +484,7 @@ This setting can only be set in config.py. Type: <<types,Dict>> -Default: +Default: - +pass:[caret]+: @@ -748,6 +753,17 @@ Default: * +pass:[yp]+: +pass:[yank pretty-url]+ * +pass:[yt]+: +pass:[yank title]+ * +pass:[yy]+: +pass:[yank]+ +* +pass:[zG]+: +pass:[set-cmd-text -s :tree-tab-create-group]+ +* +pass:[zH]+: +pass:[tree-tab-promote]+ +* +pass:[zJ]+: +pass:[tab-next -s]+ +* +pass:[zK]+: +pass:[tab-prev -s]+ +* +pass:[zL]+: +pass:[tree-tab-demote]+ +* +pass:[zO]+: +pass:[set-cmd-text --space :open -tS]+ +* +pass:[za]+: +pass:[tree-tab-toggle-hide]+ +* +pass:[zd]+: +pass:[tab-close -r]+ +* +pass:[zg]+: +pass:[set-cmd-text -s :tree-tab-create-group -r]+ +* +pass:[zo]+: +pass:[set-cmd-text --space :open -tr]+ +* +pass:[zp]+: +pass:[tab-focus parent]+ * +pass:[{{]+: +pass:[navigate prev -t]+ * +pass:[}}]+: +pass:[navigate next -t]+ - +pass:[passthrough]+: @@ -805,7 +821,7 @@ Note that when a key is bound (via `bindings.default` or `bindings.commands`), t Type: <<types,Dict>> -Default: +Default: - +pass:[<Ctrl-6>]+: +pass:[<Ctrl-^>]+ - +pass:[<Ctrl-Enter>]+: +pass:[<Ctrl-Return>]+ @@ -879,7 +895,7 @@ May be a single color to use for all columns or a list of three colors, one for Type: <<types,List of QtColor, or QtColor>> -Default: +Default: - +pass:[white]+ - +pass:[white]+ @@ -1833,7 +1849,7 @@ Valid values: * +history+ * +filesystem+ -Default: +Default: - +pass:[searchengines]+ - +pass:[quickmarks]+ @@ -1938,7 +1954,7 @@ Valid values: * +downloads+: Show a confirmation if downloads are running * +never+: Never show a confirmation. -Default: +Default: - +pass:[never]+ @@ -1969,7 +1985,7 @@ need to find the link to the raw `.txt` file (e.g. by extracting it from the Type: <<types,List of Url>> -Default: +Default: - +pass:[https://easylist.to/easylist/easylist.txt]+ - +pass:[https://easylist.to/easylist/easyprivacy.txt]+ @@ -2014,7 +2030,7 @@ The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists. Type: <<types,List of Url>> -Default: +Default: - +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+ @@ -2396,7 +2412,7 @@ The following levels are valid: `none`, `debug`, `info`, `warning`, `error`. Type: <<types,Dict>> -Default: +Default: - +pass:[error]+: +pass:[debug]+ - +pass:[info]+: +pass:[debug]+ @@ -2957,7 +2973,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[gvim]+ - +pass:[-f]+ @@ -3019,7 +3035,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[xterm]+ - +pass:[-e]+ @@ -3035,7 +3051,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[xterm]+ - +pass:[-e]+ @@ -3390,7 +3406,7 @@ Comma-separated list of regular expressions to use for 'next' links. Type: <<types,List of Regex>> -Default: +Default: - +pass:[\bnext\b]+ - +pass:[\bmore\b]+ @@ -3405,7 +3421,7 @@ Padding (in pixels) for hints. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[3]+ @@ -3418,7 +3434,7 @@ Comma-separated list of regular expressions to use for 'prev' links. Type: <<types,List of Regex>> -Default: +Default: - +pass:[\bprev(ious)?\b]+ - +pass:[\bback\b]+ @@ -3453,7 +3469,7 @@ This setting can only be set in config.py. Type: <<types,Dict>> -Default: +Default: - +pass:[all]+: @@ -4157,7 +4173,7 @@ Padding (in pixels) for the statusbar. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[1]+ - +pass:[left]+: +pass:[0]+ @@ -4210,7 +4226,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]+ @@ -4295,7 +4311,7 @@ Padding (in pixels) for tab indicators. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[2]+ - +pass:[left]+: +pass:[0]+ @@ -4395,6 +4411,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. @@ -4417,7 +4504,7 @@ Padding (in pixels) around text for tabs. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[5]+ @@ -4535,6 +4622,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. @@ -4554,7 +4643,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 @@ -4573,6 +4662,15 @@ 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). @@ -4635,7 +4733,7 @@ Valid values: * +query+ * +anchor+ -Default: +Default: - +pass:[path]+ - +pass:[query]+ @@ -4677,7 +4775,7 @@ term, e.g. `:open google qutebrowser`. Type: <<types,Dict>> -Default: +Default: - +pass:[DEFAULT]+: +pass:[https://duckduckgo.com/?q={}]+ @@ -4695,7 +4793,7 @@ URL parameters to strip with `:yank url`. Type: <<types,List of String>> -Default: +Default: - +pass:[ref]+ - +pass:[utm_source]+ @@ -4756,7 +4854,7 @@ Available zoom levels. Type: <<types,List of Perc>> -Default: +Default: - +pass:[25%]+ - +pass:[33%]+ @@ -4832,6 +4930,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v When setting from a string, pass a json-like list, e.g. `["one", "two"]`. |ListOrValue|A list of values, or a single value. |LogLevel|A logging level. +|NewChildPosition|How new children are positioned. |NewTabPosition|How new tabs are positioned. |Padding|Setting for paddings around elements. |Perc|A percentage. diff --git a/doc/img/treetabs/tree_tabs_new_tab_types.png b/doc/img/treetabs/tree_tabs_new_tab_types.png Binary files differnew file mode 100644 index 000000000..fdca0e01d --- /dev/null +++ b/doc/img/treetabs/tree_tabs_new_tab_types.png diff --git a/doc/img/treetabs/tree_tabs_overview_detail.png b/doc/img/treetabs/tree_tabs_overview_detail.png Binary files differnew file mode 100644 index 000000000..fc5610ecf --- /dev/null +++ b/doc/img/treetabs/tree_tabs_overview_detail.png diff --git a/doc/treetabs.md b/doc/treetabs.md new file mode 100644 index 000000000..7b1496201 --- /dev/null +++ b/doc/treetabs.md @@ -0,0 +1,212 @@ +# 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 create +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 into, has picked up some new behaviour to + decide where in relation to the current tab a new one should go. It has a + new `--sibling` argument and the `--related` argument, as well as `--tab` and + `-background`, has some additional meaning. +* `: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 who's behaviour will be modified 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`: `set-cmd-text -s :tree-tab-create-group -r` - r = related +* `zG`: `set-cmd-text -s :tree-tab-create-group` +* `za`: `tree-tab-toggle-hide` - same binding as vim folds +* `zp`: `tab-focus parent` +* `zo`: `set-cmd-text --space :open -tr` - r = related +* `zO`: `set-cmd-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 tree the tree structures 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 behaviour 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. It also handles +making sure tabs are moved to indexes corresponding to their traversal order +in the tree if any changes to the tree structure happen via the +`tree_tab_update()` method that is called from several places. + +A fair amount of tree tab specific code lives in *commands.py*. The six new +commands have been added, as well as a customization so that these commands +don't show up in the command completion if the tree tabs feature isn't +enabled. The commands for manipulating the tree structure do very little but +call out to other pieces of code, either the browser or the tree structure. +Of note are the two commands `tree_tab_create_group()` and +`tree_tab_suspend_children()` which use the scheme handlers `qute://treegroup` +(new) and `qute://back` (existing). + +Beyond those six new commands quite a few existing commands to do with +manipulating tabs have seen some tree tab specific code paths added, some of +them quite complex and with little shared with the existing code paths. Common +themes beyond handling new arguments are dealing with recursive operations and +collapsed nodes. + +something something sessions.py + +Other stuff, like tree group page + +## Outstanding issues? Questions? diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 4d14c9cd7..3d5003254 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -40,6 +40,8 @@ if TYPE_CHECKING: from qutebrowser.browser.webengine.webview import WebEngineView from qutebrowser.browser.webkit.webview import WebView +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.misc.notree import Node tab_id_gen = itertools.count(0) _WidgetType = Union["WebView", "WebEngineView"] @@ -1052,6 +1054,11 @@ class AbstractTab(QWidget): self, parent=self) self.backend: Optional[usertypes.Backend] = None + if parent and isinstance(parent, TreeTabWidget): + self.node: AbstractTab = Node(self, parent=parent.tree_root) + else: + self.node: AbstractTab = Node(self, parent=None) + # If true, this tab has been requested to be removed (or is removed). self.pending_removal = False self.shutting_down.connect(functools.partial( diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 83a846b85..d7212d6ce 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -7,6 +7,7 @@ import os.path import shlex import functools +import urllib.parse from typing import cast, Callable, Dict, Union, Optional from qutebrowser.qt.widgets import QApplication, QTabBar @@ -21,7 +22,7 @@ from qutebrowser.keyinput import modeman, keyutils from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, standarddir, debug) from qutebrowser.utils.usertypes import KeyMode -from qutebrowser.misc import editor, guiprocess, objects +from qutebrowser.misc import editor, guiprocess, objects, notree from qutebrowser.completion.models import urlmodel, miscmodels from qutebrowser.mainwindow import mainwindow, windowundo @@ -99,6 +100,7 @@ class CommandDispatcher: background: bool = False, window: bool = False, related: bool = False, + sibling: bool = False, private: Optional[bool] = None, ) -> None: """Helper function to open a page. @@ -110,6 +112,7 @@ class CommandDispatcher: window: Whether to open in a new window private: If opening a new window, open it in private browsing mode. If not given, inherit the current window's mode. + sibling: Open tab in a sibling node of the currently focused tab. """ urlutils.raise_cmdexc_if_invalid(url) tabbed_browser = self._tabbed_browser @@ -122,10 +125,16 @@ class CommandDispatcher: tabbed_browser = self._new_tabbed_browser(private) tabbed_browser.tabopen(url) tabbed_browser.window().show() - elif tab: - tabbed_browser.tabopen(url, background=False, related=related) - elif background: - tabbed_browser.tabopen(url, background=True, related=related) + elif tab or background: + if tabbed_browser.is_treetabbedbrowser: + tabbed_browser.tabopen(url, background=background, + related=related, sibling=sibling) + elif sibling: + raise cmdutils.CommandError("--sibling flag only works with \ + tree-tab enabled") + else: + tabbed_browser.tabopen(url, background=background, + related=related) else: widget = self._current_widget() widget.load_url(url) @@ -207,7 +216,8 @@ class CommandDispatcher: "{!r}!".format(conf_selection)) return None - def _tab_close(self, tab, prev=False, next_=False, opposite=False): + def _tab_close(self, tab, prev=False, next_=False, + opposite=False, new_undo=True): """Helper function for tab_close be able to handle message.async. Args: @@ -223,17 +233,17 @@ class CommandDispatcher: opposite) if selection_override is None: - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) else: old_selection_behavior = tabbar.selectionBehaviorOnRemove() tabbar.setSelectionBehaviorOnRemove(selection_override) - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) def tab_close(self, prev=False, next_=False, opposite=False, - force=False, count=None): + force=False, count=None, recursive=False): """Close the current/[count]th tab. Args: @@ -242,15 +252,36 @@ class CommandDispatcher: opposite: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. force: Avoid confirmation for pinned tabs. + recursive: Close all descendents (tree-tabs) as well as current tab count: The tab index to close, or None """ tab = self._cntwidget(count) + tabbed_browser = self._tabbed_browser if tab is None: return - close = functools.partial(self._tab_close, tab, prev, - next_, opposite) - - self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) + if (tabbed_browser.is_treetabbedbrowser and recursive and not + tab.node.collapsed): + # if collapsed, recursive is the same as normal close + new_undo = True # only for first one + for descendent in tab.node.traverse(notree.TraverseOrder.POST_R, + True): + if self._tabbed_browser.widget.indexOf(descendent.value) > -1: + close = functools.partial(self._tab_close, + descendent.value, prev, next_, + opposite, new_undo) + tabbed_browser.tab_close_prompt_if_pinned(descendent.value, force, + close) + new_undo = False + else: + tab = descendent.value + tab.private_api.shutdown() + tab.deleteLater() + else: + # this also applied to closing collapsed tabs + # logic for that is in TreeTabbedBrowser + close = functools.partial(self._tab_close, tab, prev, + next_, opposite) + tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') @@ -275,8 +306,9 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @cmdutils.argument('url', completion=urlmodel.url) + @cmdutils.argument('sibling', flag='S') @cmdutils.argument('count', value=cmdutils.Value.count) - def openurl(self, url=None, related=False, + def openurl(self, url=None, related=False, sibling=False, bg=False, tab=False, window=False, count=None, secure=False, private=False): """Open a URL in the current/[count]th tab. @@ -290,6 +322,8 @@ class CommandDispatcher: window: Open in a new window. related: If opening a new tab, position the tab as related to the current one (like clicking on a link). + sibling: If opening a new tab, position the as a sibling of the + current one. count: The tab index to open the URL in, or None. secure: Force HTTPS. private: Open a new window in private browsing mode. @@ -308,8 +342,8 @@ class CommandDispatcher: bg = True if tab or bg or window or private: - self._open(cur_url, tab, bg, window, related=related, - private=private) + self._open(cur_url, tab, bg, window, private=private, + related=related, sibling=sibling) else: curtab = self._cntwidget(count) if curtab is None: @@ -448,11 +482,46 @@ class CommandDispatcher: if not keep: tabbed_browser.close_tab(tab, add_undo=False, transfer=True) + def _tree_tab_give(self, tabbed_browser, keep): + """Helper function to simplify tab-give.""" + # first pass: open tabs and save the uids of the new nodes + uid_map = {} # old_uid -> new_uid + traversed = list(self._current_widget().node.traverse()) + for node in traversed: + tab = tabbed_browser.tabopen( + node.value.url(), + related=False, + ) + uid_map[node.uid] = tab.node.uid + + # second pass: copy tree structure over + newroot = tabbed_browser.widget.tree_root + oldroot = self._tabbed_browser.widget.tree_root + for node in traversed: + if node.parent.uid in uid_map: + uid = uid_map[node.uid] + new_node = newroot.get_descendent_by_uid(uid) + parent_uid = uid_map[node.parent.uid] + new_parent = newroot.get_descendent_by_uid(parent_uid) + new_node.parent = new_parent + + # third pass: remove tabs from old window, children first this time to + # avoid having to re-parent things when traversing. + if not keep: + for node in self._current_widget().node.traverse( + notree.TraverseOrder.POST_R, + render_collapsed=False, + ): + self._tabbed_browser.close_tab(node.value, + add_undo=False, + transfer=True) + @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('win_id', completion=miscmodels.window) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_give(self, win_id: int = None, keep: bool = False, - count: int = None, private: bool = False) -> None: + count: int = None, private: bool = False, + recursive: bool = False) -> None: """Give the current tab to a new or existing window if win_id given. If no win_id is given, the tab will get detached into a new window. @@ -461,6 +530,7 @@ class CommandDispatcher: win_id: The window ID of the window to give the current tab to. keep: If given, keep the old tab around. count: Overrides win_id (index starts at 1 for win_id=0). + recursive: Whether to move the entire subtree starting at the tab. private: If the tab should be detached into a private instance. """ if config.val.tabs.tabs_are_windows: @@ -492,14 +562,18 @@ class CommandDispatcher: raise cmdutils.CommandError( "The window with id {} is not private".format(win_id)) - tabbed_browser.tabopen(self._current_url()) + if recursive and tabbed_browser.is_treetabbedbrowser: + self._tree_tab_give(tabbed_browser, keep) + else: + tabbed_browser.tabopen(self._current_url()) + if not keep: + self._tabbed_browser.close_tab(self._current_widget(), + add_undo=False, + transfer=True) + # Make sure the tabbed browser is shown in case we created a new one + # when detaching. tabbed_browser.window().show() - if not keep: - self._tabbed_browser.close_tab(self._current_widget(), - add_undo=False, - transfer=True) - def _back_forward( self, *, tab: bool, @@ -876,43 +950,79 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_prev(self, count=1): + def tab_prev(self, count=1, sibling=False): """Switch to the previous tab, or switch [count] tabs back. Args: count: How many tabs to switch back. + sibling: Whether to focus the previous tree sibling. """ if self._count() == 0: # Running :tab-prev after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() - count - if newidx >= 0: - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx - count + if new_idx >= 0 or config.val.tabs.wrap: + target_node = siblings[(node_idx-count) % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("First sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("First tab") + newidx = self._current_index() - count + if newidx >= 0: + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("First tab") @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_next(self, count=1): + def tab_next(self, count=1, sibling=False): """Switch to the next tab, or switch [count] tabs forward. Args: count: How many tabs to switch forward. + sibling: Whether to focus the next tree sibling. """ if self._count() == 0: # Running :tab-next after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() + count - if newidx < self._count(): - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx + count + if new_idx < len(siblings) or config.val.tabs.wrap: + target_node = siblings[new_idx % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("Last sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("Last tab") + newidx = self._current_index() + count + if newidx < self._count(): + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("Last tab") def _resolve_tab_index(self, index): """Resolve a tab index to the tabbedbrowser and tab. @@ -992,7 +1102,8 @@ class CommandDispatcher: tabbed_browser.widget.setCurrentWidget(tab) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'], + @cmdutils.argument('index', choices=['last', 'parent', + 'stack-next', 'stack-prev'], completion=miscmodels.tab_focus) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_focus(self, index: Union[str, int] = None, @@ -1003,11 +1114,15 @@ class CommandDispatcher: If both are given, use count. Args: - index: The tab index to focus, starting with 1. The special value - `last` focuses the last focused tab (regardless of count), - and `stack-prev`/`stack-next` traverse a stack of visited - tabs. Negative indices count from the end, such that -1 is - the last tab. + index: The tab index to focus, starting with 1. Negative indices + count from the end, such that -1 is the last tab. Special + values are: + - `last` focuses the last focused tab (regardless of + count). + - `parent` focuses the parent tab in the tree hierarchy, + if `tabs.tree_tabs` is enabled. + - `stack-prev`/`stack-next` traverse a stack of visited + tabs. count: The tab index to focus, starting with 1. no_last: Whether to avoid focusing last tab if already focused. """ @@ -1017,6 +1132,22 @@ class CommandDispatcher: assert isinstance(index, str) self._tab_focus_stack(index) return + elif index == 'parent' and self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + path = node.path + if count: + if count < len(path): + path_idx = 0 - count - 1 # path[-1] is node, so shift by 1 + else: + path_idx = 1 # first non-root node + else: + path_idx = -2 # immediate parent (path[-1] is node) + + target_node = path[path_idx] + if node is target_node or target_node.value is None: + raise cmdutils.CommandError("Tab has no parent! ") + target_tab = target_node.value + index = self._tabbed_browser.widget.indexOf(target_tab) + 1 elif index is None: message.warning( "Using :tab-focus without count is deprecated, " @@ -1056,17 +1187,31 @@ class CommandDispatcher: If moving absolutely: New position (default: 0). This overrides the index argument, if given. """ + # pylint: disable=invalid-unary-operand-type + # https://github.com/PyCQA/pylint/issues/1472 if index in ["+", "-"]: # relative moving new_idx = self._current_index() delta = 1 if count is None else count - if index == "-": - new_idx -= delta - elif index == "+": # pragma: no branch - new_idx += delta - if config.val.tabs.wrap: - new_idx %= self._count() + if self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + parent = node.parent + siblings = list(parent.children) + + if len(siblings) <= 1: + return + rel_idx = siblings.index(node) + rel_idx += delta if index == '+' else - delta + rel_idx %= len(siblings) + new_idx = self._tabbed_browser.widget.indexOf( + siblings[rel_idx].value) + + else: + new_idx += delta if index == '+' else - delta + + if config.val.tabs.wrap: + new_idx %= self._count() else: # pylint: disable=else-if-used # absolute moving @@ -1089,7 +1234,34 @@ class CommandDispatcher: cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') cmdutils.check_overflow(new_idx, 'int') - self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) + + if self._tabbed_browser.is_treetabbedbrowser: + # self._tree_tab_move(new_idx) + new_idx += 1 # tree-tabs indexes start at 1 (0 is hidden root tab) + tab = self._current_widget() + + # traverse order is the same as display order + # so indexing works correctly + tree_root = self._tabbed_browser.widget.tree_root + tabs = list(tree_root.traverse(render_collapsed=False)) + target_node = tabs[new_idx] + if tab.node in target_node.path: + raise cmdutils.CommandError("Can't move tab to a descendent" + " of itself") + + new_parent = target_node.parent + # we need index relative to parent for correct placement + dest_tab = tabs[new_idx] + new_idx_relative = new_parent.children.index(dest_tab) + + tab.node.parent = None # avoid duplicate errors + siblings = list(new_parent.children) + siblings.insert(new_idx_relative, tab.node) + new_parent.children = siblings + + self._tabbed_browser.widget.tree_tab_update() + else: + self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) @@ -1905,3 +2077,123 @@ class CommandDispatcher: log.misc.debug('state before fullscreen: {}'.format( debug.qflags_key(Qt, window.state_before_fullscreen))) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_promote(self, count=1): + """Promote a tab so it becomes next sibling of its parent. + + Observes tabs.new_position.tree.promote in positioning the tab among + new siblings. + + Args: + count: How many levels the tabs should be promoted to + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + config_position = config.val.tabs.new_position.tree.promote + try: + self._current_widget().node.promote(count, config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no parent!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_demote(self): + """Demote a tab making it children of its previous adjacent sibling. + + Observes tabs.new_position.tree.demote in positioning the tab among new + siblings. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + cur_node = self._current_widget().node + + config_position = config.val.tabs.new_position.tree.demote + try: + cur_node.demote(config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no previous sibling!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_toggle_hide(self, count=None): + """If the current tab's children are shown hide them, and vice-versa. + + This toggles the current tab's node's `collapsed` attribute. + + Args: + count: Which tab to collapse + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + tab = self._cntwidget(count) + if not tab.node.children: + return + tab.node.collapsed = not tab.node.collapsed + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_cycle_hide(self, count=1): + """Hides levels of descendents: children, grandchildren, and so on. + + Args: + count: How many levels to hide. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + while count > 0: + tab = self._current_widget() + self._tabbed_browser.cycle_hide_tab(tab.node) + count -= 1 + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_create_group(self, *name, related=False, + background=False): + """Wrapper around :open qute://treegroup/name. Correctly escapes names. + + Example: `:tree-tab-create-group Foo Bar` calls + `:open qute://treegroup/Foo%20Bar` + + Args: + name: Name of the group to create + related: whether to open as a child of current tab or under root + background: whether to open in a background tab + """ + title = ' '.join(name) + path = urllib.parse.quote(title) + if background: + self.openurl('qute://treegroup/' + path, related=related, bg=True) + else: + self.openurl('qute://treegroup/' + path, related=related, tab=True) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_suspend_children(self, count=None): + """Suspends all descendent of a tab to reduce memory usage. + + Args: + count: Target tab. + """ + tab = self._cntwidget(count) + for descendent in tab.node.traverse(): + cur_tab = descendent.value + if cur_tab and cur_tab is not tab: + cur_url = cur_tab.url().url() + if not cur_url.startswith("qute://"): + new_url = self._parse_url( + "qute://back/#" + cur_tab.title()) + cur_tab.load_url(new_url) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index f325ff9e3..f1c6711fd 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -579,6 +579,18 @@ def qute_warning(url: QUrl) -> _HandlerRet: return 'text/html', src +@add_handler('treegroup') +def qute_treegroup(url): + """Handler for qute://treegroup/x. + + Makes an empty tab with a title, for use with tree-tabs as a grouping + feature. + """ + src = jinja.render('tree_group.html', + title=url.path()[1:]) + return 'text/html', src + + @add_handler('resource') def qute_resource(url: QUrl) -> _HandlerRet: """Handler for qute://resource.""" diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index effdcc9b0..aaf1763e2 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -52,6 +52,7 @@ class Command: both) no_replace_variables: Don't replace variables like {url} modes: The modes the command can be executed in. + tree_tab: Whether the command is a tree-tabs command _qute_args: The saved data from @cmdutils.argument _count: The count set for the command. _instance: The object to bind 'self' to. @@ -66,7 +67,7 @@ class Command: self, *, handler, name, instance=None, maxsplit=None, modes=None, not_modes=None, debug=False, deprecated=False, no_cmd_split=False, star_args_optional=False, scope='global', - backend=None, no_replace_variables=False, + backend=None, no_replace_variables=False, tree_tab=False, ): # pylint: disable=too-many-arguments if modes is not None and not_modes is not None: raise ValueError("Only modes or not_modes can be given!") @@ -96,6 +97,7 @@ class Command: self.handler = handler self.no_cmd_split = no_cmd_split self.backend = backend + self.tree_tab = tree_tab self.no_replace_variables = no_replace_variables self.docparser = docutils.DocstringParser(handler) diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index 492e1b2e5..4957dc6c9 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -8,6 +8,7 @@ from typing import Callable, Sequence from qutebrowser.utils import usertypes from qutebrowser.misc import objects +from qutebrowser.config import config DeleteFuncType = Callable[[Sequence[str]], None] @@ -31,8 +32,9 @@ def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): hide_debug = obj.debug and not objects.args.debug hide_mode = (usertypes.KeyMode.normal not in obj.modes and not include_hidden) + hide_tree = obj.tree_tab and not config.cache['tabs.tree_tabs'] hide_ni = obj.name == 'Ni!' - if not (hide_debug or hide_mode or obj.deprecated or hide_ni): + if not (hide_tree or hide_debug or hide_mode or obj.deprecated or hide_ni): bindings = ', '.join(cmd_to_keys.get(obj.name, [])) cmdlist.append((prefix + obj.name, obj.desc, bindings)) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index e973fca28..d1b883ff2 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2271,6 +2271,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 @@ -2336,7 +2370,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: @@ -2354,12 +2388,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. @@ -2395,6 +2433,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. @@ -2493,6 +2533,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: @@ -3833,6 +3879,17 @@ bindings.default: all no-3rdparty never ;; reload tCu: config-cycle -p -u {url} content.cookies.accept all no-3rdparty never ;; reload + zH: tree-tab-promote + zL: tree-tab-demote + zJ: tab-next -s + zK: tab-prev -s + zd: tab-close -r + zg: set-cmd-text -s :tree-tab-create-group -r + zG: set-cmd-text -s :tree-tab-create-group + za: tree-tab-toggle-hide + zp: tab-focus parent + zo: set-cmd-text --space :open -tr + zO: set-cmd-text --space :open -tS insert: <Ctrl-E>: edit-text <Shift-Ins>: insert-text -- {primary} diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index e789437d3..41f139e14 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1942,6 +1942,21 @@ class NewTabPosition(String): ('last', "At the end.")) +class NewChildPosition(String): + + """How new children are positioned.""" + + def __init__( + self, *, + none_ok: bool = False, + completions: _Completions = None, + ) -> None: + super().__init__(none_ok=none_ok, completions=completions) + self.valid_values = ValidValues( + ('first', "At the beginning."), + ('last', "At the end.")) + + class LogLevel(String): """A logging level.""" diff --git a/qutebrowser/html/tree_group.html b/qutebrowser/html/tree_group.html new file mode 100644 index 000000000..b3717e52b --- /dev/null +++ b/qutebrowser/html/tree_group.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block style %} +h1, p { + margin-left: 3rem; +} + +pre { + margin-left: 6em; +} +{% endblock %} +{% block content %} +<h1> + {{ title }} +</h1> +<p> + <em>Group for tree tabs</em> +</p> +<pre> +{% raw %} + _. + _~.:'^%^ >@~. + ,-~ ? =*= + $^_` ` , ' , + -., + (*-^. , * ;' > + >. ,> . ' .,.,. %-.,_ ,.-, + # ' ` " - " * .,. * .^ ` + *@! ., * ' ' , ;' ' . %! + & " .` :' ` ' . `~, + & ' .` ' ' . '": : +. + ^ .", , ` ' ` * , ' ` | + ] * . , ""] .. ` . , ` , " . . ' ,;, + % ' ::, , / , ' , ; + .* ,* / *% \ . . *' ` , ' '. + ? > . , ::. :;^^. %` ' ` @ + / ' `/ ` &#@%^^ `&`` ` %;; % + ;: :% * * :$%)\ ' `@%$ @%^ ,). + . # %&^ (!*^ .\,. ` ^@%^ $#%%^ ` > + \ :#$% #^&# : ` * %###$%@! & + | ' * %$#@)$*}] ` `#@#%%^ *^ + : *' * @%&&^:$ ` ' `%%. #$$$^^-, 7 + &; @#$~~ ' ` @#$%& $,*.- + *...*^ .._ %$$#@! @ ., *&&#@ + :..^ - !%&@}{#& @#$@% + --_..%#%%$#&% #&%$#;: + $%#^%#@@%%*&;; + a%##@%%@% ;:; + %####j#:::; + &#%Rj;%;;: + &#%%#;::; + $#%##:%:: + "#%%#;:; + ."$###::: + #&$%%#:;: + %&#%%#::; + %&%###;:: + &&#%%#:;; + *@&#%#};:; + $#%#%%^::: + *@#$#%#;::;: + %%@#$####@$:;: + ...%###pinusc@$%%:._____ +{% endraw %} + +</pre> +{% endblock %} diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index e39ac4f9a..9251131e3 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -8,7 +8,7 @@ import binascii import base64 import itertools import functools -from typing import List, MutableSequence, Optional, Tuple, cast +from typing import List, MutableSequence, Optional, Tuple, cast, Union from qutebrowser.qt import machinery from qutebrowser.qt.core import (pyqtBoundSignal, pyqtSlot, QRect, QPoint, QTimer, Qt, @@ -195,7 +195,7 @@ class MainWindow(QWidget): super().__init__(parent) # Late import to avoid a circular dependency # - browsertab -> hints -> webelem -> mainwindow -> bar -> browsertab - from qutebrowser.mainwindow import tabbedbrowser + from qutebrowser.mainwindow import treetabbedbrowser, tabbedbrowser from qutebrowser.mainwindow.statusbar import bar self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -224,8 +224,14 @@ class MainWindow(QWidget): self.is_private = config.val.content.private_browsing or private - self.tabbed_browser: tabbedbrowser.TabbedBrowser = tabbedbrowser.TabbedBrowser( - win_id=self.win_id, private=self.is_private, parent=self) + self.tabbed_browser: Union[tabbedbrowser.TabbedBrowser, + treetabbedbrowser.TreeTabbedBrowser] + if config.val.tabs.tree_tabs: + self.tabbed_browser = treetabbedbrowser.TreeTabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) + else: + self.tabbed_browser = tabbedbrowser.TabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) self._init_command_dispatcher() @@ -499,8 +505,10 @@ class MainWindow(QWidget): mode_manager.keystring_updated.connect( self.status.keystring.on_keystring_updated) self.status.cmd.got_cmd[str].connect(self._commandrunner.run_safely) - self.status.cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) - self.status.cmd.returnPressed.connect(self.tabbed_browser.on_cmd_return_pressed) + self.status.cmd.got_cmd[str, int].connect( + self._commandrunner.run_safely) + self.status.cmd.returnPressed.connect( + self.tabbed_browser.on_cmd_return_pressed) self.status.cmd.got_search.connect(self._command_dispatcher.search) # key hint popup diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 28f32c4fd..89b43285d 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -9,8 +9,8 @@ import functools import weakref import datetime import dataclasses -from typing import ( - Any, Deque, List, Mapping, MutableMapping, MutableSequence, Optional, Tuple) +from typing import (Any, Deque, List, Mapping, + MutableMapping, MutableSequence, Optional, Tuple) from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint @@ -194,6 +194,7 @@ class TabbedBrowser(QWidget): resized = pyqtSignal('QRect') current_tab_changed = pyqtSignal(browsertab.AbstractTab) new_tab = pyqtSignal(browsertab.AbstractTab, int) + is_treetabbedbrowser = False shutting_down = pyqtSignal() def __init__(self, *, win_id, private, parent=None): @@ -483,6 +484,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 @@ -551,17 +553,26 @@ class TabbedBrowser(QWidget): entries = self.undo_stack[-depth] del self.undo_stack[-depth] + # we return the tab list because tree_tabs needs it in post_processing + new_tabs = [] for entry in reversed(entries): if use_current_tab: newtab = self._tab_by_idx(0) assert newtab is not None use_current_tab = False else: - newtab = self.tabopen(background=False, idx=entry.index) + # FIXME:typing mypy thinks this is None due to @pyqtSlot + newtab = self.tabopen( + background=False, + related=False, + idx=entry.index + ) newtab.history.private_api.deserialize(entry.history) newtab.set_pinned(entry.pinned) + new_tabs.append(newtab) newtab.setFocus() + return new_tabs @pyqtSlot('QUrl', bool) def load_url(self, url, newtab): @@ -649,7 +660,7 @@ class TabbedBrowser(QWidget): if idx is None: idx = self._get_new_tab_idx(related) - self.widget.insertTab(idx, tab, "") + idx = self.widget.insertTab(idx, tab, "") if url is not None: tab.load_url(url) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 42c31c97e..be1c696cf 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -13,7 +13,7 @@ from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint, QTimer, QUrl) from qutebrowser.qt.widgets import (QTabWidget, QTabBar, QSizePolicy, QProxyStyle, QStyle, QStylePainter, QStyleOptionTab, - QCommonStyle) + QCommonStyle, QWidget) from qutebrowser.qt.gui import QIcon, QPalette, QColor from qutebrowser.utils import qtutils, objreg, utils, usertypes, log @@ -23,7 +23,6 @@ from qutebrowser.browser import browsertab class TabWidget(QTabWidget): - """The tab widget used for TabbedBrowser. Signals: @@ -42,18 +41,20 @@ class TabWidget(QTabWidget): def __init__(self, win_id, parent=None): super().__init__(parent) + self._tabbed_browser = parent bar = TabBar(win_id, self) self.setStyle(TabBarStyle()) self.setTabBar(bar) bar.tabCloseRequested.connect(self.tabCloseRequested) - bar.tabMoved.connect(functools.partial( - QTimer.singleShot, 0, self.update_tab_titles)) + bar.tabMoved.connect(self.update_tab_titles) bar.currentChanged.connect(self._on_current_changed) bar.new_tab_requested.connect(self._on_new_tab_requested) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.setDocumentMode(True) self.setUsesScrollButtons(True) bar.setDrawBase(False) + self._tab_title_update_disabled = False + self._init_config() config.instance.changed.connect(self._init_config) @@ -79,7 +80,7 @@ class TabWidget(QTabWidget): assert isinstance(bar, TabBar), bar return bar - def _tab_by_idx(self, idx: int) -> Optional[browsertab.AbstractTab]: + def _tab_by_idx(self, idx: int) -> Optional[QWidget]: """Get the tab at the given index.""" tab = self.widget(idx) if tab is not None: @@ -124,6 +125,12 @@ class TabWidget(QTabWidget): field: A field name which was updated. If given, the title is only set if the given field is in the template. """ + if self._tab_title_update_disabled: + return + + if self._tabbed_browser.is_shutting_down: + return + assert idx != -1 tab = self._tab_by_idx(idx) assert tab is not None @@ -176,6 +183,9 @@ class TabWidget(QTabWidget): fields['perc_raw'] = tab.progress() fields['backend'] = objects.backend.name fields['private'] = ' [Private Mode] ' if tab.is_private else '' + fields['tree'] = '' + fields['collapsed'] = '' + try: if tab.audio.is_muted(): fields['audio'] = TabWidget.MUTE_STRING @@ -238,8 +248,17 @@ class TabWidget(QTabWidget): bar.setVisible(True) bar.setUpdatesEnabled(True) + @contextlib.contextmanager + def _disable_tab_title_updates(self): + self._tab_title_update_disabled = True + yield + self._tab_title_update_disabled = False + def update_tab_titles(self): """Update all texts.""" + if self._tab_title_update_disabled: + return + with self._toggle_visibility(): for idx in range(self.count()): self.update_tab_title(idx) @@ -334,7 +353,7 @@ class TabWidget(QTabWidget): qtutils.ensure_valid(url) return url - def update_tab_favicon(self, tab: browsertab.AbstractTab) -> None: + def update_tab_favicon(self, tab: QWidget) -> None: """Update favicon of the given tab.""" idx = self.indexOf(tab) diff --git a/qutebrowser/mainwindow/treetabbedbrowser.py b/qutebrowser/mainwindow/treetabbedbrowser.py new file mode 100644 index 000000000..929188440 --- /dev/null +++ b/qutebrowser/mainwindow/treetabbedbrowser.py @@ -0,0 +1,329 @@ +# 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 datetime +from typing import List, Dict +from qutebrowser.qt.widgets import QSizePolicy +from qutebrowser.qt.core import pyqtSlot, QUrl + +from qutebrowser.config import config +from qutebrowser.mainwindow.tabbedbrowser import TabbedBrowser +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.browser import browsertab +from qutebrowser.misc import notree +from qutebrowser.utils import log + + +@dataclasses.dataclass +class _TreeUndoEntry(): + """Information needed for :undo.""" + + url: QUrl + history: bytes + index: int + pinned: bool + uid: int + parent_node_uid: int + children_node_uids: List[int] + local_index: int # index of the tab relative to its siblings + created_at: datetime.datetime = dataclasses.field( + default_factory=datetime.datetime.now) + + @staticmethod + def from_node(node, idx): + """Make a TreeUndoEntry from a Node.""" + url = node.value.url() + try: + history_data = node.value.history.private_api.serialize() + except browsertab.WebTabError: + history_data = [] + pinned = node.value.data.pinned + uid = node.uid + parent_uid = node.parent.uid + children = [n.uid for n in node.children] + local_idx = node.index + return _TreeUndoEntry(url=url, + history=history_data, + index=idx, + pinned=pinned, + uid=uid, + parent_node_uid=parent_uid, + children_node_uids=children, + local_index=local_idx) + + +class TreeTabbedBrowser(TabbedBrowser): + """Subclass of TabbedBrowser to provide tree-tab functionality. + + Extends TabbedBrowser methods (mostly tabopen, undo, and _remove_tab) so + that the internal tree is updated after every action. + + Provides methods to hide and show subtrees, and to cycle visibility. + """ + + is_treetabbedbrowser = True + + def __init__(self, *, win_id, private, parent=None): + super().__init__(win_id=win_id, private=private, parent=parent) + self.is_treetabbedbrowser = True + self.widget = TreeTabWidget(win_id, parent=self) + self.widget.tabCloseRequested.connect(self.on_tab_close_requested) + self.widget.new_tab_requested.connect(self.tabopen) + self.widget.currentChanged.connect(self._on_current_changed) + self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._reset_stack_counters() + + def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): + """Handle children positioning after a tab is removed.""" + + if not tab.url().isEmpty() and tab.url().isValid() and add_undo: + idx = self.widget.indexOf(tab) + self._add_undo_entry(tab, idx, new_undo) + + node = tab.node + parent = node.parent + + if node.collapsed: + # Collapsed nodes have already been removed from the TabWidget so + # we can't ask super() to dispose of them and need to do it + # ourselves. + for descendent in node.traverse( + order=notree.TraverseOrder.POST_R, + render_collapsed=True + ): + descendent.parent = None + descendent_tab = descendent.value + descendent_tab.private_api.shutdown() + descendent_tab.deleteLater() + elif parent: + siblings = list(parent.children) + children = node.children + + if children: + # Promote first child, + # make that promoted node the parent of our other children + # give the promoted node our position in our siblings list. + next_node = children[0] + + for n in children[1:]: + n.parent = next_node + + # swap nodes + node_idx = siblings.index(node) + siblings[node_idx] = next_node + + parent.children = tuple(siblings) + assert not node.children + + node.parent = None + + super()._remove_tab(tab, add_undo=False, new_undo=False, + crashed=crashed) + + self.widget.tree_tab_update() + + def _add_undo_entry(self, tab, idx, new_undo): + """Save undo entry with tree information. + + This function was removed in tabbedbrowser, but it is still useful here because + the mechanism is quite a bit more complex + """ + # TODO see if it's possible to remove duplicate code from + # super()._add_undo_entry + node = tab.node + if not node.collapsed: + entry = _TreeUndoEntry.from_node(node, 0) + if new_undo or not self.undo_stack: + self.undo_stack.append([entry]) + else: + self.undo_stack[-1].append(entry) + else: + entries = [] + for descendent in node.traverse(notree.TraverseOrder.POST_R): + entry = _TreeUndoEntry.from_node(descendent, 0) + # Recursively removed nodes will never have any children + # in the tree they are being added into. Children will + # always be added later as the undo stack is worked + # through. + # UndoEntry.from_node() is not clever enough enough to + # handle this case on its own currently. + entry.children_node_uids = [] + entries.append(entry) + if new_undo: + self.undo_stack.append(entries) + else: + self.undo_stack[-1] += entries + + def undo(self, depth=1): + """Undo removing of a tab or tabs.""" + # TODO find a way to remove dupe code + # probably by getting entries from undo stack, THEN calling super + # then post-processing the entries + + # save entries before super().undo() pops them + entries = list(self.undo_stack[-depth]) + new_tabs = super().undo(depth) + + for entry, tab in zip(reversed(entries), new_tabs): + if not isinstance(entry, _TreeUndoEntry): + continue + root = self.widget.tree_root + uid = entry.uid + parent_uid = entry.parent_node_uid + parent_node = root.get_descendent_by_uid(parent_uid) + if not parent_node: + parent_node = root + + children = [] + for child_uid in entry.children_node_uids: + child_node = root.get_descendent_by_uid(child_uid) + children.append(child_node) + tab.node.parent = None # Remove the node from the tree + tab.node = notree.Node(tab, parent_node, + children, uid) + + # correctly reposition the tab + local_idx = entry.local_index + if tab.node.parent: # should always be true + new_siblings = list(tab.node.parent.children) + new_siblings.remove(tab.node) + new_siblings.insert(local_idx, tab.node) + tab.node.parent.children = new_siblings + + self.widget.tree_tab_update() + + @pyqtSlot('QUrl') + @pyqtSlot('QUrl', bool) + @pyqtSlot('QUrl', bool, bool) + def tabopen( + self, url: QUrl = None, + background: bool = None, + related: bool = True, + sibling: bool = False, + idx: int = None, + ) -> browsertab.AbstractTab: + """Open a new tab with a given url. + + Args: + related: Whether to set the tab as a child of the currently focused + tab. Follows `tabs.new_position.tree.related`. + sibling: Whether to set the tab as a sibling of the currently + focused tab. Follows `tabs.new_position.tree.sibling`. + + """ + # pylint: disable=arguments-differ + # we save this now because super.tabopen also resets the focus + cur_tab = self.widget.currentWidget() + tab = super().tabopen(url, background, related, idx) + + tab.node.parent = self.widget.tree_root + if cur_tab is None or tab is cur_tab: + self.widget.tree_tab_update() + return tab + + # get pos + if related: + pos = config.val.tabs.new_position.tree.new_child + parent = cur_tab.node + # pos can only be first, last + elif sibling: + pos = config.val.tabs.new_position.tree.new_sibling + parent = cur_tab.node.parent + # pos can be first, last, prev, next + else: + pos = config.val.tabs.new_position.tree.new_toplevel + parent = self.widget.tree_root + + self._position_tab(cur_tab, tab, pos, parent, sibling, related, background) + + return tab + + def _position_tab( + self, + cur_tab: browsertab.AbstractTab, + tab: browsertab.AbstractTab, + pos: str, + parent: notree.Node, + sibling: bool = False, + related: bool = True, + background: bool = None, + ) -> None: + toplevel = not sibling and not related + siblings = list(parent.children) + if tab.node in siblings: # true if parent is tree_root + # remove it and add it later in the right position + siblings.remove(tab.node) + + if pos == 'first': + rel_idx = 0 + if config.val.tabs.new_position.stacking and related: + rel_idx += self._tree_tab_child_rel_idx + self._tree_tab_child_rel_idx += 1 + siblings.insert(rel_idx, tab.node) + elif pos in ['prev', 'next'] and (sibling or toplevel): + # pivot is the tab relative to which 'prev' or 'next' apply + # it is always a member of 'siblings' + pivot = cur_tab.node if sibling else cur_tab.node.path[1] + direction = -1 if pos == 'prev' else 1 + rel_idx = 0 if pos == 'prev' else 1 + tgt_idx = siblings.index(pivot) + rel_idx + if config.val.tabs.new_position.stacking: + if sibling: + tgt_idx += self._tree_tab_sibling_rel_idx + self._tree_tab_sibling_rel_idx += direction + elif toplevel: + tgt_idx += self._tree_tab_toplevel_rel_idx + self._tree_tab_toplevel_rel_idx += direction + siblings.insert(tgt_idx, tab.node) + else: # position == 'last' + siblings.append(tab.node) + parent.children = siblings + self.widget.tree_tab_update() + if not background: + self._reset_stack_counters() + + def _reset_stack_counters(self): + self._tree_tab_child_rel_idx = 0 + self._tree_tab_sibling_rel_idx = 0 + self._tree_tab_toplevel_rel_idx = 0 + + @pyqtSlot(int) + def _on_current_changed(self, idx): + super()._on_current_changed(idx) + self._reset_stack_counters() + + def cycle_hide_tab(self, node): + """Utility function for tree_tab_cycle_hide command.""" + # height = node.height # height is always rel_height + if node.collapsed: + node.collapsed = False + for descendent in node.traverse(render_collapsed=True): + descendent.collapsed = False + return + + def rel_depth(n): + return n.depth - node.depth + + levels: Dict[int, list] = collections.defaultdict(list) + for d in node.traverse(render_collapsed=False): + r_depth = rel_depth(d) + levels[r_depth].append(d) + + # Remove highest level because it's leaves (or already collapsed) + del levels[max(levels.keys())] + + target = 0 + for level in sorted(levels, reverse=True): + nodes = levels[level] + if not all(n.collapsed or not n.children for n in nodes): + target = level + break + for n in levels[target]: + if not n.collapsed and n.children: + n.collapsed = True diff --git a/qutebrowser/mainwindow/treetabwidget.py b/qutebrowser/mainwindow/treetabwidget.py new file mode 100644 index 000000000..082b7a079 --- /dev/null +++ b/qutebrowser/mainwindow/treetabwidget.py @@ -0,0 +1,148 @@ +# 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) + self.tabBar().tabMoved.disconnect(self.update_tab_titles) + + def _init_config(self): + super()._init_config() + # For tree-tabs + self.update_tab_titles() # Must also be called when deactivating + self.tree_tab_update() + + def get_tab_fields(self, idx): + """Add tree field data to normal tab field data.""" + fields = super().get_tab_fields(idx) + + 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( + [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 dummy entries for now. Once we finish whatever operation is + # causing the current irregularity we should get proper values. + fields["tree"] = "" + fields["collapsed"] = "" + return fields + + def update_tree_tab_positions(self): + """Update tab positions according to the tree structure.""" + nodes = self.tree_root.traverse(render_collapsed=False) + for idx, node in enumerate(nodes): + if idx > 0: + cur_idx = self.indexOf(node.value) + self.tabBar().moveTab(cur_idx, idx-1) + + def update_tree_tab_visibility(self): + """Hide collapsed tabs and show uncollapsed ones. + + Sync the internal tree to the tabs the user can actually see. + """ + for node in self.tree_root.traverse(): + if node.value is None: + continue + if any(ancestor.collapsed for ancestor in node.path[:-1]): + if self.indexOf(node.value) != -1: + # node should be hidden but is shown + cur_tab = node.value + idx = self.indexOf(cur_tab) + if idx != -1: + self.removeTab(idx) + else: + if self.indexOf(node.value) == -1: + # node should be shown but is hidden + parent = node.parent + tab = node.value + name = tab.title() + icon = tab.icon() + if node.parent is not None: + parent_idx = self.indexOf(node.parent.value) + self.insertTab(parent_idx + 1, tab, icon, name) + tab.node.parent = parent # insertTab resets node + + def tree_tab_update(self): + """Update titles and positions.""" + with self._disable_tab_title_updates(): + self.update_tree_tab_visibility() + self.update_tree_tab_positions() + self.update_tab_titles() diff --git a/qutebrowser/misc/notree.py b/qutebrowser/misc/notree.py new file mode 100644 index 000000000..297673bfb --- /dev/null +++ b/qutebrowser/misc/notree.py @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tree library for tree-tabs. + +The fundamental unit is the Node class. + +Create a tree with with Node(value, parent): +root = Node('foo') +child = Node('bar', root) +child2 = Node('baz', root) +child3 = Node('lorem', child) + +You can also assign parent after instantiation, or even reassign it: +child4 = Node('ipsum') +child4.parent = root + +Assign children: +child.children = [] +child2.children = [child4, child3] +child3.parent +> Node('foo/bar/baz') + +Render a tree with render_tree(root_node): +render_tree(root) + +> ('', 'foo') +> ('├─', 'bar') +> ('│ ├─', 'lorem') +> ('│ └─', 'ipsum') +> ('└─', 'baz') +""" +import enum +from typing import Optional, TypeVar, Sequence, List, Tuple, Iterable, Generic +import itertools + +# For Node.render +CORNER = '└─' +INTERSECTION = '├─' +PIPE = '│' + + +class TreeError(RuntimeError): + """Exception used for tree-related errors.""" + + +class TraverseOrder(enum.Enum): + """Tree traversal order for Node.traverse(). + + All traversals are depth first. + See https://en.wikipedia.org/wiki/Depth-first_search#Vertex_orderings + + Attributes: + PRE: pre-order: parent then children, leftmost nodes first. Same as in Node.render(). + POST: post-order: children then parent, leftmost nodes first, then parent. + POST_R: post-order-reverse: like POST but rightmost nodes first. + """ + + PRE = 'pre-order' + POST = 'post-order' + POST_R = 'post-order-reverse' + + +uid_gen = itertools.count(0) + +# generic type of value held by Node +T = TypeVar('T') # pylint: disable=invalid-name + + +class Node(Generic[T]): + """Fundamental unit of notree library. + + Attributes: + value: The element (ususally a tab) the node represents + parent: Node's parent. + children: Node's children elements. + siblings: Children of parent node that are not self. + path: List of nodes from root of tree to self value, parent, and + children can all be set by user. Everything else will be updated + accordingly, so that if `node.parent = root_node`, then `node in + root_node.children` will be True. + """ + + sep: str = '/' + __parent: Optional['Node[T]'] = None + # this is a global 'static' class attribute + + def __init__(self, + value: T, + parent: Optional['Node[T]'] = None, + childs: Sequence['Node[T]'] = (), + uid: Optional[int] = None) -> None: + if uid is not None: + self.__uid = uid + else: + self.__uid = next(uid_gen) + + self.value = value + # set initial values so there's no need for AttributeError checks + self.__parent: Optional['Node[T]'] = None + self.__children: List['Node[T]'] = [] + + # For render memoization + self.__modified = False + self.__set_modified() # not the same as line above + self.__rendered: Optional[List[Tuple[str, 'Node[T]']]] = None + + if parent: + self.parent = parent # calls setter + if childs: + self.children = childs # this too + + self.__collapsed = False + + @property + def uid(self) -> int: + return self.__uid + + @property + def parent(self) -> Optional['Node[T]']: + return self.__parent + + @parent.setter + def parent(self, value: 'Node[T]') -> None: + """Set parent property. Also adds self to value.children.""" + # pylint: disable=protected-access + assert (value is None or isinstance(value, Node)) + if self.__parent: + self.__parent.__disown(self) + self.__parent = None + if value is not None: + value.__add_child(self) + self.__parent = value + self.__set_modified() + + @property + def children(self) -> Sequence['Node[T]']: + return tuple(self.__children) + + @children.setter + def children(self, value: Sequence['Node[T]']) -> None: + """Set children property, preserving order. + + Also sets n.parent = self for n in value. Does not allow duplicates. + """ + seen = set(value) + if len(seen) != len(value): + raise TreeError("A duplicate item is present in in %r" % value) + new_children = list(value) + for child in new_children: + if child.parent is not self: + child.parent = self + self.__children = new_children + self.__set_modified() + + @property + def path(self) -> List['Node[T]']: + """Get a list of all nodes from the root node to self.""" + if self.parent is None: + return [self] + else: + return self.parent.path + [self] + + @property + def depth(self) -> int: + """Get the number of nodes between self and the root node.""" + return len(self.path) - 1 + + @property + def index(self) -> int: + """Get self's position among its siblings (self.parent.children).""" + if self.parent is not None: + return self.parent.children.index(self) + else: + raise TreeError('Node has no parent.') + + @property + def collapsed(self) -> bool: + return self.__collapsed + + @collapsed.setter + def collapsed(self, val: bool) -> None: + self.__collapsed = val + self.__set_modified() + + def __set_modified(self) -> None: + """If self is modified, every ancestor is modified as well.""" + for node in self.path: + node.__modified = True # pylint: disable=protected-access + + def render(self) -> List[Tuple[str, 'Node[T]']]: + """Render a tree with ascii symbols. + + Tabs appear in the same order as in traverse() with TraverseOrder.PRE + Args: + node; the root of the tree to render + + Return: list of tuples where the first item is the symbol, + and the second is the node it refers to + """ + if not self.__modified and self.__rendered is not None: + return self.__rendered + + result = [('', self)] + for child in self.children: + if child.children: + subtree = child.render() + if child is not self.children[-1]: + subtree = [(PIPE + ' ' + c, n) for c, n in subtree] + char = INTERSECTION + else: + subtree = [(' ' + c, n) for c, n in subtree] + char = CORNER + subtree[0] = (char, subtree[0][1]) + if child.collapsed: + result += [subtree[0]] + else: + result += subtree + else: + if child is self.children[-1]: + result.append((CORNER, child)) + else: + result.append((INTERSECTION, child)) + self.__modified = False + self.__rendered = list(result) + return list(result) + + def traverse(self, order: TraverseOrder = TraverseOrder.PRE, + render_collapsed: bool = True) -> Iterable['Node']: + """Generator for all descendants of `self`. + + Args: + order: a TraverseOrder object. See TraverseOrder documentation. + render_collapsed: whether to yield children of collapsed nodes + Even if render_collapsed is False, collapsed nodes are be rendered. + It's their children that won't. + """ + if order == TraverseOrder.PRE: + yield self + + if self.collapsed and not render_collapsed: + if order != TraverseOrder.PRE: + yield self + return + + f = reversed if order is TraverseOrder.POST_R else lambda x: x + for child in f(self.children): + if render_collapsed or not child.collapsed: + yield from child.traverse(order, render_collapsed) + else: + yield child + if order in [TraverseOrder.POST, TraverseOrder.POST_R]: + yield self + + def __add_child(self, node: 'Node[T]') -> None: + if node not in self.__children: + self.__children.append(node) + + def __disown(self, value: 'Node[T]') -> None: + self.__set_modified() + if value in self.__children: + self.__children.remove(value) + + def get_descendent_by_uid(self, uid: int) -> Optional['Node[T]']: + """Return descendent identified by the provided uid. + + Returns None if there is no such descendent. + + Args: + uid: The uid of the node to return + """ + for descendent in self.traverse(): + if descendent.uid == uid: + return descendent + return None + + def promote(self, times: int = 1, to: str = 'first') -> None: + """Makes self a child of its grandparent, i.e. sibling of its parent. + + Args: + times: How many levels to promote the tab to. to: One of 'next', + 'prev', 'first', 'last'. Determines the position among siblings + after being promoted. 'next' and 'prev' are relative to the current + parent. + + """ + if to not in ['first', 'last', 'next', 'prev']: + raise Exception("Invalid value supplied for 'to': " + to) + position = {'first': 0, 'last': -1}.get(to, None) + diff = {'next': 1, 'prev': 0}.get(to, 1) + count = times + while count > 0: + if self.parent is None or self.parent.parent is None: + raise TreeError("Tab has no parent!") + grandparent = self.parent.parent + if position is not None: + idx = position + else: # diff is necessarily not none + idx = self.parent.index + diff + self.parent = None + + siblings = list(grandparent.children) + if idx != -1: + siblings.insert(idx, self) + else: + siblings.append(self) + grandparent.children = siblings + count -= 1 + + def demote(self, to: str = 'last') -> None: + """Demote a tab making it a child of its previous adjacent sibling.""" + if self.parent is None or self.parent.children is None: + raise TreeError("Tab has no siblings!") + siblings = list(self.parent.children) + + # we want previous node in the same subtree as current node + rel_idx = siblings.index(self) - 1 + + if rel_idx >= 0: + parent = siblings[rel_idx] + new_siblings = list(parent.children) + position = {'first': 0, 'last': -1}.get(to, -1) + if position == 0: + new_siblings.insert(0, self) + else: + new_siblings.append(self) + parent.children = new_siblings + else: + raise TreeError("Tab has no previous sibling!") + + def __repr__(self) -> str: + try: + value = str(self.value.url().url()) # type: ignore + except Exception: + value = str(self.value) + return "<Node -%d- '%s'>" % (self.__uid, value) + + def __str__(self) -> str: + # return "<Node '%s'>" % self.value + return str(self.value) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index dd63904cd..662335dcc 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -281,13 +281,32 @@ class SessionManager(QObject): if getattr(active_window, 'win_id', None) == win_id: win_data['active'] = True win_data['geometry'] = bytes(main_window.saveGeometry()) - win_data['tabs'] = [] if tabbed_browser.is_private: win_data['private'] = True - for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.widget.currentIndex() - win_data['tabs'].append(self._save_tab(tab, active, - with_history=with_history)) + + if tabbed_browser.is_treetabbedbrowser: + # a dict where keys are node UIDs, and values are dicts + # with tab data (the result of _save_tab) and a list of + # children UIDs + tree_data = {} + root_node = tabbed_browser.widget.tree_root + for i, node in enumerate(root_node.traverse(), -1): + node_data = {} + active = i == tabbed_browser.widget.currentIndex() + if node == root_node: + node_data['tab'] = {} + else: + node_data['tab'] = self._save_tab(node.value, active) + node_data['children'] = [c.uid for c in node.children] + node_data['collapsed'] = node.collapsed + tree_data[node.uid] = node_data + win_data['tree'] = tree_data + else: + win_data['tabs'] = [] + for i, tab in enumerate(tabbed_browser.widgets()): + active = i == tabbed_browser.widget.currentIndex() + win_data['tabs'].append(self._save_tab(tab, active, + with_history=with_history)) data['windows'].append(win_data) return data @@ -455,6 +474,45 @@ class SessionManager(QObject): except ValueError as e: raise SessionError(e) + def _load_tree(self, tabbed_browser, tree_data): + tree_keys = list(tree_data.keys()) + if not tree_keys: + return None + + root_data = tree_data.get(tree_keys[0]) + if root_data is None: + return None + + root_node = tabbed_browser.widget.tree_root + tab_to_focus = None + index = -1 + + def recursive_load_node(uid): + nonlocal tab_to_focus + nonlocal index + index += 1 + node_data = tree_data[uid] + children_uids = node_data['children'] + + if tree_data[uid]['tab'].get('active'): + tab_to_focus = index + + tab_data = node_data['tab'] + new_tab = tabbed_browser.tabopen(background=False) + self._load_tab(new_tab, tab_data) + + new_tab.node.parent = root_node + children = [recursive_load_node(uid) for uid in children_uids] + new_tab.node.children = children + new_tab.node.collapsed = node_data['collapsed'] + return new_tab.node + + for child_uid in root_data['children']: + child = recursive_load_node(child_uid) + child.parent = root_node + + return tab_to_focus + def _load_window(self, win): """Turn yaml data into windows.""" window = mainwindow.MainWindow(geometry=win['geometry'], @@ -462,13 +520,29 @@ class SessionManager(QObject): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) tab_to_focus = None - for i, tab in enumerate(win['tabs']): - new_tab = tabbed_browser.tabopen(background=False) - self._load_tab(new_tab, tab) - if tab.get('active', False): - tab_to_focus = i - if new_tab.data.pinned: - new_tab.set_pinned(True) + + # plain_tabs is used in case the saved session contains a tree and + # tree-tabs is not enabled, or if the saved session contains normal + # tabs + plain_tabs = win.get('tabs', None) + if win.get('tree'): + if tabbed_browser.is_treetabbedbrowser: + tree_data = win.get('tree') + tab_to_focus = self._load_tree(tabbed_browser, tree_data) + tabbed_browser.widget.tree_tab_update() + else: + tree = win.get('tree') + plain_tabs = [tree[i]['tab'] for i in tree if + tree[i]['tab']] + if plain_tabs: + for i, tab in enumerate(plain_tabs): + new_tab = tabbed_browser.tabopen(background=False) + self._load_tab(new_tab, tab) + if tab.get('active', False): + tab_to_focus = i + if new_tab.data.pinned: + new_tab.set_pinned(True) + if tab_to_focus is not None: tabbed_browser.widget.setCurrentIndex(tab_to_focus) diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py index 1d0249d9a..836d02d87 100755 --- a/scripts/asciidoc2html.py +++ b/scripts/asciidoc2html.py @@ -98,11 +98,11 @@ class AsciiDoc: """Copy image files to qutebrowser/html/doc.""" print("Copying files...") dst_path = DOC_DIR / 'img' - dst_path.mkdir(exist_ok=True) - for filename in ['cheatsheet-big.png', 'cheatsheet-small.png']: - src = REPO_ROOT / 'doc' / 'img' / filename - dst = dst_path / filename - shutil.copy(src, dst) + try: + shutil.rmtree(dst_path) + except FileNotFoundError: + pass + shutil.copytree(REPO_ROOT / 'doc' / 'img', dst_path) def _build_website_file(self, root: pathlib.Path, filename: str) -> None: """Build a single website file.""" diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 089f0c42c..70a5fc205 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -201,12 +201,15 @@ def open_path(quteproc, server, path): path = testutils.substitute_testdata(path) new_tab = False + related_tab = False new_bg_tab = False new_window = False private = False as_url = False wait = True + related_tab_suffix = ' in a new related tab' + related_background_tab_suffix = ' in a new related background tab' new_tab_suffix = ' in a new tab' new_bg_tab_suffix = ' in a new background tab' new_window_suffix = ' in a new window' @@ -218,6 +221,14 @@ def open_path(quteproc, server, path): if path.endswith(new_tab_suffix): path = path[:-len(new_tab_suffix)] new_tab = True + elif path.endswith(related_tab_suffix): + path = path[:-len(related_tab_suffix)] + new_tab = True + related_tab = True + elif path.endswith(related_background_tab_suffix): + path = path[:-len(related_background_tab_suffix)] + new_bg_tab = True + related_tab = True elif path.endswith(new_bg_tab_suffix): path = path[:-len(new_bg_tab_suffix)] new_bg_tab = True @@ -236,9 +247,9 @@ def open_path(quteproc, server, path): else: break - quteproc.open_path(path, new_tab=new_tab, new_bg_tab=new_bg_tab, - new_window=new_window, private=private, as_url=as_url, - wait=wait) + quteproc.open_path(path, related_tab=related_tab, new_tab=new_tab, + new_bg_tab=new_bg_tab, new_window=new_window, + private=private, as_url=as_url, wait=wait) @bdd.when(bdd.parsers.parse("I set {opt} to {value}")) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 98f7eff4d..470f74589 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -658,16 +658,17 @@ class QuteProc(testprocess.Process): self.set_setting(opt, old_value) def open_path(self, path, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, port=None, - https=False, wait=True): + related_tab=False, new_window=False, private=False, + as_url=False, port=None, https=False, wait=True): """Open the given path on the local webserver in qutebrowser.""" url = self.path_to_url(path, port=port, https=https) self.open_url(url, new_tab=new_tab, new_bg_tab=new_bg_tab, - new_window=new_window, private=private, as_url=as_url, - wait=wait) + related_tab=related_tab, new_window=new_window, + private=private, as_url=as_url, wait=wait) def open_url(self, url, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, wait=True): + related_tab=False, new_window=False, private=False, + as_url=False, wait=True): """Open the given url in qutebrowser.""" if sum(1 for opt in [new_tab, new_bg_tab, new_window, private, as_url] if opt) > 1: @@ -677,9 +678,15 @@ class QuteProc(testprocess.Process): self.send_cmd(url, invalid=True) line = None elif new_tab: - line = self.send_cmd(':open -t ' + url) + if related_tab: + line = self.send_cmd(':open -t -r ' + url) + else: + line = self.send_cmd(':open -t ' + url) elif new_bg_tab: - line = self.send_cmd(':open -b ' + url) + if related_tab: + line = self.send_cmd(':open -b -r ' + url) + else: + line = self.send_cmd(':open -b ' + url) elif new_window: line = self.send_cmd(':open -w ' + url) elif private: diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 658b027cb..a4a54e7bc 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -324,6 +324,7 @@ class FakeCommand: hide: bool = False debug: bool = False deprecated: bool = False + tree_tab: bool = False completion: Any = None maxsplit: int = None takes_count: Callable[[], bool] = lambda: False diff --git a/tests/unit/misc/test_notree.py b/tests/unit/misc/test_notree.py new file mode 100644 index 000000000..192dc6ac7 --- /dev/null +++ b/tests/unit/misc/test_notree.py @@ -0,0 +1,293 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The-Compiler) <me@the-compiler.org> +# +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for misc.notree library.""" +import pytest + +from qutebrowser.misc.notree import TreeError, Node, TraverseOrder + + +@pytest.fixture +def tree(): + """Return an example tree. + + n1 + ├─n2 + │ ├─n4 + │ └─n5 + └─n3 + ├─n6 + │ ├─n7 + │ ├─n8 + │ └─n9 + │ └─n10 + └─n11 + """ + # these are actually used because they appear in expected strings + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) + n5 = Node('n5', n2) + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) + n8 = Node('n8', n6) + n9 = Node('n9', n6) + n10 = Node('n10', n9) + n11 = Node('n11', n3) + return n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 + + +@pytest.fixture +def node(tree): + return tree[0] + + +def test_creation(): + node = Node('foo') + assert node.value == 'foo' + + child = Node('bar', node) + assert child.parent == node + assert node.children == (child, ) + + +def test_attach_parent(): + n1 = Node('n1', None, []) + print(n1.children) + n2 = Node('n2', n1) + n3 = Node('n3') + + n2.parent = n3 + assert n2.parent == n3 + assert n3.children == (n2, ) + assert n1.children == () + + +def test_duplicate_child(): + p = Node('n1') + try: + c1 = Node('c1', p) + c2 = Node('c2', p) + p.children = [c1, c1, c2] + raise AssertionError("Can add duplicate child") + except TreeError: + pass + finally: + if len(p.children) == 3: + raise AssertionError("Can add duplicate child") + + +def test_replace_parent(): + p1 = Node('foo') + p2 = Node('bar') + _ = Node('_', p2) + c = Node('baz', p1) + c.parent = p2 + assert c.parent is p2 + assert c not in p1.children + assert c in p2.children + + +def test_replace_children(tree): + n2 = tree[1] + n3 = tree[2] + n6 = tree[5] + n11 = tree[10] + n3.children = [n11] + n2.children = (n6, ) + n2.children + assert n6.parent is n2 + assert n6 in n2.children + assert n11.parent is n3 + assert n11 in n3.children + assert n6 not in n3.children + assert len(n3.children) == 1 + + +def test_promote_to_first(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='first') + assert n6.parent is n1 + assert n1.children[0] is n6 + + +def test_promote_to_last(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='last') + assert n6.parent is n1 + assert n1.children[-1] is n6 + + +def test_promote_to_prev(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='prev') + assert n6.parent is n1 + assert n1.children[1] is n6 + + +def test_promote_to_next(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='next') + assert n6.parent is n1 + assert n1.children[2] is n6 + + +def test_demote_to_first(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='first') + assert n11.parent is n6 + assert n6.children[0] is n11 + + +def test_demote_to_last(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='last') + assert n11.parent is n6 + assert n6.children[-1] is n11 + + +def test_traverse(node): + len_traverse = len(list(node.traverse())) + len_render = len(node.render()) + assert len_traverse == len_render + + +def test_traverse_postorder(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n4, n5, n2, n7, n8, n10, n9, n6, n11, n3, n1] + + +def test_traverse_postorder_r(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST_R)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n11, n10, n9, n8, n7, n6, n3, n5, n4, n2, n1] + + +def test_render_tree(node): + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3', + ' ├─n6', + ' │ ├─n7', + ' │ ├─n8', + ' │ └─n9', + ' │ └─n10', + ' └─n11' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + +def test_uid(node): + uids = set() + for n in node.traverse(): + assert n not in uids + uids.add(n.uid) + # pylint: disable=unused-variable + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) # noqa: F841 + n5 = Node('n5', n2) # noqa: F841 + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) # noqa: F841 + n8 = Node('n8', n6) # noqa: F841 + n9 = Node('n9', n6) + n10 = Node('n10', n9) # noqa: F841 + n11 = Node('n11', n3) + # pylint: enable=unused-variable + for n in n1.traverse(): + assert n not in uids + uids.add(n.uid) + + n11_uid = n11.uid + assert n1.get_descendent_by_uid(n11_uid) is n11 + assert node.get_descendent_by_uid(n11_uid) is None + + +def test_collapsed(node): + pre_collapsed_traverse = list(node.traverse()) + to_collapse = node.children[1] + + # collapse + to_collapse.collapsed = True + assert to_collapse.collapsed is True + for n in node.traverse(render_collapsed=False): + assert to_collapse not in n.path[:-1] + + assert list(to_collapse.traverse(render_collapsed=False)) == [to_collapse] + + assert list(node.traverse()) == pre_collapsed_traverse + + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + # uncollapse + to_collapse.collapsed = False + + assert any(n for n in node.traverse(render_collapsed=False) if to_collapse + in n.path[:-1]) + + +def test_memoization(node): + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].parent = None + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + n2 = Node('ntest', parent=node) + assert node._Node__modified is True + assert n2._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].children[1].parent = None + assert node._Node__modified is True + assert node.children[0]._Node__modified is True + node.render() + assert node._Node__modified is False |