diff options
31 files changed, 3021 insertions, 246 deletions
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 4d1610970..1c4c47e7f 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -130,6 +130,12 @@ possible to run or bind multiple commands by separating them with `;;`. |<<tab-prev,tab-prev>>|Switch to the previous tab, or switch [count] tabs back. |<<tab-select,tab-select>>|Select tab by index or url/title best match. |<<tab-take,tab-take>>|Take a tab from another window. +|<<tree-tab-create-group,tree-tab-create-group>>|Wrapper around :open qute://treegroup/name. Correctly escapes names. +|<<tree-tab-cycle-hide,tree-tab-cycle-hide>>|Hides levels of descendents: children, grandchildren, and so on. +|<<tree-tab-demote,tree-tab-demote>>|Demote a tab making it children of its previous adjacent sibling. +|<<tree-tab-promote,tree-tab-promote>>|Promote a tab so it becomes next sibling of its parent. +|<<tree-tab-suspend-children,tree-tab-suspend-children>>|Suspends all descendent of a tab to reduce memory usage. +|<<tree-tab-toggle-hide,tree-tab-toggle-hide>>|If the current tab's children are shown hide them, and vice-versa. |<<unbind,unbind>>|Unbind a keychain. |<<undo,undo>>|Re-open the last closed tab(s) or window. |<<version,version>>|Show version information. @@ -999,7 +1005,7 @@ Do nothing. [[open]] === open -Syntax: +:open [*--related*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ +Syntax: +:open [*--related*] [*--sibling*] [*--bg*] [*--tab*] [*--window*] [*--secure*] [*--private*] ['url']+ Open a URL in the current/[count]th tab. @@ -1011,6 +1017,8 @@ If the URL contains newlines, each line gets opened in its own tab. ==== optional arguments * +*-r*+, +*--related*+: If opening a new tab, position the tab as related to the current one (like clicking on a link). +* +*-S*+, +*--sibling*+: If opening a new tab, position the as a sibling of the current one. + * +*-b*+, +*--bg*+: Open in a new background tab. * +*-t*+, +*--tab*+: Open in a new tab. * +*-w*+, +*--window*+: Open in a new window. @@ -1400,7 +1408,7 @@ Duplicate the current tab. [[tab-close]] === tab-close -Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*]+ +Syntax: +:tab-close [*--prev*] [*--next*] [*--opposite*] [*--force*] [*--recursive*]+ Close the current/[count]th tab. @@ -1410,6 +1418,7 @@ Close the current/[count]th tab. * +*-o*+, +*--opposite*+: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. * +*-f*+, +*--force*+: Avoid confirmation for pinned tabs. +* +*-r*+, +*--recursive*+: Close all descendents (tree-tabs) as well as current tab ==== count The tab index to close @@ -1423,10 +1432,14 @@ Select the tab given as argument/[count]. If neither count nor index are given, it behaves like tab-next. If both are given, use count. ==== positional arguments -* +'index'+: The tab index to focus, starting with 1. The special value `last` focuses the last focused tab (regardless of count), - and `stack-prev`/`stack-next` traverse a stack of visited - tabs. Negative indices count from the end, such that -1 is - the last tab. +* +'index'+: The tab index to focus, starting with 1. Negative indices count from the end, such that -1 is the last tab. Special + values are: + - `last` focuses the last focused tab (regardless of + count). + - `parent` focuses the parent tab in the tree hierarchy, + if `tabs.tree_tabs` is enabled. + - `stack-prev`/`stack-next` traverse a stack of visited + tabs. ==== optional arguments @@ -1437,7 +1450,7 @@ The tab index to focus, starting with 1. [[tab-give]] === tab-give -Syntax: +:tab-give [*--keep*] [*--private*] ['win-id']+ +Syntax: +:tab-give [*--keep*] [*--private*] [*--recursive*] ['win-id']+ Give the current tab to a new or existing window if win_id given. @@ -1449,6 +1462,7 @@ If no win_id is given, the tab will get detached into a new window. ==== optional arguments * +*-k*+, +*--keep*+: If given, keep the old tab around. * +*-p*+, +*--private*+: If the tab should be detached into a private instance. +* +*-r*+, +*--recursive*+: Whether to move the entire subtree starting at the tab. ==== count Overrides win_id (index starts at 1 for win_id=0). @@ -1481,8 +1495,13 @@ The tab index to mute or unmute [[tab-next]] === tab-next +Syntax: +:tab-next [*--sibling*]+ + Switch to the next tab, or switch [count] tabs forward. +==== optional arguments +* +*-s*+, +*--sibling*+: Whether to focus the next tree sibling. + ==== count How many tabs to switch forward. @@ -1510,8 +1529,13 @@ The tab index to pin or unpin [[tab-prev]] === tab-prev +Syntax: +:tab-prev [*--sibling*]+ + Switch to the previous tab, or switch [count] tabs back. +==== optional arguments +* +*-s*+, +*--sibling*+: Whether to focus the previous tree sibling. + ==== count How many tabs to switch back. @@ -1549,6 +1573,59 @@ Take a tab from another window. ==== note * This command does not split arguments after the last argument and handles quotes literally. +[[tree-tab-create-group]] +=== tree-tab-create-group +Syntax: +:tree-tab-create-group [*--related*] [*--background*] 'name' ['name' ...]+ + +Wrapper around :open qute://treegroup/name. Correctly escapes names. + +Example: `:tree-tab-create-group Foo Bar` calls `:open qute://treegroup/Foo%20Bar` + +==== positional arguments +* +'name'+: Name of the group to create + +==== optional arguments +* +*-r*+, +*--related*+: whether to open as a child of current tab or under root +* +*-b*+, +*--background*+: whether to open in a background tab + +[[tree-tab-cycle-hide]] +=== tree-tab-cycle-hide +Hides levels of descendents: children, grandchildren, and so on. + +==== count +How many levels to hide. + +[[tree-tab-demote]] +=== tree-tab-demote +Demote a tab making it children of its previous adjacent sibling. + +Observes tabs.new_position.tree.demote in positioning the tab among new siblings. + +[[tree-tab-promote]] +=== tree-tab-promote +Promote a tab so it becomes next sibling of its parent. + +Observes tabs.new_position.tree.promote in positioning the tab among new siblings. + +==== count +How many levels the tabs should be promoted to + +[[tree-tab-suspend-children]] +=== tree-tab-suspend-children +Suspends all descendent of a tab to reduce memory usage. + +==== count +Target tab. + +[[tree-tab-toggle-hide]] +=== tree-tab-toggle-hide +If the current tab's children are shown hide them, and vice-versa. + +This toggles the current tab's node's `collapsed` attribute. + +==== count +Which tab to collapse + [[unbind]] === unbind Syntax: +:unbind [*--mode* 'mode'] 'key'+ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index af76527c9..73646e541 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -332,6 +332,11 @@ |<<tabs.mousewheel_switching,tabs.mousewheel_switching>>|Switch between tabs using the mouse wheel. |<<tabs.new_position.related,tabs.new_position.related>>|Position of new tabs opened from another tab. |<<tabs.new_position.stacking,tabs.new_position.stacking>>|Stack related tabs on top of each other when opened consecutively. +|<<tabs.new_position.tree.demote,tabs.new_position.tree.demote>>|Position at which a tab is placed among its new siblings after being demoted with `:tree-tab-demote` +|<<tabs.new_position.tree.new_child,tabs.new_position.tree.new_child>>|Position of new children among siblings, e.g. after calling `:open --relative ...` or following a link. +|<<tabs.new_position.tree.new_sibling,tabs.new_position.tree.new_sibling>>|Position of siblings, e.g. after calling `:open --sibling ...`. +|<<tabs.new_position.tree.new_toplevel,tabs.new_position.tree.new_toplevel>>|Position of new top-level tabs related to the topmost ancestor of current tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. +|<<tabs.new_position.tree.promote,tabs.new_position.tree.promote>>|Position at which a tab is placed among its new siblings after being promoted with `:tree-tab-promote` |<<tabs.new_position.unrelated,tabs.new_position.unrelated>>|Position of new tabs which are not opened from another tab. |<<tabs.padding,tabs.padding>>|Padding (in pixels) around text for tabs. |<<tabs.pinned.frozen,tabs.pinned.frozen>>|Force pinned tabs to stay at fixed URL. @@ -346,6 +351,7 @@ |<<tabs.title.format,tabs.title.format>>|Format to use for the tab title. |<<tabs.title.format_pinned,tabs.title.format_pinned>>|Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. |<<tabs.tooltips,tabs.tooltips>>|Show tooltips on tabs. +|<<tabs.tree_tabs,tabs.tree_tabs>>|Enable tree-tabs mode. |<<tabs.undo_stack_size,tabs.undo_stack_size>>|Number of closed tabs (per window) and closed windows to remember for :undo (-1 for no maximum). |<<tabs.width,tabs.width>>|Width (in pixels or as percentage of the window) of the tab bar if it's vertical. |<<tabs.wrap,tabs.wrap>>|Wrap when changing tabs. @@ -372,7 +378,7 @@ The keys of the given dictionary are the aliases, while the values are the comma Type: <<types,Dict>> -Default: +Default: - +pass:[q]+: +pass:[close]+ - +pass:[qa]+: +pass:[quit]+ @@ -479,7 +485,7 @@ This setting can only be set in config.py. Type: <<types,Dict>> -Default: +Default: - +pass:[caret]+: @@ -748,6 +754,17 @@ Default: * +pass:[yp]+: +pass:[yank pretty-url]+ * +pass:[yt]+: +pass:[yank title]+ * +pass:[yy]+: +pass:[yank]+ +* +pass:[zG]+: +pass:[cmd-set-text -s :tree-tab-create-group]+ +* +pass:[zH]+: +pass:[tree-tab-promote]+ +* +pass:[zJ]+: +pass:[tab-next -s]+ +* +pass:[zK]+: +pass:[tab-prev -s]+ +* +pass:[zL]+: +pass:[tree-tab-demote]+ +* +pass:[zO]+: +pass:[cmd-set-text --space :open -tS]+ +* +pass:[za]+: +pass:[tree-tab-toggle-hide]+ +* +pass:[zd]+: +pass:[tab-close -r]+ +* +pass:[zg]+: +pass:[cmd-set-text -s :tree-tab-create-group -r]+ +* +pass:[zo]+: +pass:[cmd-set-text --space :open -tr]+ +* +pass:[zp]+: +pass:[tab-focus parent]+ * +pass:[{{]+: +pass:[navigate prev -t]+ * +pass:[}}]+: +pass:[navigate next -t]+ - +pass:[passthrough]+: @@ -805,7 +822,7 @@ Note that when a key is bound (via `bindings.default` or `bindings.commands`), t Type: <<types,Dict>> -Default: +Default: - +pass:[<Ctrl-6>]+: +pass:[<Ctrl-^>]+ - +pass:[<Ctrl-Enter>]+: +pass:[<Ctrl-Return>]+ @@ -879,7 +896,7 @@ May be a single color to use for all columns or a list of three colors, one for Type: <<types,List of QtColor, or QtColor>> -Default: +Default: - +pass:[white]+ - +pass:[white]+ @@ -1833,7 +1850,7 @@ Valid values: * +history+ * +filesystem+ -Default: +Default: - +pass:[searchengines]+ - +pass:[quickmarks]+ @@ -1938,7 +1955,7 @@ Valid values: * +downloads+: Show a confirmation if downloads are running * +never+: Never show a confirmation. -Default: +Default: - +pass:[never]+ @@ -1969,7 +1986,7 @@ need to find the link to the raw `.txt` file (e.g. by extracting it from the Type: <<types,List of Url>> -Default: +Default: - +pass:[https://easylist.to/easylist/easylist.txt]+ - +pass:[https://easylist.to/easylist/easyprivacy.txt]+ @@ -2014,7 +2031,7 @@ The file `~/.config/qutebrowser/blocked-hosts` is always read if it exists. Type: <<types,List of Url>> -Default: +Default: - +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+ @@ -2396,7 +2413,7 @@ The following levels are valid: `none`, `debug`, `info`, `warning`, `error`. Type: <<types,Dict>> -Default: +Default: - +pass:[error]+: +pass:[debug]+ - +pass:[info]+: +pass:[debug]+ @@ -2957,7 +2974,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[gvim]+ - +pass:[-f]+ @@ -3019,7 +3036,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[xterm]+ - +pass:[-e]+ @@ -3035,7 +3052,7 @@ The following placeholders are defined: Type: <<types,ShellCommand>> -Default: +Default: - +pass:[xterm]+ - +pass:[-e]+ @@ -3390,7 +3407,7 @@ Comma-separated list of regular expressions to use for 'next' links. Type: <<types,List of Regex>> -Default: +Default: - +pass:[\bnext\b]+ - +pass:[\bmore\b]+ @@ -3405,7 +3422,7 @@ Padding (in pixels) for hints. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[3]+ @@ -3418,7 +3435,7 @@ Comma-separated list of regular expressions to use for 'prev' links. Type: <<types,List of Regex>> -Default: +Default: - +pass:[\bprev(ious)?\b]+ - +pass:[\bback\b]+ @@ -3453,7 +3470,7 @@ This setting can only be set in config.py. Type: <<types,Dict>> -Default: +Default: - +pass:[all]+: @@ -4158,7 +4175,7 @@ Padding (in pixels) for the statusbar. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[1]+ - +pass:[left]+: +pass:[0]+ @@ -4211,7 +4228,7 @@ Valid values: * +text:foo+: Display the static text after the colon, `foo` in the example. * +clock+: Display current time. The format can be changed by adding a format string via `clock:...`. For supported format strings, see https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes[the Python datetime documentation]. -Default: +Default: - +pass:[keypress]+ - +pass:[search_match]+ @@ -4296,7 +4313,7 @@ Padding (in pixels) for tab indicators. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[2]+ - +pass:[left]+: +pass:[0]+ @@ -4396,6 +4413,77 @@ Type: <<types,Bool>> Default: +pass:[true]+ +[[tabs.new_position.tree.demote]] +=== tabs.new_position.tree.demote +Position at which a tab is placed among its new siblings after being demoted with `:tree-tab-demote` + +Type: <<types,NewChildPosition>> + +Valid values: + + * +first+: At the beginning. + * +last+: At the end. + +Default: +pass:[last]+ + +[[tabs.new_position.tree.new_child]] +=== tabs.new_position.tree.new_child +Position of new children among siblings, e.g. after calling `:open --relative ...` or following a link. + +Type: <<types,NewChildPosition>> + +Valid values: + + * +first+: At the beginning. + * +last+: At the end. + +Default: +pass:[first]+ + +[[tabs.new_position.tree.new_sibling]] +=== tabs.new_position.tree.new_sibling +Position of siblings, e.g. after calling `:open --sibling ...`. + +Type: <<types,NewTabPosition>> + +Valid values: + + * +prev+: Before the current tab. + * +next+: After the current tab. + * +first+: At the beginning. + * +last+: At the end. + +Default: +pass:[first]+ + +[[tabs.new_position.tree.new_toplevel]] +=== tabs.new_position.tree.new_toplevel +Position of new top-level tabs related to the topmost ancestor of current tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. + +Type: <<types,NewTabPosition>> + +Valid values: + + * +prev+: Before the current tab. + * +next+: After the current tab. + * +first+: At the beginning. + * +last+: At the end. + +Default: +pass:[last]+ + +[[tabs.new_position.tree.promote]] +=== tabs.new_position.tree.promote +Position at which a tab is placed among its new siblings after being promoted with `:tree-tab-promote` + +Type: <<types,NewTabPosition>> + +Valid values: + + * +prev+: Before the current tab. + * +next+: After the current tab. + * +first+: At the beginning. + * +last+: At the end. + +Default: +pass:[next]+ + [[tabs.new_position.unrelated]] === tabs.new_position.unrelated Position of new tabs which are not opened from another tab. @@ -4418,7 +4506,7 @@ Padding (in pixels) around text for tabs. Type: <<types,Padding>> -Default: +Default: - +pass:[bottom]+: +pass:[0]+ - +pass:[left]+: +pass:[5]+ @@ -4536,6 +4624,8 @@ Format to use for the tab title. The following placeholders are defined: * `{perc}`: Percentage as a string like `[10%]`. +* `{collapsed}`: If children tabs are hidden, the string `[...]`, empty otherwise +* `{tree}`: The ASCII tree prefix of current tab. * `{perc_raw}`: Raw percentage, e.g. `10`. * `{current_title}`: Title of the current web page. * `{title_sep}`: The string `" - "` if a title is set, empty otherwise. @@ -4555,7 +4645,7 @@ The following placeholders are defined: Type: <<types,FormatString>> -Default: +pass:[{audio}{index}: {current_title}]+ +Default: +pass:[{tree}{collapsed}{audio}{index}: {current_title}]+ [[tabs.title.format_pinned]] === tabs.title.format_pinned @@ -4574,6 +4664,16 @@ Type: <<types,Bool>> Default: +pass:[true]+ +[[tabs.tree_tabs]] +=== tabs.tree_tabs +Enable tree-tabs mode. + +This setting requires a restart. + +Type: <<types,Bool>> + +Default: +pass:[false]+ + [[tabs.undo_stack_size]] === tabs.undo_stack_size Number of closed tabs (per window) and closed windows to remember for :undo (-1 for no maximum). @@ -4636,7 +4736,7 @@ Valid values: * +query+ * +anchor+ -Default: +Default: - +pass:[path]+ - +pass:[query]+ @@ -4678,7 +4778,7 @@ term, e.g. `:open google qutebrowser`. Type: <<types,Dict>> -Default: +Default: - +pass:[DEFAULT]+: +pass:[https://duckduckgo.com/?q={}]+ @@ -4696,7 +4796,7 @@ URL parameters to strip with `:yank url`. Type: <<types,List of String>> -Default: +Default: - +pass:[ref]+ - +pass:[utm_source]+ @@ -4757,7 +4857,7 @@ Available zoom levels. Type: <<types,List of Perc>> -Default: +Default: - +pass:[25%]+ - +pass:[33%]+ @@ -4833,6 +4933,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v When setting from a string, pass a json-like list, e.g. `["one", "two"]`. |ListOrValue|A list of values, or a single value. |LogLevel|A logging level. +|NewChildPosition|How new children are positioned. |NewTabPosition|How new tabs are positioned. |Padding|Setting for paddings around elements. |Perc|A percentage. diff --git a/doc/img/treetabs/tree_tabs_new_tab_types.png b/doc/img/treetabs/tree_tabs_new_tab_types.png 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..d52a08de3 --- /dev/null +++ b/doc/treetabs.md @@ -0,0 +1,219 @@ +# Tree Style Tabs + +## Intro + +Tree style tabs allow you to group and manage related tabs together. Related +tabs will be shown in a hierarchical fashion in the tab bar when it is on the +left or right side of the browser window. It can be enabled by setting +`tabs.tree_tabs` to `true`. That setting only applies to new windows created +after it is enabled (including via saving and loading a session or +`:restart`). + +![](img/treetabs/tree_tabs_overview_detail.png) + +When a tab is being opened it will be classified as one of *unrelated* +(default), *sibling* or *related* to the current tab. + +![](img/treetabs/tree_tabs_new_tab_types.png) + +* *unrelated* tabs are created at the top level of the tree for the current + browser window. They can be created by opening a new tab using `:open -t`. +* *sibling* tabs are created at the same level as the current tab. They can be + created by running `:open -t -S`. +* *related* tabs are created as children of the current tab. They can be + created by following a link in a new tab (middle click, `F` hinting mode) or + by running `:open -t -r`. + +## Enabling Tree Tabs + +TODO: more words here + +* `tabs.tree_tabs` +* check default settings: title format, padding, elide +* steps to take when downgrading if you don't want to lose settings + +## Manipulating the Tree + +todo: add animated illustrations? + +You can change how tabs relate to each other after they are created too. + +* `:open`, as described in the intro, has picked up some new behavior to + decide where in relation to the current tab a new one should go. It has a + new `--sibling` argument and the existing arguments `--related`, `--tab` and + `--background` have picked up some additional meaning to help with that. +* `:tab-move` will move a tab and its children within the tree + * With a `+` or `-` argument tabs will only move within their siblings + (wrapping at the top or bottom) + * With a count or integer argument tabs will move to the absolute position + specified, which may include changing level in the hierarchy. +* Tabs can be moved up and down a hierarchy with the commands + `:tree-tab-promote` and `:tree-tab-demote` +* `:tab-give --recursive` will move a tab and its children to another window. + They will be placed at the top level. +* Some methods of moving tabs do *not* yet understand tab groups, these are: + * `:tab-take` + * moving tabs with a mouse or other pointer + +Other pre-existing commands that understand tab groups are: + +* `:tab-close --recursive` will close a tab and all its children. If + `:tab-close` is used without `--recursive` the first of a tabs children will + be promoted in its place. +* `:tab-focus parent` will switch focus to a tab's parent, so that you don't + have to cycle through a tab's siblings to get there. +* `:tab-next --sibling` and `:tab-prev --sibling` will switch the focus to a + tab's sibling, skipping any child tabs. + +## Working with Tab Groups + +Beyond the commands above for manipulating the tree, there are a few new +commands introduced to take advantage of the tab grouping feature. + +* `:tree-tab-create-group {name}` will create a new placeholder tab with a + title of `{name}`. This is a light weight way of creating a "named group" by + putting a tab with a meaningful title at the top level of it. It can + create tabs at the top level of the window or under the current tab with the + `--related` argument. The placeholder tab contains an ascii art picture of a + tree. The title of the tab comes from the URL path. +* `:tree-tab-toggle-hide` will collapse, or reveal, a tab group, which will + hide any children tabs from the hierarchy shown in the tab bar as well as + making children unelectable via `:tab-focus`, `tab-select` and `:tab-take`. + The tabs will still be running in the background. +* `:tree-tab-cycle-hide` will hide successive levels of a tab's hierarchy of + children. For example, the first time you run it will hide the outermost + generation of leaf nodes, the next time will hide the next level up and so + on. +* `:tree-tab-suspend-children` will suspend all of the children of a tab via + the lazy load mechanism (`qute://back/`). Tabs will be un-suspended when + they are next focused. This apply for any children which are hidden too. + +## Settings + +There are some existing settings that will have modified behavior when tree +tabs are enabled: + +* `tabs.new_position.related`: this is essentially replaced by + `tabs.new_position.new_child` +* `tabs.new_position.unrelated`: this is essentially replaced by + `tabs.new_position.new_toplevel` +* the settings `tabs.title.format`, `tabs.title.format_pinned` and + `window.title_format` have gained two new template variables: `{tree}` and + `{collapsed}`. These are for displaying the tree structure in the tab bar and + the default value for `tabs.title.format` now has `{tree}{collapsed}` at the + start of it. + +There are a few new settings introduced to control where tabs are places in +the tree structure as a result of various operations. All of these settings +accept the options `first`, `last`, `next` or `prev`; apart from `new_child` +and `demote` which only accept `first` or `last`. + +* `tabs.new_position.promote` +* `tabs.new_position.demote` +* `tabs.new_position.new_toplevel` +* `tabs.new_position.new_sibling` +* `tabs.new_position.new_child` + +## Bindings + +There are various new default bindings introduced to make accessing the new +and changed commands easy. They all start with the letter `z`: + +TODO: more words here? Are any of these bindings analogous to existing +ones? Any theme to them? + +* `zH`: `tree-tab-promote` +* `zL`: `tree-tab-demote` +* `zK`: `tab-prev -s` - cycle tab focus upwards among siblings +* `zJ`: `tab-next -s` - cycle tab focus downwards among siblings +* `zd`: `tab-close -r` - r = recursive +* `zg`: `cmd-set-text -s :tree-tab-create-group -r` - r = related +* `zG`: `cmd-set-text -s :tree-tab-create-group` +* `za`: `tree-tab-toggle-hide` - same binding as vim folds +* `zp`: `tab-focus parent` +* `zo`: `cmd-set-text --space :open -tr` - r = related +* `zO`: `cmd-set-text --space :open -tS` - S = sibling + +## Implementation + +The core tree data structure is in `qutebrowser/misc/notree.py`, inspired by +the `anytree` python library. It defines a `Node` type. A Node can have a +parent, a list of child nodes, and `value` attribute - which in qutebrowser's +case is always a browser tab. A tree of nodes is always modified by changing +either the parent or children of a node via property setters. Beyond those two +setters nodes have `promote()` and `demote()` helper functions used by the +corresponding commands. + +Beyond those four methods to manipulate the tree structure nodes have +methods for: + +* traversing the tree: + * `traverse()` return all descendant nodes (including self) + * `path()` return all nodes from self up to the tree root, inclusive + * `depth()` return depth in tree +* collapsing a node + * this just sets an attribute on a node, the traversal function respects it + but beyond that it's up to callers to know that an un-collapsed node may + be hidden if a parent node is collapsed, there are a few pieces of + calling code which do implement different behavior for collapsed nodes +* rendering unicode tree segments to be used in tab titles + * our tab bar itself doesn't understand the tree structure for now, it's + just being represented by drawing unicode line and angle characters to + the left of the tab titles which happen to line up + * this does generally put some restrictions on some tab bar related + settings. `tabs.title.format` needs to have `{tree}{collapsed}` in it, + `tabs.padding` needs to have 0 for the top and bottom padding, + `tabs.title.elide` can't be on the same side as the tree related format strings. + +Beyond the core data structure most of the changes are in places where tabs +need to relate to each other. There are two new subclasses of existing core +classes: + +*TreeTabbedBrowser* inherits the main TabbedBrowser and has overriden methods +to make sure tabs are correctly positioned when opening a tab, closing a tab +and undoing a tab close. After tabs are opened they are placed into the +correct position in the tree based on the new `tabs.new_position.*` settings +and then into order in the tab widget corresponding to the tree traversal +order. When tabs are closed the new `--recursive` flag is handled, children +are re-parented in the tree and extra details are added to undo entries. When +a tab close is undone its position in the tree is restored, including demoting +any child that was promoted when the tab was closed. TreeTabbedBrowsers will +be created by MainWindow when the new `tabs.tree_tabs` setting is set. + +*TreeTabWidget* handles making sure the new `{tree}` and `{collapsed}` are +filled in for the tab title template string, with a lot of help from the data +structure. It also handles hiding or showing tabs for collapsed +groups/branches. Hidden tabs are children of tabs with the `collapsed` +property set, they remain in the tree structure (which is held by the tabbed +browser) but they are removed entirely from the tab widget. The +`tree_tab_update()` method, which is called from several places, also handles +making sure tabs are moved to indices corresponding to their traversal order +in the tree, in case any changes have been made to the tree structure. + +One place in the tab widget classes where tree tab specific code isn't +contained entirely in TreeTabWidget is the `{tree}` and `{collapsed}` +tab/window title format attributes. A key error will be thrown if a +placeholder is in the format string but no value is supplied for it. So the +parent class initialize them to an empty string in case users have them +configured but have tree tabs turned off. + +A fair amount of tree tab specific code lives in *commands.py*. The six new +commands have been added, as well as a customization so that these commands +don't show up in the command completion if the tree tabs feature isn't +enabled. The commands for manipulating the tree structure do very little but +call out to other pieces of code, either the browser or the tree structure. +Of note are the two commands `tree_tab_create_group()` and +`tree_tab_suspend_children()` which use the scheme handlers `qute://treegroup` +(new) and `qute://back` (existing). + +Beyond those six new commands quite a few existing commands to do with +manipulating tabs have seen some tree tab specific code paths added, some of +them quite complex and with little shared with the existing code paths. Common +themes beyond handling new arguments are dealing with recursive operations and +collapsed nodes. + +something something sessions.py + +Other stuff, like tree group page + +## Outstanding issues? Questions? diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 625046a9c..e7e56a061 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -40,6 +40,8 @@ if TYPE_CHECKING: from qutebrowser.browser.webengine.webview import WebEngineView from qutebrowser.browser.webkit.webview import WebView +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.misc.notree import Node tab_id_gen = itertools.count(0) _WidgetType = Union["WebView", "WebEngineView"] @@ -1058,6 +1060,11 @@ class AbstractTab(QWidget): self, parent=self) self.backend: Optional[usertypes.Backend] = None + if parent is not None and isinstance(parent, TreeTabWidget): + self.node: AbstractTab = Node(self, parent=parent.tree_root) + else: + self.node: AbstractTab = Node(self, parent=None) + # If true, this tab has been requested to be removed (or is removed). self.pending_removal = False self.shutting_down.connect(functools.partial( diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 83a846b85..6784f0c1d 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -7,6 +7,7 @@ import os.path import shlex import functools +import urllib.parse from typing import cast, Callable, Dict, Union, Optional from qutebrowser.qt.widgets import QApplication, QTabBar @@ -21,7 +22,7 @@ from qutebrowser.keyinput import modeman, keyutils from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, standarddir, debug) from qutebrowser.utils.usertypes import KeyMode -from qutebrowser.misc import editor, guiprocess, objects +from qutebrowser.misc import editor, guiprocess, objects, notree from qutebrowser.completion.models import urlmodel, miscmodels from qutebrowser.mainwindow import mainwindow, windowundo @@ -99,6 +100,7 @@ class CommandDispatcher: background: bool = False, window: bool = False, related: bool = False, + sibling: bool = False, private: Optional[bool] = None, ) -> None: """Helper function to open a page. @@ -110,6 +112,7 @@ class CommandDispatcher: window: Whether to open in a new window private: If opening a new window, open it in private browsing mode. If not given, inherit the current window's mode. + sibling: Open tab in a sibling node of the currently focused tab. """ urlutils.raise_cmdexc_if_invalid(url) tabbed_browser = self._tabbed_browser @@ -122,10 +125,16 @@ class CommandDispatcher: tabbed_browser = self._new_tabbed_browser(private) tabbed_browser.tabopen(url) tabbed_browser.window().show() - elif tab: - tabbed_browser.tabopen(url, background=False, related=related) - elif background: - tabbed_browser.tabopen(url, background=True, related=related) + elif tab or background: + if tabbed_browser.is_treetabbedbrowser: + tabbed_browser.tabopen(url, background=background, + related=related, sibling=sibling) + elif sibling: + raise cmdutils.CommandError("--sibling flag only works with \ + tree-tab enabled") + else: + tabbed_browser.tabopen(url, background=background, + related=related) else: widget = self._current_widget() widget.load_url(url) @@ -207,7 +216,8 @@ class CommandDispatcher: "{!r}!".format(conf_selection)) return None - def _tab_close(self, tab, prev=False, next_=False, opposite=False): + def _tab_close(self, tab, prev=False, next_=False, + opposite=False, new_undo=True): """Helper function for tab_close be able to handle message.async. Args: @@ -223,17 +233,17 @@ class CommandDispatcher: opposite) if selection_override is None: - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) else: old_selection_behavior = tabbar.selectionBehaviorOnRemove() tabbar.setSelectionBehaviorOnRemove(selection_override) - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, new_undo=new_undo) tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) def tab_close(self, prev=False, next_=False, opposite=False, - force=False, count=None): + force=False, count=None, recursive=False): """Close the current/[count]th tab. Args: @@ -242,15 +252,36 @@ class CommandDispatcher: opposite: Force selecting the tab in the opposite direction of what's configured in 'tabs.select_on_remove'. force: Avoid confirmation for pinned tabs. + recursive: Close all descendents (tree-tabs) as well as current tab count: The tab index to close, or None """ tab = self._cntwidget(count) + tabbed_browser = self._tabbed_browser if tab is None: return - close = functools.partial(self._tab_close, tab, prev, - next_, opposite) - - self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) + if (tabbed_browser.is_treetabbedbrowser and recursive and not + tab.node.collapsed): + # if collapsed, recursive is the same as normal close + new_undo = True # only for first one + for descendent in tab.node.traverse(notree.TraverseOrder.POST_R, + True): + if self._tabbed_browser.widget.indexOf(descendent.value) > -1: + close = functools.partial(self._tab_close, + descendent.value, prev, next_, + opposite, new_undo) + tabbed_browser.tab_close_prompt_if_pinned(descendent.value, force, + close) + new_undo = False + else: + tab = descendent.value + tab.private_api.shutdown() + tab.deleteLater() + else: + # this also applied to closing collapsed tabs + # logic for that is in TreeTabbedBrowser + close = functools.partial(self._tab_close, tab, prev, + next_, opposite) + tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') @@ -275,8 +306,9 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @cmdutils.argument('url', completion=urlmodel.url) + @cmdutils.argument('sibling', flag='S') @cmdutils.argument('count', value=cmdutils.Value.count) - def openurl(self, url=None, related=False, + def openurl(self, url=None, related=False, sibling=False, bg=False, tab=False, window=False, count=None, secure=False, private=False): """Open a URL in the current/[count]th tab. @@ -290,6 +322,8 @@ class CommandDispatcher: window: Open in a new window. related: If opening a new tab, position the tab as related to the current one (like clicking on a link). + sibling: If opening a new tab, position the as a sibling of the + current one. count: The tab index to open the URL in, or None. secure: Force HTTPS. private: Open a new window in private browsing mode. @@ -308,8 +342,8 @@ class CommandDispatcher: bg = True if tab or bg or window or private: - self._open(cur_url, tab, bg, window, related=related, - private=private) + self._open(cur_url, tab, bg, window, private=private, + related=related, sibling=sibling) else: curtab = self._cntwidget(count) if curtab is None: @@ -448,11 +482,45 @@ class CommandDispatcher: if not keep: tabbed_browser.close_tab(tab, add_undo=False, transfer=True) + def _tree_tab_give(self, tabbed_browser, keep): + """Helper function to simplify tab-give.""" + # first pass: open tabs and save the uids of the new nodes + uid_map = {} # old_uid -> new_uid + traversed = list(self._current_widget().node.traverse()) + for node in traversed: + tab = tabbed_browser.tabopen( + node.value.url(), + related=False, + ) + uid_map[node.uid] = tab.node.uid + + # second pass: copy tree structure over + newroot = tabbed_browser.widget.tree_root + for node in traversed: + if node.parent.uid in uid_map: + uid = uid_map[node.uid] + new_node = newroot.get_descendent_by_uid(uid) + parent_uid = uid_map[node.parent.uid] + new_parent = newroot.get_descendent_by_uid(parent_uid) + new_node.parent = new_parent + + # third pass: remove tabs from old window, children first this time to + # avoid having to re-parent things when traversing. + if not keep: + for node in self._current_widget().node.traverse( + notree.TraverseOrder.POST_R, + render_collapsed=False, + ): + self._tabbed_browser.close_tab(node.value, + add_undo=False, + transfer=True) + @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('win_id', completion=miscmodels.window) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_give(self, win_id: int = None, keep: bool = False, - count: int = None, private: bool = False) -> None: + count: int = None, private: bool = False, + recursive: bool = False) -> None: """Give the current tab to a new or existing window if win_id given. If no win_id is given, the tab will get detached into a new window. @@ -461,6 +529,7 @@ class CommandDispatcher: win_id: The window ID of the window to give the current tab to. keep: If given, keep the old tab around. count: Overrides win_id (index starts at 1 for win_id=0). + recursive: Whether to move the entire subtree starting at the tab. private: If the tab should be detached into a private instance. """ if config.val.tabs.tabs_are_windows: @@ -492,14 +561,18 @@ class CommandDispatcher: raise cmdutils.CommandError( "The window with id {} is not private".format(win_id)) - tabbed_browser.tabopen(self._current_url()) + if recursive and tabbed_browser.is_treetabbedbrowser: + self._tree_tab_give(tabbed_browser, keep) + else: + tabbed_browser.tabopen(self._current_url()) + if not keep: + self._tabbed_browser.close_tab(self._current_widget(), + add_undo=False, + transfer=True) + # Make sure the tabbed browser is shown in case we created a new one + # when detaching. tabbed_browser.window().show() - if not keep: - self._tabbed_browser.close_tab(self._current_widget(), - add_undo=False, - transfer=True) - def _back_forward( self, *, tab: bool, @@ -876,43 +949,79 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_prev(self, count=1): + def tab_prev(self, count=1, sibling=False): """Switch to the previous tab, or switch [count] tabs back. Args: count: How many tabs to switch back. + sibling: Whether to focus the previous tree sibling. """ if self._count() == 0: # Running :tab-prev after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() - count - if newidx >= 0: - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx - count + if new_idx >= 0 or config.val.tabs.wrap: + target_node = siblings[(node_idx-count) % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("First sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("First tab") + newidx = self._current_index() - count + if newidx >= 0: + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("First tab") @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', value=cmdutils.Value.count) - def tab_next(self, count=1): + def tab_next(self, count=1, sibling=False): """Switch to the next tab, or switch [count] tabs forward. Args: count: How many tabs to switch forward. + sibling: Whether to focus the next tree sibling. """ if self._count() == 0: # Running :tab-next after last tab was closed # See https://github.com/qutebrowser/qutebrowser/issues/1448 return - newidx = self._current_index() + count - if newidx < self._count(): - self._set_current_index(newidx) - elif config.val.tabs.wrap: - self._set_current_index(newidx % self._count()) + if sibling and self._tabbed_browser.is_treetabbedbrowser: + cur_node = self._current_widget().node + siblings = list(cur_node.parent.children) + + if siblings and len(siblings) > 1: + node_idx = siblings.index(cur_node) + new_idx = node_idx + count + if new_idx < len(siblings) or config.val.tabs.wrap: + target_node = siblings[new_idx % len(siblings)] + idx = self._tabbed_browser.widget.indexOf( + target_node.value) + self._set_current_index(idx) + else: + log.webview.debug("Last sibling") + else: + log.webview.debug("No siblings") else: - log.webview.debug("Last tab") + newidx = self._current_index() + count + if newidx < self._count(): + self._set_current_index(newidx) + elif config.val.tabs.wrap: + self._set_current_index(newidx % self._count()) + else: + log.webview.debug("Last tab") def _resolve_tab_index(self, index): """Resolve a tab index to the tabbedbrowser and tab. @@ -992,7 +1101,8 @@ class CommandDispatcher: tabbed_browser.widget.setCurrentWidget(tab) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'], + @cmdutils.argument('index', choices=['last', 'parent', + 'stack-next', 'stack-prev'], completion=miscmodels.tab_focus) @cmdutils.argument('count', value=cmdutils.Value.count) def tab_focus(self, index: Union[str, int] = None, @@ -1003,11 +1113,15 @@ class CommandDispatcher: If both are given, use count. Args: - index: The tab index to focus, starting with 1. The special value - `last` focuses the last focused tab (regardless of count), - and `stack-prev`/`stack-next` traverse a stack of visited - tabs. Negative indices count from the end, such that -1 is - the last tab. + index: The tab index to focus, starting with 1. Negative indices + count from the end, such that -1 is the last tab. Special + values are: + - `last` focuses the last focused tab (regardless of + count). + - `parent` focuses the parent tab in the tree hierarchy, + if `tabs.tree_tabs` is enabled. + - `stack-prev`/`stack-next` traverse a stack of visited + tabs. count: The tab index to focus, starting with 1. no_last: Whether to avoid focusing last tab if already focused. """ @@ -1017,6 +1131,22 @@ class CommandDispatcher: assert isinstance(index, str) self._tab_focus_stack(index) return + elif index == 'parent' and self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + path = node.path + if count: + if count < len(path): + path_idx = 0 - count - 1 # path[-1] is node, so shift by 1 + else: + path_idx = 1 # first non-root node + else: + path_idx = -2 # immediate parent (path[-1] is node) + + target_node = path[path_idx] + if node is target_node or target_node.value is None: + raise cmdutils.CommandError("Tab has no parent! ") + target_tab = target_node.value + index = self._tabbed_browser.widget.indexOf(target_tab) + 1 elif index is None: message.warning( "Using :tab-focus without count is deprecated, " @@ -1056,17 +1186,31 @@ class CommandDispatcher: If moving absolutely: New position (default: 0). This overrides the index argument, if given. """ + # pylint: disable=invalid-unary-operand-type + # https://github.com/PyCQA/pylint/issues/1472 if index in ["+", "-"]: # relative moving new_idx = self._current_index() delta = 1 if count is None else count - if index == "-": - new_idx -= delta - elif index == "+": # pragma: no branch - new_idx += delta - if config.val.tabs.wrap: - new_idx %= self._count() + if self._tabbed_browser.is_treetabbedbrowser: + node = self._current_widget().node + parent = node.parent + siblings = list(parent.children) + + if len(siblings) <= 1: + return + rel_idx = siblings.index(node) + rel_idx += delta if index == '+' else - delta + rel_idx %= len(siblings) + new_idx = self._tabbed_browser.widget.indexOf( + siblings[rel_idx].value) + + else: + new_idx += delta if index == '+' else - delta + + if config.val.tabs.wrap: + new_idx %= self._count() else: # pylint: disable=else-if-used # absolute moving @@ -1089,7 +1233,34 @@ class CommandDispatcher: cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') cmdutils.check_overflow(new_idx, 'int') - self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) + + if self._tabbed_browser.is_treetabbedbrowser: + # self._tree_tab_move(new_idx) + new_idx += 1 # tree-tabs indexes start at 1 (0 is hidden root tab) + tab = self._current_widget() + + # traverse order is the same as display order + # so indexing works correctly + tree_root = self._tabbed_browser.widget.tree_root + tabs = list(tree_root.traverse(render_collapsed=False)) + target_node = tabs[new_idx] + if tab.node in target_node.path: + raise cmdutils.CommandError("Can't move tab to a descendent" + " of itself") + + new_parent = target_node.parent + # we need index relative to parent for correct placement + dest_tab = tabs[new_idx] + new_idx_relative = new_parent.children.index(dest_tab) + + tab.node.parent = None # avoid duplicate errors + siblings = list(new_parent.children) + siblings.insert(new_idx_relative, tab.node) + new_parent.children = siblings + + self._tabbed_browser.widget.tree_tab_update() + else: + self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) @@ -1905,3 +2076,123 @@ class CommandDispatcher: log.misc.debug('state before fullscreen: {}'.format( debug.qflags_key(Qt, window.state_before_fullscreen))) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_promote(self, count=1): + """Promote a tab so it becomes next sibling of its parent. + + Observes tabs.new_position.tree.promote in positioning the tab among + new siblings. + + Args: + count: How many levels the tabs should be promoted to + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + config_position = config.val.tabs.new_position.tree.promote + try: + self._current_widget().node.promote(count, config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no parent!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_demote(self): + """Demote a tab making it children of its previous adjacent sibling. + + Observes tabs.new_position.tree.demote in positioning the tab among new + siblings. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + cur_node = self._current_widget().node + + config_position = config.val.tabs.new_position.tree.demote + try: + cur_node.demote(config_position) + except notree.TreeError: + raise cmdutils.CommandError('Tab has no previous sibling!') + finally: + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_toggle_hide(self, count=None): + """If the current tab's children are shown hide them, and vice-versa. + + This toggles the current tab's node's `collapsed` attribute. + + Args: + count: Which tab to collapse + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + tab = self._cntwidget(count) + if not tab.node.children: + return + tab.node.collapsed = not tab.node.collapsed + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_cycle_hide(self, count=1): + """Hides levels of descendents: children, grandchildren, and so on. + + Args: + count: How many levels to hide. + """ + if not self._tabbed_browser.is_treetabbedbrowser: + raise cmdutils.CommandError('Tree-tabs are disabled') + while count > 0: + tab = self._current_widget() + self._tabbed_browser.cycle_hide_tab(tab.node) + count -= 1 + + self._tabbed_browser.widget.tree_tab_update() + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + def tree_tab_create_group(self, *name, related=False, + background=False): + """Wrapper around :open qute://treegroup/name. Correctly escapes names. + + Example: `:tree-tab-create-group Foo Bar` calls + `:open qute://treegroup/Foo%20Bar` + + Args: + name: Name of the group to create + related: whether to open as a child of current tab or under root + background: whether to open in a background tab + """ + title = ' '.join(name) + path = urllib.parse.quote(title) + if background: + self.openurl('qute://treegroup/' + path, related=related, bg=True) + else: + self.openurl('qute://treegroup/' + path, related=related, tab=True) + + @cmdutils.register(instance='command-dispatcher', scope='window', + tree_tab=True) + @cmdutils.argument('count', value=cmdutils.Value.count) + def tree_tab_suspend_children(self, count=None): + """Suspends all descendent of a tab to reduce memory usage. + + Args: + count: Target tab. + """ + tab = self._cntwidget(count) + for descendent in tab.node.traverse(): + cur_tab = descendent.value + if cur_tab and cur_tab is not tab: + cur_url = cur_tab.url().url() + if not cur_url.startswith("qute://"): + new_url = self._parse_url( + "qute://back/#" + cur_tab.title()) + cur_tab.load_url(new_url) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 508d510d7..e7bb27d8d 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -579,6 +579,18 @@ def qute_warning(url: QUrl) -> _HandlerRet: return 'text/html', src +@add_handler('treegroup') +def qute_treegroup(url): + """Handler for qute://treegroup/x. + + Makes an empty tab with a title, for use with tree-tabs as a grouping + feature. + """ + src = jinja.render('tree_group.html', + title=url.path()[1:]) + return 'text/html', src + + @add_handler('resource') def qute_resource(url: QUrl) -> _HandlerRet: """Handler for qute://resource.""" diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index effdcc9b0..aaf1763e2 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -52,6 +52,7 @@ class Command: both) no_replace_variables: Don't replace variables like {url} modes: The modes the command can be executed in. + tree_tab: Whether the command is a tree-tabs command _qute_args: The saved data from @cmdutils.argument _count: The count set for the command. _instance: The object to bind 'self' to. @@ -66,7 +67,7 @@ class Command: self, *, handler, name, instance=None, maxsplit=None, modes=None, not_modes=None, debug=False, deprecated=False, no_cmd_split=False, star_args_optional=False, scope='global', - backend=None, no_replace_variables=False, + backend=None, no_replace_variables=False, tree_tab=False, ): # pylint: disable=too-many-arguments if modes is not None and not_modes is not None: raise ValueError("Only modes or not_modes can be given!") @@ -96,6 +97,7 @@ class Command: self.handler = handler self.no_cmd_split = no_cmd_split self.backend = backend + self.tree_tab = tree_tab self.no_replace_variables = no_replace_variables self.docparser = docutils.DocstringParser(handler) diff --git a/qutebrowser/completion/models/util.py b/qutebrowser/completion/models/util.py index 492e1b2e5..4957dc6c9 100644 --- a/qutebrowser/completion/models/util.py +++ b/qutebrowser/completion/models/util.py @@ -8,6 +8,7 @@ from typing import Callable, Sequence from qutebrowser.utils import usertypes from qutebrowser.misc import objects +from qutebrowser.config import config DeleteFuncType = Callable[[Sequence[str]], None] @@ -31,8 +32,9 @@ def get_cmd_completions(info, include_hidden, include_aliases, prefix=''): hide_debug = obj.debug and not objects.args.debug hide_mode = (usertypes.KeyMode.normal not in obj.modes and not include_hidden) + hide_tree = obj.tree_tab and not config.cache['tabs.tree_tabs'] hide_ni = obj.name == 'Ni!' - if not (hide_debug or hide_mode or obj.deprecated or hide_ni): + if not (hide_tree or hide_debug or hide_mode or obj.deprecated or hide_ni): bindings = ', '.join(cmd_to_keys.get(obj.name, [])) cmdlist.append((prefix + obj.name, obj.desc, bindings)) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index ca92f96c1..8abc0b7d2 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2272,6 +2272,40 @@ tabs.new_position.stacking: Only applies for `next` and `prev` values of `tabs.new_position.related` and `tabs.new_position.unrelated`. +tabs.new_position.tree.new_child: + default: first + type: NewChildPosition + desc: >- + Position of new children among siblings, e.g. after calling `:open + --relative ...` or following a link. + +tabs.new_position.tree.new_sibling: + default: first + type: NewTabPosition + desc: >- + Position of siblings, e.g. after calling `:open --sibling ...`. + +tabs.new_position.tree.new_toplevel: + default: last + type: NewTabPosition + desc: >- + Position of new top-level tabs related to the topmost ancestor of current + tab, e.g. when calling `:open ...` without `--relative` or `--sibling`. + +tabs.new_position.tree.promote: + default: next + type: NewTabPosition + desc: >- + Position at which a tab is placed among its new siblings after being + promoted with `:tree-tab-promote` + +tabs.new_position.tree.demote: + default: last + type: NewChildPosition + desc: >- + Position at which a tab is placed among its new siblings after being + demoted with `:tree-tab-demote` + tabs.padding: default: top: 0 @@ -2337,7 +2371,7 @@ tabs.title.elide: desc: Position of ellipsis in truncated title of tabs. tabs.title.format: - default: '{audio}{index}: {current_title}' + default: '{tree}{collapsed}{audio}{index}: {current_title}' type: name: FormatString fields: @@ -2355,12 +2389,16 @@ tabs.title.format: - current_url - protocol - audio + - collapsed + - tree none_ok: true desc: | Format to use for the tab title. The following placeholders are defined: * `{perc}`: Percentage as a string like `[10%]`. + * `{collapsed}`: If children tabs are hidden, the string `[...]`, empty otherwise + * `{tree}`: The ASCII tree prefix of current tab. * `{perc_raw}`: Raw percentage, e.g. `10`. * `{current_title}`: Title of the current web page. * `{title_sep}`: The string `" - "` if a title is set, empty otherwise. @@ -2396,6 +2434,8 @@ tabs.title.format_pinned: - current_url - protocol - audio + - collapsed + - tree none_ok: true desc: Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. @@ -2494,6 +2534,12 @@ tabs.wrap: type: Bool desc: Wrap when changing tabs. +tabs.tree_tabs: + default: false + type: Bool + desc: Enable tree-tabs mode. + restart: true + tabs.focus_stack_size: default: 10 type: @@ -3834,6 +3880,17 @@ bindings.default: all no-3rdparty never ;; reload tCu: config-cycle -p -u {url} content.cookies.accept all no-3rdparty never ;; reload + zH: tree-tab-promote + zL: tree-tab-demote + zJ: tab-next -s + zK: tab-prev -s + zd: tab-close -r + zg: cmd-set-text -s :tree-tab-create-group -r + zG: cmd-set-text -s :tree-tab-create-group + za: tree-tab-toggle-hide + zp: tab-focus parent + zo: cmd-set-text --space :open -tr + zO: cmd-set-text --space :open -tS insert: <Ctrl-E>: edit-text <Shift-Ins>: insert-text -- {primary} diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index e789437d3..41f139e14 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1942,6 +1942,21 @@ class NewTabPosition(String): ('last', "At the end.")) +class NewChildPosition(String): + + """How new children are positioned.""" + + def __init__( + self, *, + none_ok: bool = False, + completions: _Completions = None, + ) -> None: + super().__init__(none_ok=none_ok, completions=completions) + self.valid_values = ValidValues( + ('first', "At the beginning."), + ('last', "At the end.")) + + class LogLevel(String): """A logging level.""" diff --git a/qutebrowser/html/tree_group.html b/qutebrowser/html/tree_group.html new file mode 100644 index 000000000..b3717e52b --- /dev/null +++ b/qutebrowser/html/tree_group.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block style %} +h1, p { + margin-left: 3rem; +} + +pre { + margin-left: 6em; +} +{% endblock %} +{% block content %} +<h1> + {{ title }} +</h1> +<p> + <em>Group for tree tabs</em> +</p> +<pre> +{% raw %} + _. + _~.:'^%^ >@~. + ,-~ ? =*= + $^_` ` , ' , + -., + (*-^. , * ;' > + >. ,> . ' .,.,. %-.,_ ,.-, + # ' ` " - " * .,. * .^ ` + *@! ., * ' ' , ;' ' . %! + & " .` :' ` ' . `~, + & ' .` ' ' . '": : +. + ^ .", , ` ' ` * , ' ` | + ] * . , ""] .. ` . , ` , " . . ' ,;, + % ' ::, , / , ' , ; + .* ,* / *% \ . . *' ` , ' '. + ? > . , ::. :;^^. %` ' ` @ + / ' `/ ` &#@%^^ `&`` ` %;; % + ;: :% * * :$%)\ ' `@%$ @%^ ,). + . # %&^ (!*^ .\,. ` ^@%^ $#%%^ ` > + \ :#$% #^&# : ` * %###$%@! & + | ' * %$#@)$*}] ` `#@#%%^ *^ + : *' * @%&&^:$ ` ' `%%. #$$$^^-, 7 + &; @#$~~ ' ` @#$%& $,*.- + *...*^ .._ %$$#@! @ ., *&&#@ + :..^ - !%&@}{#& @#$@% + --_..%#%%$#&% #&%$#;: + $%#^%#@@%%*&;; + a%##@%%@% ;:; + %####j#:::; + &#%Rj;%;;: + &#%%#;::; + $#%##:%:: + "#%%#;:; + ."$###::: + #&$%%#:;: + %&#%%#::; + %&%###;:: + &&#%%#:;; + *@&#%#};:; + $#%#%%^::: + *@#$#%#;::;: + %%@#$####@$:;: + ...%###pinusc@$%%:._____ +{% endraw %} + +</pre> +{% endblock %} diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index e39ac4f9a..9251131e3 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -8,7 +8,7 @@ import binascii import base64 import itertools import functools -from typing import List, MutableSequence, Optional, Tuple, cast +from typing import List, MutableSequence, Optional, Tuple, cast, Union from qutebrowser.qt import machinery from qutebrowser.qt.core import (pyqtBoundSignal, pyqtSlot, QRect, QPoint, QTimer, Qt, @@ -195,7 +195,7 @@ class MainWindow(QWidget): super().__init__(parent) # Late import to avoid a circular dependency # - browsertab -> hints -> webelem -> mainwindow -> bar -> browsertab - from qutebrowser.mainwindow import tabbedbrowser + from qutebrowser.mainwindow import treetabbedbrowser, tabbedbrowser from qutebrowser.mainwindow.statusbar import bar self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -224,8 +224,14 @@ class MainWindow(QWidget): self.is_private = config.val.content.private_browsing or private - self.tabbed_browser: tabbedbrowser.TabbedBrowser = tabbedbrowser.TabbedBrowser( - win_id=self.win_id, private=self.is_private, parent=self) + self.tabbed_browser: Union[tabbedbrowser.TabbedBrowser, + treetabbedbrowser.TreeTabbedBrowser] + if config.val.tabs.tree_tabs: + self.tabbed_browser = treetabbedbrowser.TreeTabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) + else: + self.tabbed_browser = tabbedbrowser.TabbedBrowser( + win_id=self.win_id, private=self.is_private, parent=self) objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) self._init_command_dispatcher() @@ -499,8 +505,10 @@ class MainWindow(QWidget): mode_manager.keystring_updated.connect( self.status.keystring.on_keystring_updated) self.status.cmd.got_cmd[str].connect(self._commandrunner.run_safely) - self.status.cmd.got_cmd[str, int].connect(self._commandrunner.run_safely) - self.status.cmd.returnPressed.connect(self.tabbed_browser.on_cmd_return_pressed) + self.status.cmd.got_cmd[str, int].connect( + self._commandrunner.run_safely) + self.status.cmd.returnPressed.connect( + self.tabbed_browser.on_cmd_return_pressed) self.status.cmd.got_search.connect(self._command_dispatcher.search) # key hint popup diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 28f32c4fd..a635d803e 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -9,8 +9,8 @@ import functools import weakref import datetime import dataclasses -from typing import ( - Any, Deque, List, Mapping, MutableMapping, MutableSequence, Optional, Tuple) +from typing import (Any, Deque, List, Mapping, Union, + MutableMapping, MutableSequence, Optional, Tuple) from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint @@ -34,7 +34,32 @@ class _UndoEntry: index: int pinned: bool created_at: datetime.datetime = dataclasses.field( - default_factory=datetime.datetime.now) + default_factory=datetime.datetime.now, + init=False, # WORKAROUND until py3.10 with kw_only: https://www.trueblade.com/blogs/news/python-3-10-new-dataclass-features + ) + + def restore_into_tab(self, tab: browsertab.AbstractTab) -> None: + """Set the url, history and state of `tab` from this undo entry.""" + tab.history.private_api.deserialize(self.history) + tab.set_pinned(self.pinned) + tab.setFocus() + + @classmethod + def from_tab( + cls, tab: browsertab.AbstractTab, idx: int + ) -> Union["_UndoEntry", List["_UndoEntry"]]: + """Generate an undo entry from `tab`.""" + try: + history_data = tab.history.private_api.serialize() + except browsertab.WebTabError: + return None # special URL + + return cls( + url=tab.url(), + history=history_data, + index=idx, + pinned=tab.data.pinned, + ) UndoStackType = MutableSequence[MutableSequence[_UndoEntry]] @@ -194,17 +219,20 @@ class TabbedBrowser(QWidget): resized = pyqtSignal('QRect') current_tab_changed = pyqtSignal(browsertab.AbstractTab) new_tab = pyqtSignal(browsertab.AbstractTab, int) + is_treetabbedbrowser = False shutting_down = pyqtSignal() + _undo_class = _UndoEntry def __init__(self, *, win_id, private, parent=None): if private: assert not qtutils.is_single_process() super().__init__(parent) - self.widget = tabwidget.TabWidget(win_id, parent=self) self._win_id = win_id self._tab_insert_idx_left = 0 self._tab_insert_idx_right = -1 self.is_shutting_down = False + + self.widget = self._create_tab_widget() self.widget.tabCloseRequested.connect(self.on_tab_close_requested) self.widget.new_tab_requested.connect( self.tabopen) # type: ignore[arg-type,unused-ignore] @@ -242,6 +270,9 @@ class TabbedBrowser(QWidget): config.instance.changed.connect(self._on_config_changed) quitter.instance.shutting_down.connect(self.shutdown) + def _create_tab_widget(self): + return tabwidget.TabWidget(self._win_id, parent=self) + def _update_stack_size(self): newsize = config.instance.get('tabs.undo_stack_size') if newsize < 0: @@ -280,9 +311,11 @@ class TabbedBrowser(QWidget): raise TabDeletedError("index is -1!") return idx - def widgets(self): + def widgets(self) -> List[browsertab.AbstractTab]: """Get a list of open tab widgets. + Consider using `tabs()` instead of this method. + We don't implement this as generator so we can delete tabs while iterating over the list. """ @@ -295,6 +328,18 @@ class TabbedBrowser(QWidget): widgets.append(widget) return widgets + def tabs( + self, + include_hidden: bool = False, # pylint: disable=unused-argument + ) -> List[browsertab.AbstractTab]: + """Get a list of tabs in this browser. + + Args: + include_hidden: Include child tabs which are not currently in the + tab bar. + """ + return self.widgets() + def _update_window_title(self, field=None): """Change the window title to match the current tab. @@ -376,7 +421,7 @@ class TabbedBrowser(QWidget): tab.history_item_triggered.connect( history.web_history.add_from_tab) - def _current_tab(self) -> browsertab.AbstractTab: + def current_tab(self) -> browsertab.AbstractTab: """Get the current browser tab. Note: The assert ensures the current tab is never None. @@ -483,6 +528,7 @@ class TabbedBrowser(QWidget): crashed: Whether we're closing a tab with crashed renderer process. """ idx = self.widget.indexOf(tab) + if idx == -1: if crashed: return @@ -493,36 +539,46 @@ class TabbedBrowser(QWidget): tab.pending_removal = True + if add_undo: + self._add_undo_entry(tab, new_undo=new_undo) + + tab.private_api.shutdown() + self.widget.removeTab(idx) + + tab.deleteLater() + + def _add_undo_entry(self, tab, new_undo): if tab.url().isEmpty(): # There are some good reasons why a URL could be empty # (target="_blank" with a download, see [1]), so we silently ignore # this. # [1] https://github.com/qutebrowser/qutebrowser/issues/163 - pass - elif not tab.url().isValid(): + return + + if not tab.url().isValid(): # We display a warning for URLs which are not empty but invalid - # but we don't return here because we want the tab to close either # way. urlutils.invalid_url_error(tab.url(), "saving tab") - elif add_undo: - try: - history_data = tab.history.private_api.serialize() - except browsertab.WebTabError: - pass # special URL - else: - entry = _UndoEntry(url=tab.url(), - history=history_data, - index=idx, - pinned=tab.data.pinned) - if new_undo or not self.undo_stack: - self.undo_stack.append([entry]) - else: - self.undo_stack[-1].append(entry) + return - tab.private_api.shutdown() - self.widget.removeTab(idx) + idx = self.widget.indexOf(tab) + entry = self._undo_class.from_tab(tab, idx) + if not entry: + return - tab.deleteLater() + if isinstance(entry, self._undo_class): + if new_undo or not self.undo_stack: + self.undo_stack.append([entry]) + else: + self.undo_stack[-1].append(entry) + else: + assert len(entry) > 0 + entries = entry + if new_undo: + self.undo_stack.append(entries) + else: + self.undo_stack[-1].extend(entries) def undo(self, depth=1): """Undo removing of a tab or tabs.""" @@ -557,11 +613,14 @@ class TabbedBrowser(QWidget): assert newtab is not None use_current_tab = False else: - newtab = self.tabopen(background=False, idx=entry.index) + # FIXME:typing mypy thinks this is None due to @pyqtSlot + newtab = self.tabopen( + background=False, + related=False, + idx=entry.index + ) - newtab.history.private_api.deserialize(entry.history) - newtab.set_pinned(entry.pinned) - newtab.setFocus() + entry.restore_into_tab(newtab) @pyqtSlot('QUrl', bool) def load_url(self, url, newtab): @@ -575,7 +634,7 @@ class TabbedBrowser(QWidget): if newtab or self.widget.currentWidget() is None: self.tabopen(url, background=False) else: - self._current_tab().load_url(url) + self.current_tab().load_url(url) @pyqtSlot(int) def on_tab_close_requested(self, idx): @@ -605,6 +664,7 @@ class TabbedBrowser(QWidget): background: bool = None, related: bool = True, idx: int = None, + sibling: bool = False, # pylint: disable=unused-argument ) -> browsertab.AbstractTab: """Open a new tab with a given URL. @@ -649,7 +709,7 @@ class TabbedBrowser(QWidget): if idx is None: idx = self._get_new_tab_idx(related) - self.widget.insertTab(idx, tab, "") + idx = self.widget.insertTab(idx, tab, "") if url is not None: tab.load_url(url) @@ -660,7 +720,7 @@ class TabbedBrowser(QWidget): # Make sure the background tab has the correct initial size. # With a foreground tab, it's going to be resized correctly by the # layout anyways. - current_widget = self._current_tab() + current_widget = self.current_tab() tab.resize(current_widget.size()) self.widget.tab_index_changed.emit(self.widget.currentIndex(), self.widget.count()) @@ -1075,7 +1135,7 @@ class TabbedBrowser(QWidget): if key != "'": message.error("Failed to set mark: url invalid") return - point = self._current_tab().scroller.pos_px() + point = self.current_tab().scroller.pos_px() if key.isupper(): self._global_marks[key] = point, url @@ -1096,7 +1156,7 @@ class TabbedBrowser(QWidget): except qtutils.QtValueError: urlkey = None - tab = self._current_tab() + tab = self.current_tab() if key.isupper(): if key in self._global_marks: diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 42c31c97e..be1c696cf 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -13,7 +13,7 @@ from qutebrowser.qt.core import (pyqtSignal, pyqtSlot, Qt, QSize, QRect, QPoint, QTimer, QUrl) from qutebrowser.qt.widgets import (QTabWidget, QTabBar, QSizePolicy, QProxyStyle, QStyle, QStylePainter, QStyleOptionTab, - QCommonStyle) + QCommonStyle, QWidget) from qutebrowser.qt.gui import QIcon, QPalette, QColor from qutebrowser.utils import qtutils, objreg, utils, usertypes, log @@ -23,7 +23,6 @@ from qutebrowser.browser import browsertab class TabWidget(QTabWidget): - """The tab widget used for TabbedBrowser. Signals: @@ -42,18 +41,20 @@ class TabWidget(QTabWidget): def __init__(self, win_id, parent=None): super().__init__(parent) + self._tabbed_browser = parent bar = TabBar(win_id, self) self.setStyle(TabBarStyle()) self.setTabBar(bar) bar.tabCloseRequested.connect(self.tabCloseRequested) - bar.tabMoved.connect(functools.partial( - QTimer.singleShot, 0, self.update_tab_titles)) + bar.tabMoved.connect(self.update_tab_titles) bar.currentChanged.connect(self._on_current_changed) bar.new_tab_requested.connect(self._on_new_tab_requested) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.setDocumentMode(True) self.setUsesScrollButtons(True) bar.setDrawBase(False) + self._tab_title_update_disabled = False + self._init_config() config.instance.changed.connect(self._init_config) @@ -79,7 +80,7 @@ class TabWidget(QTabWidget): assert isinstance(bar, TabBar), bar return bar - def _tab_by_idx(self, idx: int) -> Optional[browsertab.AbstractTab]: + def _tab_by_idx(self, idx: int) -> Optional[QWidget]: """Get the tab at the given index.""" tab = self.widget(idx) if tab is not None: @@ -124,6 +125,12 @@ class TabWidget(QTabWidget): field: A field name which was updated. If given, the title is only set if the given field is in the template. """ + if self._tab_title_update_disabled: + return + + if self._tabbed_browser.is_shutting_down: + return + assert idx != -1 tab = self._tab_by_idx(idx) assert tab is not None @@ -176,6 +183,9 @@ class TabWidget(QTabWidget): fields['perc_raw'] = tab.progress() fields['backend'] = objects.backend.name fields['private'] = ' [Private Mode] ' if tab.is_private else '' + fields['tree'] = '' + fields['collapsed'] = '' + try: if tab.audio.is_muted(): fields['audio'] = TabWidget.MUTE_STRING @@ -238,8 +248,17 @@ class TabWidget(QTabWidget): bar.setVisible(True) bar.setUpdatesEnabled(True) + @contextlib.contextmanager + def _disable_tab_title_updates(self): + self._tab_title_update_disabled = True + yield + self._tab_title_update_disabled = False + def update_tab_titles(self): """Update all texts.""" + if self._tab_title_update_disabled: + return + with self._toggle_visibility(): for idx in range(self.count()): self.update_tab_title(idx) @@ -334,7 +353,7 @@ class TabWidget(QTabWidget): qtutils.ensure_valid(url) return url - def update_tab_favicon(self, tab: browsertab.AbstractTab) -> None: + def update_tab_favicon(self, tab: QWidget) -> None: """Update favicon of the given tab.""" idx = self.indexOf(tab) diff --git a/qutebrowser/mainwindow/treetabbedbrowser.py b/qutebrowser/mainwindow/treetabbedbrowser.py new file mode 100644 index 000000000..ddbe91c97 --- /dev/null +++ b/qutebrowser/mainwindow/treetabbedbrowser.py @@ -0,0 +1,353 @@ +# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Subclass of TabbedBrowser to provide tree-tab functionality.""" + +import collections +import dataclasses +from typing import List, Dict, Union +from qutebrowser.qt.core import pyqtSlot, QUrl + +from qutebrowser.config import config +from qutebrowser.mainwindow.tabbedbrowser import TabbedBrowser, _UndoEntry +from qutebrowser.mainwindow.treetabwidget import TreeTabWidget +from qutebrowser.browser import browsertab +from qutebrowser.misc import notree + + +@dataclasses.dataclass +class _TreeUndoEntry(_UndoEntry): + """Information needed for :undo.""" + + uid: int + parent_node_uid: int + children_node_uids: List[int] + local_index: int # index of the tab relative to its siblings + + def restore_into_tab(self, tab: browsertab.AbstractTab) -> None: + super().restore_into_tab(tab) + + root = tab.node.path[0] + uid = self.uid + parent_uid = self.parent_node_uid + parent_node = root.get_descendent_by_uid(parent_uid) + if not parent_node: + parent_node = root + + children = [] + for child_uid in self.children_node_uids: + child_node = root.get_descendent_by_uid(child_uid) + if child_node: + children.append(child_node) + tab.node.parent = None # Remove the node from the tree + tab.node = notree.Node(tab, parent_node, + children, uid) + + # correctly reposition the tab + local_idx = self.local_index + if tab.node.parent: # should always be true + new_siblings = list(tab.node.parent.children) + new_siblings.remove(tab.node) + new_siblings.insert(local_idx, tab.node) + tab.node.parent.children = new_siblings + + @classmethod + def from_tab( + cls, + tab: browsertab.AbstractTab, + idx: int, + recursing: bool = False, + ) -> Union["_TreeUndoEntry", List["_TreeUndoEntry"]]: + """Make a TreeUndoEntry from a Node.""" + node = tab.node + url = node.value.url() + try: + history_data = tab.history.private_api.serialize() + except browsertab.WebTabError: + return None # special URL + + if not recursing and node.collapsed: + entries = [ + cls.from_tab(descendent.value, idx+1, recursing=True) + for descendent in + node.traverse(notree.TraverseOrder.POST_R) + ] + entries = [entry for entry in entries if entry] + return entries + + pinned = node.value.data.pinned + uid = node.uid + parent_uid = node.parent.uid + if recursing: + # Recursively removed nodes will never have any existing children + # to re-parent in the tree they are being added into, children + # will always be added later as the undo stack is worked through. + # So remove child IDs here so we don't confuse undo() later. + children = [] + else: + children = [n.uid for n in node.children] + local_idx = node.index + return cls( + url=url, + history=history_data, + # The index argument is redundant given the parent and local index + # info, but required by the parent class. + index=idx, + pinned=pinned, + uid=uid, + parent_node_uid=parent_uid, + children_node_uids=children, + local_index=local_idx, + ) + + +class TreeTabbedBrowser(TabbedBrowser): + """Subclass of TabbedBrowser to provide tree-tab functionality. + + Extends TabbedBrowser methods (mostly tabopen, undo, and _remove_tab) so + that the internal tree is updated after every action. + + Provides methods to hide and show subtrees, and to cycle visibility. + """ + + is_treetabbedbrowser = True + _undo_class = _TreeUndoEntry + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tree_tab_child_rel_idx = 0 + self._tree_tab_sibling_rel_idx = 0 + self._tree_tab_toplevel_rel_idx = 0 + + def _create_tab_widget(self): + """Return the tab widget that can display a tree structure.""" + return TreeTabWidget(self._win_id, parent=self) + + def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False): + """Handle children positioning after a tab is removed.""" + if not tab.url().isEmpty() and tab.url().isValid() and add_undo: + self._add_undo_entry(tab, new_undo) + + node = tab.node + parent = node.parent + + if node.collapsed: + # Collapsed nodes have already been removed from the TabWidget so + # we can't ask super() to dispose of them and need to do it + # ourselves. + for descendent in node.traverse( + order=notree.TraverseOrder.POST_R, + render_collapsed=True + ): + descendent.parent = None + descendent_tab = descendent.value + descendent_tab.private_api.shutdown() + descendent_tab.deleteLater() + elif parent: + siblings = list(parent.children) + children = node.children + + if children: + # Promote first child, + # make that promoted node the parent of our other children + # give the promoted node our position in our siblings list. + next_node = children[0] + + for n in children[1:]: + n.parent = next_node + + # swap nodes + node_idx = siblings.index(node) + siblings[node_idx] = next_node + + parent.children = tuple(siblings) + assert not node.children + + node.parent = None + + super()._remove_tab(tab, add_undo=False, new_undo=False, + crashed=crashed) + + self.widget.tree_tab_update() + + def undo(self, depth=1): + """Undo removing of a tab or tabs.""" + super().undo(depth) + self.widget.tree_tab_update() + + def tabs( + self, + include_hidden: bool = False, + ) -> List[browsertab.AbstractTab]: + """Get a list of tabs in this browser. + + Args: + include_hidden: Include child tabs which are not currently in the + tab bar. + """ + return [ + node.value + for node + in self.widget.tree_root.traverse( + render_collapsed=include_hidden, + ) + if node.value + ] + + @pyqtSlot('QUrl') + @pyqtSlot('QUrl', bool) + @pyqtSlot('QUrl', bool, bool) + def tabopen( + self, url: QUrl = None, + background: bool = None, + related: bool = True, + idx: int = None, + sibling: bool = False, + ) -> browsertab.AbstractTab: + """Open a new tab with a given url. + + Args: + related: Whether to set the tab as a child of the currently focused + tab. Follows `tabs.new_position.tree.related`. + sibling: Whether to set the tab as a sibling of the currently + focused tab. Follows `tabs.new_position.tree.sibling`. + + """ + # Save the current tab now before letting super create the new tab + # (and possibly give it focus). To insert the new tab correctly in the + # tree structure later we may need to know which tab it was opened + # from (for the `related` and `sibling` cases). + cur_tab = self.widget.currentWidget() + tab = super().tabopen(url, background, related, idx) + + # Some trivial cases where we don't need to do positioning: + + # 1. this is the first tab in the window. + if cur_tab is None: + assert self.widget.count() == 1 + assert tab.node.parent == self.widget.tree_root + return tab + + if ( + config.val.tabs.tabs_are_windows or # 2. one tab per window + tab is cur_tab # 3. opening URL in existing tab + ): + return tab + + # Some sanity checking to make sure the tab super created was set up + # as a tree style tab correctly. We don't have a TreeTab so this is + # heuristic to highlight any problems elsewhere in the application + # logic. + assert tab.node.parent, ( + f"Node for new tab doesn't have a parent: {tab.node}" + ) + + # We may also be able to skip the positioning code below if the `idx` + # arg is passed in. Semgrep says that arg is used from undo() and + # SessionManager, both cases are updating the tree structure + # themselves after opening the new tab. On the other hand the only + # downside is we move the tab and update the tree twice. Although that + # may actually make loading large sessions a bit slower. + + if related: + pos = config.val.tabs.new_position.tree.new_child + parent = cur_tab.node + # pos can only be first, last + elif sibling: + pos = config.val.tabs.new_position.tree.new_sibling + parent = cur_tab.node.parent + # pos can be first, last, prev, next + else: + pos = config.val.tabs.new_position.tree.new_toplevel + parent = self.widget.tree_root + + self._position_tab(cur_tab.node, tab.node, pos, parent, sibling, related, background) + + return tab + + def _position_tab( + self, + cur_node: notree.Node, + new_node: notree.Node, + pos: str, + parent: notree.Node, + sibling: bool = False, + related: bool = True, + background: bool = None, + ) -> None: + toplevel = not sibling and not related + siblings = list(parent.children) + if new_node.parent == parent: + # Remove the current node from its parent's children list to avoid + # potentially adding it as a duplicate later. + siblings.remove(new_node) + + if pos == 'first': + rel_idx = 0 + if config.val.tabs.new_position.stacking and related: + rel_idx += self._tree_tab_child_rel_idx + self._tree_tab_child_rel_idx += 1 + siblings.insert(rel_idx, new_node) + elif pos in ['prev', 'next'] and (sibling or toplevel): + # Pivot is the tab relative to which 'prev' or 'next' apply to. + # Either the current node or top of the current tree. + pivot = cur_node if sibling else cur_node.path[1] + direction = -1 if pos == 'prev' else 1 + rel_idx = 0 if pos == 'prev' else 1 + tgt_idx = siblings.index(pivot) + rel_idx + if config.val.tabs.new_position.stacking: + if sibling: + tgt_idx += self._tree_tab_sibling_rel_idx + self._tree_tab_sibling_rel_idx += direction + elif toplevel: + tgt_idx += self._tree_tab_toplevel_rel_idx + self._tree_tab_toplevel_rel_idx += direction + siblings.insert(tgt_idx, new_node) + else: # position == 'last' + siblings.append(new_node) + + parent.children = siblings + self.widget.tree_tab_update() + if not background: + self._reset_stack_counters() + + def _reset_stack_counters(self): + self._tree_tab_child_rel_idx = 0 + self._tree_tab_sibling_rel_idx = 0 + self._tree_tab_toplevel_rel_idx = 0 + + @pyqtSlot(int) + def _on_current_changed(self, idx): + super()._on_current_changed(idx) + self._reset_stack_counters() + + def cycle_hide_tab(self, node): + """Utility function for tree_tab_cycle_hide command.""" + # height = node.height # height is always rel_height + if node.collapsed: + node.collapsed = False + for descendent in node.traverse(render_collapsed=True): + descendent.collapsed = False + return + + def rel_depth(n): + return n.depth - node.depth + + levels: Dict[int, list] = collections.defaultdict(list) + for d in node.traverse(render_collapsed=False): + r_depth = rel_depth(d) + levels[r_depth].append(d) + + # Remove highest level because it's leaves (or already collapsed) + del levels[max(levels.keys())] + + target = 0 + for level in sorted(levels, reverse=True): + nodes = levels[level] + if not all(n.collapsed or not n.children for n in nodes): + target = level + break + for n in levels[target]: + if not n.collapsed and n.children: + n.collapsed = True diff --git a/qutebrowser/mainwindow/treetabwidget.py b/qutebrowser/mainwindow/treetabwidget.py new file mode 100644 index 000000000..8e8b6f29e --- /dev/null +++ b/qutebrowser/mainwindow/treetabwidget.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Extension of TabWidget for tree-tab functionality.""" + +from qutebrowser.mainwindow.tabwidget import TabWidget +from qutebrowser.misc.notree import Node +from qutebrowser.utils import log + + +class TreeTabWidget(TabWidget): + """Tab widget used in TabbedBrowser, with tree-functionality. + + Handles correct rendering of the tree as a tab field, and correct + positioning of tabs according to tree structure. + """ + + def __init__(self, win_id, parent=None): + # root of the tab tree, common for all tabs in the window + self.tree_root = Node(None) + super().__init__(win_id, parent) + + def get_tab_fields(self, idx): + """Add tree field data to normal tab field data.""" + fields = super().get_tab_fields(idx) + + if len(self.tree_root.children) == 0: + # Presumably the window is still being initialized + log.misc.vdebug(f"Tree root has no children. Are we starting up? fields={fields}") + return fields + + rendered_tree = self.tree_root.render() + tab = self.widget(idx) + found = [ + prefix + for prefix, node in rendered_tree + if node.value == tab + ] + + if len(found) == 1: + # we remove the first two chars because every tab is child of tree + # root and that gets rendered as well + fields['tree'] = found[0][2:] + fields['collapsed'] = '[...] ' if tab.node.collapsed else '' + return fields + + # Beyond here we have a mismatch between the tab widget and the tree. + # Try to identify known situations where this happens precisely and + # handle them gracefully. Blow up on unknown situations so we don't + # miss them. + + # Just sanity checking, we haven't seen this yet. + assert len(found) == 0, ( + "Found multiple tree nodes with the same tab as value: tab={tab}" + ) + + # Having more tabs in the widget when loading a session with a + # collapsed group in is a known case. Check for it with a heuristic + # (for now) and assert if that doesn't look like that's how we got + # here. + all_nodes = self.tree_root.traverse() + node = [n for n in all_nodes if n.value == tab][0] + is_hidden = any(n.collapsed for n in node.path) + + tabs = [str(self.widget(idx)) for idx in range(self.count())] + difference = len(rendered_tree) - 1 - len(tabs) + # empty_urls here is a proxy for "there is a session being loaded into + # this window" + empty_urls = all( + not self.widget(idx).url().toString() for idx in range(self.count()) + ) + if empty_urls and is_hidden: + # All tabs will be added to the tab widget during session load + # and they will only be removed later when the widget is + # updated from the tree. Meanwhile, if we get here we'll have + # hidden tabs present in the widget but absent from the node. + # To detect this situation more clearly we could do something like + # have a is_starting_up or is_loading_session attribute on the + # tabwidget/tabbbedbrowser. Or have the session manager add all + # nodes to the tree uncollapsed initially and then go through and + # collapse them. + log.misc.vdebug( + "get_tab_fields() called with different amount of tabs in " + f"widget vs in the tree: difference={difference} " + f"tree={rendered_tree[1:]} tabs={tabs}" + ) + else: + # If we get here then we have another case to investigate. + assert difference == 0, ( + "Different amount of nodes in tree than widget. " + f"difference={difference} tree={rendered_tree[1:]} tabs={tabs}" + ) + + return fields + + def update_tree_tab_positions(self): + """Update tab positions according to the tree structure.""" + nodes = self.tree_root.traverse(render_collapsed=False) + for idx, node in enumerate(nodes): + if idx > 0: + cur_idx = self.indexOf(node.value) + self.tabBar().moveTab(cur_idx, idx-1) + + def update_tree_tab_visibility(self): + """Hide collapsed tabs and show uncollapsed ones. + + Sync the internal tree to the tabs the user can actually see. + """ + for node in self.tree_root.traverse(): + if node.value is None: + continue + + should_be_hidden = any(ancestor.collapsed for ancestor in node.path[:-1]) + is_shown = self.indexOf(node.value) != -1 + if should_be_hidden and is_shown: + # node should be hidden but is shown + cur_tab = node.value + idx = self.indexOf(cur_tab) + if idx != -1: + self.removeTab(idx) + elif not should_be_hidden and not is_shown: + # node should be shown but is hidden + parent = node.parent + tab = node.value + name = tab.title() + icon = tab.icon() + if node.parent is not None: + parent_idx = self.indexOf(node.parent.value) + self.insertTab(parent_idx + 1, tab, icon, name) + tab.node.parent = parent # insertTab resets node + + def tree_tab_update(self): + """Update titles and positions.""" + with self._disable_tab_title_updates(): + self.update_tree_tab_visibility() + self.update_tree_tab_positions() + self.update_tab_titles() diff --git a/qutebrowser/misc/notree.py b/qutebrowser/misc/notree.py new file mode 100644 index 000000000..6388a3e77 --- /dev/null +++ b/qutebrowser/misc/notree.py @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: Giuseppe Stelluto (pinusc) <giuseppe@gstelluto.com> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tree library for tree-tabs. + +The fundamental unit is the Node class. + +Create a tree with with Node(value, parent): +root = Node('foo') +child = Node('bar', root) +child2 = Node('baz', root) +child3 = Node('lorem', child) + +You can also assign parent after instantiation, or even reassign it: +child4 = Node('ipsum') +child4.parent = root + +Assign children: +child.children = [] +child2.children = [child4, child3] +child3.parent +> Node('foo/bar/baz') + +Render a tree with render_tree(root_node): +render_tree(root) + +> ('', 'foo') +> ('├─', 'bar') +> ('│ ├─', 'lorem') +> ('│ └─', 'ipsum') +> ('└─', 'baz') +""" +import enum +from typing import Optional, TypeVar, Sequence, List, Tuple, Iterable, Generic +import itertools + +# For Node.render +CORNER = '└─' +INTERSECTION = '├─' +PIPE = '│' + + +class TreeError(RuntimeError): + """Exception used for tree-related errors.""" + + +class TraverseOrder(enum.Enum): + """Tree traversal order for Node.traverse(). + + All traversals are depth first. + See https://en.wikipedia.org/wiki/Depth-first_search#Vertex_orderings + + Attributes: + PRE: pre-order: parent then children, leftmost nodes first. Same as in Node.render(). + POST: post-order: children then parent, leftmost nodes first, then parent. + POST_R: post-order-reverse: like POST but rightmost nodes first. + """ + + PRE = 'pre-order' # pylint: disable=invalid-name + POST = 'post-order' # pylint: disable=invalid-name + POST_R = 'post-order-reverse' # pylint: disable=invalid-name + + +uid_gen = itertools.count(0) + +# generic type of value held by Node +T = TypeVar('T') + + +class Node(Generic[T]): + """Fundamental unit of notree library. + + Attributes: + value: The element (ususally a tab) the node represents + parent: Node's parent. + children: Node's children elements. + siblings: Children of parent node that are not self. + path: List of nodes from root of tree to self value, parent, and + children can all be set by user. Everything else will be updated + accordingly, so that if `node.parent = root_node`, then `node in + root_node.children` will be True. + """ + + sep: str = '/' + __parent: Optional['Node[T]'] = None + # this is a global 'static' class attribute + + def __init__(self, + value: T, + parent: Optional['Node[T]'] = None, + childs: Sequence['Node[T]'] = (), + uid: Optional[int] = None) -> None: + if uid is not None: + self.__uid = uid + else: + self.__uid = next(uid_gen) + + self.value = value + # set initial values so there's no need for AttributeError checks + self.__parent: Optional['Node[T]'] = None + self.__children: List['Node[T]'] = [] + + # For render memoization + self.__modified = False + self.__set_modified() # not the same as line above + self.__rendered: Optional[List[Tuple[str, 'Node[T]']]] = None + + if parent: + self.parent = parent # calls setter + if childs: + self.children = childs # this too + + self.__collapsed = False + + @property + def uid(self) -> int: + return self.__uid + + @property + def parent(self) -> Optional['Node[T]']: + return self.__parent + + @parent.setter + def parent(self, value: 'Node[T]') -> None: + """Set parent property. Also adds self to value.children.""" + # pylint: disable=protected-access + assert (value is None or isinstance(value, Node)) + if self.__parent: + self.__parent.__disown(self) + self.__parent = None + if value is not None: + value.__add_child(self) + self.__parent = value + self.__set_modified() + + @property + def children(self) -> Sequence['Node[T]']: + return tuple(self.__children) + + @children.setter + def children(self, value: Sequence['Node[T]']) -> None: + """Set children property, preserving order. + + Also sets n.parent = self for n in value. Does not allow duplicates. + """ + seen = set(value) + if len(seen) != len(value): + raise TreeError("A duplicate item is present in %r" % value) + new_children = list(value) + for child in new_children: + if child.parent is not self: + child.parent = self + self.__children = new_children + self.__set_modified() + + @property + def path(self) -> List['Node[T]']: + """Get a list of all nodes from the root node to self.""" + if self.parent is None: + return [self] + else: + return self.parent.path + [self] + + @property + def depth(self) -> int: + """Get the number of nodes between self and the root node.""" + return len(self.path) - 1 + + @property + def index(self) -> int: + """Get self's position among its siblings (self.parent.children).""" + if self.parent is not None: + return self.parent.children.index(self) + else: + raise TreeError('Node has no parent.') + + @property + def collapsed(self) -> bool: + return self.__collapsed + + @collapsed.setter + def collapsed(self, val: bool) -> None: + self.__collapsed = val + self.__set_modified() + + def __set_modified(self) -> None: + """If self is modified, every ancestor is modified as well.""" + for node in self.path: + node.__modified = True # pylint: disable=protected-access,unused-private-member + + def render(self) -> List[Tuple[str, 'Node[T]']]: + """Render a tree with ascii symbols. + + Tabs appear in the same order as in traverse() with TraverseOrder.PRE + Args: + node; the root of the tree to render + + Return: list of tuples where the first item is the symbol, + and the second is the node it refers to + """ + if not self.__modified and self.__rendered is not None: + return self.__rendered + + result = [('', self)] + for child in self.children: + if child.children: + subtree = child.render() + if child is not self.children[-1]: + subtree = [(PIPE + ' ' + c, n) for c, n in subtree] + char = INTERSECTION + else: + subtree = [(' ' + c, n) for c, n in subtree] + char = CORNER + subtree[0] = (char, subtree[0][1]) + if child.collapsed: + result += [subtree[0]] + else: + result += subtree + elif child is self.children[-1]: + result.append((CORNER, child)) + else: + result.append((INTERSECTION, child)) + self.__modified = False + self.__rendered = list(result) + return list(result) + + def traverse(self, order: TraverseOrder = TraverseOrder.PRE, + render_collapsed: bool = True) -> Iterable['Node']: + """Generator for all descendants of `self`. + + Args: + order: a TraverseOrder object. See TraverseOrder documentation. + render_collapsed: whether to yield children of collapsed nodes + Even if render_collapsed is False, collapsed nodes are be rendered. + It's their children that won't. + """ + if order == TraverseOrder.PRE: + yield self + + if self.collapsed and not render_collapsed: + if order != TraverseOrder.PRE: + yield self + return + + f = reversed if order is TraverseOrder.POST_R else lambda x: x + for child in f(self.children): + if render_collapsed or not child.collapsed: + yield from child.traverse(order, render_collapsed) + else: + yield child + if order in [TraverseOrder.POST, TraverseOrder.POST_R]: + yield self + + def __add_child( # pylint: disable=unused-private-member + self, + node: 'Node[T]', + ) -> None: + if node not in self.__children: + self.__children.append(node) + + def __disown( # pylint: disable=unused-private-member + self, + value: 'Node[T]', + ) -> None: + self.__set_modified() + if value in self.__children: + self.__children.remove(value) + + def get_descendent_by_uid(self, uid: int) -> Optional['Node[T]']: + """Return descendent identified by the provided uid. + + Returns None if there is no such descendent. + + Args: + uid: The uid of the node to return + """ + for descendent in self.traverse(): + if descendent.uid == uid: + return descendent + return None + + def promote(self, times: int = 1, to: str = 'first') -> None: + """Makes self a child of its grandparent, i.e. sibling of its parent. + + Args: + times: How many levels to promote the tab to. to: One of 'next', + 'prev', 'first', 'last'. Determines the position among siblings + after being promoted. 'next' and 'prev' are relative to the current + parent. + + """ + if to not in ['first', 'last', 'next', 'prev']: + raise ValueError("Invalid value supplied for 'to': " + to) + position = {'first': 0, 'last': -1}.get(to, None) + diff = {'next': 1, 'prev': 0}.get(to, 1) + count = times + while count > 0: + if self.parent is None or self.parent.parent is None: + raise TreeError("Tab has no parent!") + grandparent = self.parent.parent + if position is not None: + idx = position + else: # diff is necessarily not none + idx = self.parent.index + diff + self.parent = None + + siblings = list(grandparent.children) + if idx != -1: + siblings.insert(idx, self) + else: + siblings.append(self) + grandparent.children = siblings + count -= 1 + + def demote(self, to: str = 'last') -> None: + """Demote a tab making it a child of its previous adjacent sibling.""" + if self.parent is None or self.parent.children is None: + raise TreeError("Tab has no siblings!") + siblings = list(self.parent.children) + + # we want previous node in the same subtree as current node + rel_idx = siblings.index(self) - 1 + + if rel_idx >= 0: + parent = siblings[rel_idx] + new_siblings = list(parent.children) + position = {'first': 0, 'last': -1}.get(to, -1) + if position == 0: + new_siblings.insert(0, self) + else: + new_siblings.append(self) + parent.children = new_siblings + else: + raise TreeError("Tab has no previous sibling!") + + def __repr__(self) -> str: + try: + value = str(self.value.url().url()) # type: ignore + except Exception: + value = str(self.value) + return "<Node -%d- '%s'>" % (self.__uid, value) + + def __str__(self) -> str: + # return "<Node '%s'>" % self.value + return str(self.value) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index dd63904cd..35c36f203 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -121,6 +121,42 @@ class TabHistoryItem: last_visited=self.last_visited) +def reconstruct_tree_data(window_data): + """Return a dict usable as a tree from a window. + + Returns a dict like: + { + 1: {'children': [2]}, + 2: { + ...tab, + "treetab_node_data": { + "children": [], + "collapsed": False, + "parent": 1, + "uid": 2, + } + } + } + + Which you can traverse by starting at the node with no "treetab_node_data" + attribute (the root) and pulling successive levels of children from the + dict using their `uid`s as keys. + + The ...tab part represents the usual attributes for a tab when saved in a + session. + """ + tree_data = {} + root = window_data['treetab_root'] + tree_data[root['uid']] = { + 'children': root['children'], + 'tab': {}, + 'collapsed': False + } + for tab in window_data['tabs']: + tree_data[tab['treetab_node_data']['uid']] = tab + return tree_data + + class SessionManager(QObject): """Manager for sessions. @@ -281,13 +317,31 @@ class SessionManager(QObject): if getattr(active_window, 'win_id', None) == win_id: win_data['active'] = True win_data['geometry'] = bytes(main_window.saveGeometry()) - win_data['tabs'] = [] if tabbed_browser.is_private: win_data['private'] = True - for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.widget.currentIndex() - win_data['tabs'].append(self._save_tab(tab, active, - with_history=with_history)) + + win_data['tabs'] = [] + for tab in tabbed_browser.tabs(include_hidden=True): + active = tab == tabbed_browser.current_tab() + tab_data = self._save_tab(tab, + active, + with_history=with_history) + if tabbed_browser.is_treetabbedbrowser: + node = tab.node + node_data = { + 'parent': node.parent.uid, + 'children': [c.uid for c in node.children], + 'collapsed': node.collapsed, + 'uid': node.uid + } + tab_data['treetab_node_data'] = node_data + win_data['tabs'].append(tab_data) + if tabbed_browser.is_treetabbedbrowser: + root = tabbed_browser.widget.tree_root + win_data['treetab_root'] = { + 'children': [c.uid for c in root.children], + 'uid': root.uid + } data['windows'].append(win_data) return data @@ -455,6 +509,86 @@ class SessionManager(QObject): except ValueError as e: raise SessionError(e) + def _load_tree(self, tabbed_browser, tree_data, legacy=False): + tree_keys = list(tree_data.keys()) + if not tree_keys: + return None + + root_data = tree_data.get(tree_keys[0]) + if root_data is None: + return None + + root_node = tabbed_browser.widget.tree_root + tab_to_focus = None + index = -1 + + def recursive_load_node(uid): + nonlocal tab_to_focus + nonlocal index + index += 1 + if legacy: + node_data = tree_data[uid] + tab_data = node_data['tab'] + else: + tab_data = tree_data[uid] + node_data = tab_data['treetab_node_data'] + children_uids = node_data['children'] + + if tab_data.get('active'): + tab_to_focus = index + + new_tab = tabbed_browser.tabopen( + background=False, + related=False, + idx=index, + ) + self._load_tab(new_tab, tab_data) + + new_tab.node.parent = root_node + children = [recursive_load_node(uid) for uid in children_uids] + new_tab.node.children = children + new_tab.node.collapsed = node_data['collapsed'] + return new_tab.node + + for child_uid in root_data['children']: + child = recursive_load_node(child_uid) + child.parent = root_node + + # Make sure any collapsed tabs are removed from the widget. + # Since we only set the "collapsed" attribute after loading the tab, + # and the tree only gets updated in the above loop on tab loads. So if + # the last set of tabs we load is a collapsed group this children + # won't know they are support to be hidden yet. + tabbed_browser.widget.tree_tab_update() + + return tab_to_focus + + def _load_legacy_tree_tabs(self, win, tabbed_browser): + """Load the "legacy" tree session format. + + For a number of years (pull #4602) tree tabs used a session format + that wasn't backwards compatible with prior session formats. In that + tab data was a child of a tree node. Now it's been switched to the + other way around. This method (along with a conditional in + `_load_tree()`) handle loading the old format for early adopters who + have session they don't want to have to rebuild. + + Returns: + a. `(None, None)` if tree tabs where loaded, or + b. or a tuple of the tab index to focus and a flat list of tabs that + need loading if tree tabs is turned off. + """ + plain_tabs = None + tab_to_focus = None + tree_data = win.get('tree') + if tabbed_browser.is_treetabbedbrowser: + tab_to_focus = self._load_tree(tabbed_browser, tree_data, legacy=True) + tabbed_browser.widget.tree_tab_update() + else: + plain_tabs = [tree_data[i]['tab'] for i in tree_data if + tree_data[i]['tab']] + return tab_to_focus, plain_tabs + def _load_window(self, win): """Turn yaml data into windows.""" window = mainwindow.MainWindow(geometry=win['geometry'], @@ -462,13 +596,35 @@ class SessionManager(QObject): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) tab_to_focus = None - for i, tab in enumerate(win['tabs']): - new_tab = tabbed_browser.tabopen(background=False) - self._load_tab(new_tab, tab) - if tab.get('active', False): - tab_to_focus = i - if new_tab.data.pinned: - new_tab.set_pinned(True) + + legacy_tree_loaded = False + if win.get('tree'): + tab_to_focus, tabs = self._load_legacy_tree_tabs(win, tabbed_browser) + legacy_tree_loaded = not tabs + else: + tabs = win['tabs'] + + # restore a tab tree only if the session contains treetab + # data and tree tabs are enabled. + # Otherwise, restore tabs "flat" + load_tree_tabs = 'treetab_root' in win.keys() and \ + tabbed_browser.is_treetabbedbrowser + if load_tree_tabs: + tree_data = reconstruct_tree_data(win) + tab_to_focus = self._load_tree(tabbed_browser, tree_data) + elif not legacy_tree_loaded: + for i, tab in enumerate(tabs): + new_tab = tabbed_browser.tabopen( + background=False, + related=False, + idx=i, + ) + self._load_tab(new_tab, tab) + if tab.get('active', False): + tab_to_focus = i + if new_tab.data.pinned: + new_tab.set_pinned(True) + if tab_to_focus is not None: tabbed_browser.widget.setCurrentIndex(tab_to_focus) diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py index 1d0249d9a..836d02d87 100755 --- a/scripts/asciidoc2html.py +++ b/scripts/asciidoc2html.py @@ -98,11 +98,11 @@ class AsciiDoc: """Copy image files to qutebrowser/html/doc.""" print("Copying files...") dst_path = DOC_DIR / 'img' - dst_path.mkdir(exist_ok=True) - for filename in ['cheatsheet-big.png', 'cheatsheet-small.png']: - src = REPO_ROOT / 'doc' / 'img' / filename - dst = dst_path / filename - shutil.copy(src, dst) + try: + shutil.rmtree(dst_path) + except FileNotFoundError: + pass + shutil.copytree(REPO_ROOT / 'doc' / 'img', dst_path) def _build_website_file(self, root: pathlib.Path, filename: str) -> None: """Build a single website file.""" diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 082b999b1..ce31125b6 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -20,6 +20,7 @@ import pytest import pytest_bdd as bdd import qutebrowser +from qutebrowser.misc import sessions from qutebrowser.utils import log, utils, docutils, version from qutebrowser.browser import pdfjs from end2end.fixtures import testprocess @@ -125,14 +126,14 @@ def set_setting_given(quteproc, server, opt, value): @bdd.given(bdd.parsers.parse("I open {path}")) -def open_path_given(quteproc, path): +def open_path_given(quteproc, server, path): """Open a URL. This is available as "Given:" step so it can be used as "Background:". It always opens a new tab, unlike "When I open ..." """ - quteproc.open_path(path, new_tab=True) + open_path(quteproc, server, path, default_kwargs={"new_tab": True}) @bdd.given(bdd.parsers.parse("I run {command}")) @@ -188,7 +189,7 @@ def clear_log_lines(quteproc): @bdd.when(bdd.parsers.parse("I open {path}")) -def open_path(quteproc, server, path): +def open_path(quteproc, server, path, default_kwargs: dict = None): """Open a URL. - If used like "When I open ... in a new tab", the URL is opened in a new @@ -200,45 +201,41 @@ def open_path(quteproc, server, path): path = path.replace('(port)', str(server.port)) path = testutils.substitute_testdata(path) - new_tab = False - new_bg_tab = False - new_window = False - private = False - as_url = False - wait = True + suffixes = { + "in a new tab": "new_tab", + "in a new related tab": ("new_tab", "related_tab"), + "in a new sibling tab": ("new_tab", "sibling_tab"), + "in a new related background tab": ("new_bg_tab", "related_tab"), + "in a new background tab": "new_bg_tab", + "in a new window": "new_window", + "in a private window": "private", + "without waiting": {"wait": False}, + "as a URL": "as_url", + } - new_tab_suffix = ' in a new tab' - new_bg_tab_suffix = ' in a new background tab' - new_window_suffix = ' in a new window' - private_suffix = ' in a private window' - do_not_wait_suffix = ' without waiting' - as_url_suffix = ' as a URL' + def update_from_value(value, kwargs): + if isinstance(value, str): + kwargs[value] = True + elif isinstance(value, (tuple, list)): + for i in value: + update_from_value(i, kwargs) + elif isinstance(value, dict): + kwargs.update(value) + kwargs = {} while True: - if path.endswith(new_tab_suffix): - path = path[:-len(new_tab_suffix)] - new_tab = True - elif path.endswith(new_bg_tab_suffix): - path = path[:-len(new_bg_tab_suffix)] - new_bg_tab = True - elif path.endswith(new_window_suffix): - path = path[:-len(new_window_suffix)] - new_window = True - elif path.endswith(private_suffix): - path = path[:-len(private_suffix)] - private = True - elif path.endswith(as_url_suffix): - path = path[:-len(as_url_suffix)] - as_url = True - elif path.endswith(do_not_wait_suffix): - path = path[:-len(do_not_wait_suffix)] - wait = False + for suffix, value in suffixes.items(): + if path.endswith(suffix): + path = path[:-len(suffix) - 1] + update_from_value(value, kwargs) + break else: break - quteproc.open_path(path, new_tab=new_tab, new_bg_tab=new_bg_tab, - new_window=new_window, private=private, as_url=as_url, - wait=wait) + if not kwargs and default_kwargs: + kwargs.update(default_kwargs) + + quteproc.open_path(path, **kwargs) @bdd.when(bdd.parsers.parse("I set {opt} to {value}")) @@ -609,55 +606,104 @@ def check_contents_json(quteproc, text): assert actual == expected -@bdd.then(bdd.parsers.parse("the following tabs should be open:\n{tabs}")) -def check_open_tabs(quteproc, request, tabs): - """Check the list of open tabs in the session. +@bdd.then(bdd.parsers.parse("the following tabs should be open:\n{expected_tabs}")) +def check_open_tabs(quteproc, request, expected_tabs): + """Check the list of open tabs in a one window session. This is a lightweight alternative for "The session should look like: ...". - It expects a list of URLs, with an optional "(active)" suffix. + It expects a tree of URLs in the form: + - data/numbers/1.txt + - data/numbers/2.txt (active) + + Where the indentation is optional (but if present the indent should be two + spaces) and the suffix can be one or more of: + + (active) + (pinned) + (collapsed) """ session = quteproc.get_session() + expected_tabs = expected_tabs.splitlines() + assert len(session['windows']) == 1 + window = session['windows'][0] + assert len(window['tabs']) == len(expected_tabs) + active_suffix = ' (active)' pinned_suffix = ' (pinned)' - tabs = tabs.splitlines() - assert len(session['windows']) == 1 - assert len(session['windows'][0]['tabs']) == len(tabs) - - # If we don't have (active) anywhere, don't check it - has_active = any(active_suffix in line for line in tabs) - has_pinned = any(pinned_suffix in line for line in tabs) - - for i, line in enumerate(tabs): - line = line.strip() - assert line.startswith('- ') - line = line[2:] # remove "- " prefix - - active = False - pinned = False - - while line.endswith(active_suffix) or line.endswith(pinned_suffix): - if line.endswith(active_suffix): - # active - line = line[:-len(active_suffix)] - active = True - else: - # pinned - line = line[:-len(pinned_suffix)] - pinned = True - - session_tab = session['windows'][0]['tabs'][i] - current_page = session_tab['history'][-1] - assert current_page['url'] == quteproc.path_to_url(line) - if active: - assert session_tab['active'] - elif has_active: - assert 'active' not in session_tab - - if pinned: - assert current_page['pinned'] - elif has_pinned: - assert not current_page['pinned'] + collapsed_suffix = ' (collapsed)' + # Don't check for states in the session if they aren't in the expected + # text. + has_active = any(active_suffix in line for line in expected_tabs) + has_pinned = any(pinned_suffix in line for line in expected_tabs) + has_collapsed = any(collapsed_suffix in line for line in expected_tabs) + + def tab_to_str(tab, prefix="", collapsed=False): + """Convert a tab from a session file into a one line string.""" + current = [ + entry + for entry in tab["history"] + if entry.get("active") + ][0] + text = f"{prefix}- {current['url']}" + for suffix, state in { + active_suffix: tab.get("active") and has_active, + collapsed_suffix: collapsed and has_collapsed, + pinned_suffix: current["pinned"] and has_pinned, + }.items(): + if state: + text += suffix + return text + + def tree_to_str(node, tree_data, indentation=-1): + """Traverse a tree turning each node into an indented string.""" + tree_node = node.get("treetab_node_data") + if tree_node: # root node doesn't have treetab_node_data + yield tab_to_str( + node, + prefix=" " * indentation, + collapsed=tree_node["collapsed"], + ) + else: + tree_node = node + + for uid in tree_node["children"]: + yield from tree_to_str(tree_data[uid], tree_data, indentation + 1) + + is_tree_tab_window = "treetab_root" in window + if is_tree_tab_window: + tree_data = sessions.reconstruct_tree_data(window) + root = [node for node in tree_data.values() if "treetab_node_data" not in node][0] + actual = list(tree_to_str(root, tree_data)) + else: + actual = [tab_to_str(tab) for tab in window["tabs"]] + + def normalize(line): + """Normalize expected lines to match session lines. + + Turn paths into URLs and sort suffixes. + """ + prefix, rest = line.split("- ", maxsplit=1) + path = rest.split(" ", maxsplit=1) + path[0] = quteproc.path_to_url(path[0]) + if len(path) == 2: + suffixes = path[1].split() + for s in suffixes: + assert s[0] == "(" + assert s[-1] == ")" + path[1] = " ".join(sorted(suffixes)) + return "- ".join((prefix, " ".join(path))) + + expected_tabs = [ + normalize(line) + for line in expected_tabs + ] + # Removed the hyphens from the start of lines so they don't get mixed in + # with the diff markers. + expected_tabs = [line.replace("- ", "") for line in expected_tabs] + actual = [line.replace("- ", "") for line in actual] + for idx, expected in enumerate(expected_tabs): + assert expected == actual[idx] @bdd.then(bdd.parsers.re(r'the (?P<what>primary selection|clipboard) should ' diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature index 9a61baf61..2f121132f 100644 --- a/tests/end2end/features/sessions.feature +++ b/tests/end2end/features/sessions.feature @@ -440,3 +440,18 @@ Feature: Saving and loading sessions - data/numbers/2.txt (active) (pinned) - data/numbers/4.txt - data/numbers/3.txt + + # Make sure the new_position.related setting doesn't change the tab order + # when loading from a session. + Scenario: Loading a session with tabs.new_position.related=prev + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :session-save foo + And I set tabs.new_position.related to prev + And I run :session-load -c foo + And I wait until data/numbers/3.txt is loaded + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt + - data/numbers/3.txt (active) diff --git a/tests/end2end/features/test_treetabs_bdd.py b/tests/end2end/features/test_treetabs_bdd.py new file mode 100644 index 000000000..9cbb315d7 --- /dev/null +++ b/tests/end2end/features/test_treetabs_bdd.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import pytest_bdd as bdd +bdd.scenarios("treetabs.feature") diff --git a/tests/end2end/features/treetabs.feature b/tests/end2end/features/treetabs.feature new file mode 100644 index 000000000..bf99e0f66 --- /dev/null +++ b/tests/end2end/features/treetabs.feature @@ -0,0 +1,208 @@ +Feature: Tree tab management + Tests for various :tree-tab-* commands. + + Background: + # Open a new tree tab enabled window, close everything else + Given I set tabs.tabs_are_windows to false + And I set tabs.tree_tabs to true + And I open about:blank?starting%20page in a new window + And I clean up open tabs + And I clear the log + + Scenario: Focus previous sibling tab + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new tab + And I run :tab-prev --sibling + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + + Scenario: Focus next sibling tab + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new tab + And I run :tab-focus 1 + And I run :tab-next --sibling + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt + - data/numbers/3.txt (active) + + Scenario: Closing a tab promotes the first child in its place + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 1 + And I run :tab-close + Then the following tabs should be open: + - data/numbers/2.txt + - data/numbers/3.txt + - data/numbers/4.txt + + Scenario: Focus a parent tab + When I open data/numbers/1.txt + And I open data/numbers/3.txt in a new related tab + And I open data/numbers/2.txt in a new sibling tab + And I run :tab-focus parent + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + + Scenario: :tab-close --recursive + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 1 + And I run :tab-close --recursive + Then the following tabs should be open: + - data/numbers/4.txt + + Scenario: Open a child tab + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt (active) + + Scenario: Move a tab to the given index + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new tab + And I open data/numbers/4.txt in a new related tab + And I run :tab-focus 3 + And I run :tab-move 1 + Then the following tabs should be open: + - data/numbers/3.txt + - data/numbers/4.txt + - data/numbers/1.txt + - data/numbers/2.txt + + Scenario: Collapse a subtree + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I run :tab-focus 2 + And I run :tree-tab-toggle-hide + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt (active) (collapsed) + - data/numbers/3.txt + + Scenario: Load a collapsed subtree + # Same setup as above + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I run :tab-focus 2 + And I run :tree-tab-toggle-hide + # Now actually load the saved session + And I run :session-save foo + And I run :session-load -c foo + And I wait until data/numbers/1.txt is loaded + And I wait until data/numbers/2.txt is loaded + And I wait until data/numbers/3.txt is loaded + # And of course the same assertion as above too + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt (active) (collapsed) + - data/numbers/3.txt + + Scenario: Uncollapse a subtree + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I run :tab-focus 2 + And I run :tree-tab-toggle-hide + And I run :tree-tab-toggle-hide + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt (active) + - data/numbers/3.txt + + # Same as a test in sessions.feature but tree tabs and the related + # settings. + Scenario: TreeTabs: Loading a session with tabs.new_position.related=prev + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new related tab + And I open data/numbers/3.txt in a new related tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 2 + And I run :tree-tab-toggle-hide + And I run :session-save foo + And I set tabs.new_position.related to prev + And I set tabs.new_position.tree.new_child to last + And I set tabs.new_position.tree.new_toplevel to prev + And I run :session-load -c foo + And I wait until data/numbers/1.txt is loaded + And I wait until data/numbers/2.txt is loaded + And I wait until data/numbers/3.txt is loaded + And I wait until data/numbers/4.txt is loaded + Then the following tabs should be open: + - data/numbers/1.txt + - data/numbers/2.txt (active) (collapsed) + - data/numbers/3.txt + - data/numbers/4.txt + + Scenario: Undo a tab close restores tree structure + # Restored node should be put back in the right place in the tree with + # same parent and child. + When I open about:blank?grandparent + And I open about:blank?parent in a new related tab + And I open about:blank?child in a new related tab + And I run :tab-select ?parent + And I run :tab-close + And I run :undo + Then the following tabs should be open: + - about:blank?grandparent + - about:blank?parent (active) + - about:blank?child + + Scenario: Undo a tab close when the parent has already been closed + # Close the child first, then the parent. When the child is restored + # it should be placed back at the root. + When I open about:blank?grandparent + And I open about:blank?parent in a new related tab + And I open about:blank?child in a new related tab + And I run :tab-close + And I run :tab-close + And I run :undo 2 + Then the following tabs should be open: + - about:blank?child (active) + - about:blank?grandparent + + Scenario: Undo a tab close when the parent has already been closed - with children + # Close the child first, then the parent. When the child is restored + # it should be placed back at the root, and its previous child should + # be re-attached to it. (Not sure if this is the best behavior.) + When I open about:blank?grandparent + And I open about:blank?parent in a new related tab + And I open about:blank?child in a new related tab + And I open about:blank?leaf in a new related tab + And I run :tab-select ?child + And I run :tab-close + And I run :tab-select ?parent + And I run :tab-close + And I run :undo 2 + Then the following tabs should be open: + - about:blank?child (active) + - about:blank?leaf + - about:blank?grandparent + + Scenario: Undo a tab close when the child has already been closed + # Close the parent first, then the child. Make sure we don't crash + # when trying to re-parent the child. + When I open about:blank?grandparent + And I open about:blank?parent in a new related tab + And I open about:blank?child in a new related tab + And I run :tab-select ?parent + And I run :tab-close + And I run :tab-close + And I run :undo 2 + Then the following tabs should be open: + - about:blank?grandparent + - about:blank?parent (active) diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index b1e4bbaab..e77757880 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -672,16 +672,19 @@ class QuteProc(testprocess.Process): self.set_setting(opt, old_value) def open_path(self, path, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, port=None, - https=False, wait=True): + related_tab=False, sibling_tab=False, new_window=False, + private=False, as_url=False, port=None, https=False, + wait=True): """Open the given path on the local webserver in qutebrowser.""" url = self.path_to_url(path, port=port, https=https) self.open_url(url, new_tab=new_tab, new_bg_tab=new_bg_tab, + related_tab=related_tab, sibling_tab=sibling_tab, new_window=new_window, private=private, as_url=as_url, wait=wait) def open_url(self, url, *, new_tab=False, new_bg_tab=False, - new_window=False, private=False, as_url=False, wait=True): + related_tab=False, sibling_tab=False, new_window=False, + private=False, as_url=False, wait=True): """Open the given url in qutebrowser.""" if sum(1 for opt in [new_tab, new_bg_tab, new_window, private, as_url] if opt) > 1: @@ -691,9 +694,17 @@ class QuteProc(testprocess.Process): self.send_cmd(url, invalid=True) line = None elif new_tab: - line = self.send_cmd(':open -t ' + url) + if related_tab: + line = self.send_cmd(':open -t -r ' + url) + elif sibling_tab: + line = self.send_cmd(':open -t -S ' + url) + else: + line = self.send_cmd(':open -t ' + url) elif new_bg_tab: - line = self.send_cmd(':open -b ' + url) + if related_tab: + line = self.send_cmd(':open -b -r ' + url) + else: + line = self.send_cmd(':open -b ' + url) elif new_window: line = self.send_cmd(':open -w ' + url) elif private: diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 658b027cb..a4a54e7bc 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -324,6 +324,7 @@ class FakeCommand: hide: bool = False debug: bool = False deprecated: bool = False + tree_tab: bool = False completion: Any = None maxsplit: int = None takes_count: Callable[[], bool] = lambda: False diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index e0bce8f04..7c51a6a62 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -1410,14 +1410,15 @@ def test_forward_completion(tab_with_history, info): def test_undo_completion(tabbed_browser_stubs, info): """Test :undo completion.""" entry1 = tabbedbrowser._UndoEntry(url=QUrl('https://example.org/'), - history=None, index=None, pinned=None, - created_at=datetime(2020, 1, 1)) + history=None, index=None, pinned=None) entry2 = tabbedbrowser._UndoEntry(url=QUrl('https://example.com/'), - history=None, index=None, pinned=None, - created_at=datetime(2020, 1, 2)) + history=None, index=None, pinned=None) entry3 = tabbedbrowser._UndoEntry(url=QUrl('https://example.net/'), - history=None, index=None, pinned=None, - created_at=datetime(2020, 1, 2)) + history=None, index=None, pinned=None) + + entry1.created_at = datetime(2020, 1, 1) + for entry in [entry2, entry3]: + entry.created_at = datetime(2020, 1, 2) # Most recently closed is at the end tabbed_browser_stubs[0].undo_stack = [ @@ -1442,19 +1443,22 @@ def test_undo_completion(tabbed_browser_stubs, info): }) -def undo_completion_retains_sort_order(tabbed_browser_stubs, info): +def test_undo_completion_retains_sort_order(tabbed_browser_stubs, info): """Test :undo completion sort order with > 10 entries.""" created_dt = datetime(2020, 1, 1) - created_str = "2020-01-02 00:00" + created_str = "2020-01-01 00:00" tabbed_browser_stubs[0].undo_stack = [ - tabbedbrowser._UndoEntry( - url=QUrl(f'https://example.org/{idx}'), - history=None, index=None, pinned=None, - created_at=created_dt, - ) - for idx in range(1, 11) + [ + tabbedbrowser._UndoEntry( + url=QUrl(f'https://example.org/{idx}'), + history=None, index=None, pinned=None, + ) + ] + for idx in reversed(range(1, 11)) ] + for entries in tabbed_browser_stubs[0].undo_stack: + entries[0].created_at = created_dt model = miscmodels.undo(info=info) model.set_pattern('') diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py index a249a6d1c..59defeb50 100644 --- a/tests/unit/mainwindow/test_tabwidget.py +++ b/tests/unit/mainwindow/test_tabwidget.py @@ -11,6 +11,7 @@ import pytest from unittest.mock import Mock from qutebrowser.qt.gui import QIcon, QPixmap +from qutebrowser.qt.widgets import QWidget from qutebrowser.mainwindow import tabwidget from qutebrowser.utils import usertypes @@ -21,7 +22,14 @@ class TestTabWidget: @pytest.fixture def widget(self, qtbot, monkeypatch, config_stub): - w = tabwidget.TabWidget(0) + class DummyParent(QWidget): + def __init__(self): + super().__init__() + self.is_shutting_down = False + self.show() + + w = tabwidget.TabWidget(0, parent=DummyParent()) + w.resize(640, 480) qtbot.add_widget(w) monkeypatch.setattr(tabwidget.objects, 'backend', usertypes.Backend.QtWebKit) diff --git a/tests/unit/mainwindow/test_treetabbedbrowser.py b/tests/unit/mainwindow/test_treetabbedbrowser.py new file mode 100644 index 000000000..3ae2b3b00 --- /dev/null +++ b/tests/unit/mainwindow/test_treetabbedbrowser.py @@ -0,0 +1,255 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import pytest + +from qutebrowser.config.configtypes import NewTabPosition, NewChildPosition +from qutebrowser.misc.notree import Node +from qutebrowser.mainwindow import treetabbedbrowser, treetabwidget + + +@pytest.fixture +def mock_browser(mocker): + # Mock browser used as `self` below because we are actually testing mostly + # standalone functionality apart from the tab stack related counters. + # Which are also only defined in __init__, not on the class, so mock + # doesn't see them. Hence specifying them manually here. + browser = mocker.Mock( + spec=treetabbedbrowser.TreeTabbedBrowser, + widget=mocker.Mock(spec=treetabwidget.TreeTabWidget), + _tree_tab_child_rel_idx=0, + _tree_tab_sibling_rel_idx=0, + _tree_tab_toplevel_rel_idx=0, + ) + + # Sad little workaround to create a bound method on a mock, because + # _position_tab calls a method on self but we are using a mock as self to + # avoid initializing the whole tabbed browser class. + def reset_passthrough(): + return treetabbedbrowser.TreeTabbedBrowser._reset_stack_counters( + browser + ) + browser._reset_stack_counters = reset_passthrough + + return browser + + +class TestPositionTab: + """Test TreeTabbedBrowser._position_tab().""" + + @pytest.mark.parametrize( + " relation, cur_node, pos, expected", [ + ("sibling", "three", "first", "one",), + ("sibling", "three", "prev", "two",), + ("sibling", "three", "next", "three",), + ("sibling", "three", "last", "six",), + ("sibling", "one", "first", "root",), + ("sibling", "one", "prev", "root",), + ("sibling", "one", "next", "one",), + ("sibling", "one", "last", "seven",), + + ("related", "one", "first", "one",), + ("related", "one", "last", "six",), + ("related", "two", "first", "two",), + ("related", "two", "last", "two",), + + (None, "five", "first", "root",), + (None, "five", "prev", "root",), + (None, "five", "next", "one",), + (None, "five", "last", "seven",), + (None, "seven", "prev", "one",), + (None, "seven", "next", "seven",), + ] + ) + def test_position_tab( + self, + config_stub, + mock_browser, + # parameterized + relation, + cur_node, + pos, + expected, + ): + """Test tree tab positioning. + + How to use the parameters above: + * refer to the tree structure being passed to create_tree() below, that's + our starting state + * specify how the new node should be related to the current one + * specify cur_node by value, which is the tab currently focused when the + new tab is opened and the one the "sibling" and "related" arguments + refer to + * set "pos" which is the position of the new node in the list of + siblings it's going to end up in. It should be one of first, list, prev, + next (except the "related" relation doesn't support prev and next) + * specify the expected preceding node (the preceding sibling if there is + one, otherwise the parent) after the new node is positioned, "root" is + a valid value for this + + Having the expectation being the preceding tab (sibling or parent) is + a bit limited, in particular if the new tab somehow ends up as a child + instead of the next sibling you wouldn't be able to tell those + situations apart. But I went this route to avoid having to specify + multiple trees in the parameters. + """ + root = self.create_tree( + """ + - one + - two + - three + - four + - five + - six + - seven + """, + ) + new_node = Node("new", parent=root) + + config_stub.val.tabs.new_position.stacking = False + self.call_position_tab( + mock_browser, + root, + cur_node, + new_node, + pos, + relation, + ) + + preceding_node = None + if new_node.parent.children[0] == new_node: + preceding_node = new_node.parent + else: + for n in new_node.parent.children: + if n.value == "new": + break + preceding_node = n + else: + pytest.fail("new tab not found") + + assert preceding_node.value == expected + + def call_position_tab( + self, + mock_browser, + root, + cur_node, + new_node, + pos, + relation, + background=False, + ): + sibling = related = False + if relation == "sibling": + sibling = True + elif relation == "related": + related = True + elif relation == "background": + background = True + elif relation is not None: + pytest.fail( + "Valid values for relation are: " + "sibling, related, background, None" + ) + + # This relation -> parent mapping is copied from + # TreeTabbedBrowser.tabopen(). + cur_node = next(n for n in root.traverse() if n.value == cur_node) + assert not (related and sibling) + if related: + parent = cur_node + NewChildPosition().from_str(pos) + elif sibling: + parent = cur_node.parent + NewTabPosition().from_str(pos) + else: + parent = root + NewTabPosition().from_str(pos) + + treetabbedbrowser.TreeTabbedBrowser._position_tab( + mock_browser, + cur_node=cur_node, + new_node=new_node, + pos=pos, + parent=parent, + sibling=sibling, + related=related, + background=background, + ) + + def create_tree(self, tree_str): + # Construct a notree.Node tree from the test string. + root = Node("root") + previous_indent = '' + previous_node = root + for line in tree_str.splitlines(): + if not line.strip(): + continue + indent, value = line.split("-") + node = Node(value.strip()) + if len(indent) > len(previous_indent): + node.parent = previous_node + elif len(indent) == len(previous_indent): + node.parent = previous_node.parent + else: + # TODO: handle going up in jumps of more than one rank + node.parent = previous_node.parent.parent + previous_indent = indent + previous_node = node + return root + + @pytest.mark.parametrize( + " test_tree, relation, pos, expected", [ + ("tree_one", "sibling", "next", "one,two,new1,new2,new3",), + ("tree_one", "sibling", "prev", "one,new3,new2,new1,two",), + ("tree_one", None, "next", "one,two,new1,new2,new3",), + ("tree_one", None, "prev", "new3,new2,new1,one,two",), + ("tree_one", "related", "first", "one,two,new1,new2,new3",), + ("tree_one", "related", "last", "one,two,new1,new2,new3",), + ] + ) + def test_position_tab_stacking( + self, + config_stub, + mock_browser, + # parameterized + test_tree, + relation, + pos, + expected, + ): + """Test tree tab positioning with tab stacking enabled. + + With tab stacking enabled the first background tab should be opened + beside the current one, successive background tabs should be opened on + the other side of prior opened tabs, not beside the current tab. + This test covers what is currently implemented, I'm not sure all the + desired behavior is implemented currently though. + """ + # Simpler tree here to make the assert string a bit simpler. + # Tab "two" is hardcoded as cur_tab. + root = self.create_tree( + """ + - one + - two + """, + ) + config_stub.val.tabs.new_position.stacking = True + + for val in ["new1", "new2", "new3"]: + new_node = Node(val, parent=root) + + self.call_position_tab( + mock_browser, + root, + "two", + new_node, + pos, + relation, + background=True, + ) + + actual = ",".join([n.value for n in root.traverse()]) + actual = actual[len("root,"):] + assert actual == expected diff --git a/tests/unit/misc/test_notree.py b/tests/unit/misc/test_notree.py new file mode 100644 index 000000000..192dc6ac7 --- /dev/null +++ b/tests/unit/misc/test_notree.py @@ -0,0 +1,293 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The-Compiler) <me@the-compiler.org> +# +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for misc.notree library.""" +import pytest + +from qutebrowser.misc.notree import TreeError, Node, TraverseOrder + + +@pytest.fixture +def tree(): + """Return an example tree. + + n1 + ├─n2 + │ ├─n4 + │ └─n5 + └─n3 + ├─n6 + │ ├─n7 + │ ├─n8 + │ └─n9 + │ └─n10 + └─n11 + """ + # these are actually used because they appear in expected strings + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) + n5 = Node('n5', n2) + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) + n8 = Node('n8', n6) + n9 = Node('n9', n6) + n10 = Node('n10', n9) + n11 = Node('n11', n3) + return n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 + + +@pytest.fixture +def node(tree): + return tree[0] + + +def test_creation(): + node = Node('foo') + assert node.value == 'foo' + + child = Node('bar', node) + assert child.parent == node + assert node.children == (child, ) + + +def test_attach_parent(): + n1 = Node('n1', None, []) + print(n1.children) + n2 = Node('n2', n1) + n3 = Node('n3') + + n2.parent = n3 + assert n2.parent == n3 + assert n3.children == (n2, ) + assert n1.children == () + + +def test_duplicate_child(): + p = Node('n1') + try: + c1 = Node('c1', p) + c2 = Node('c2', p) + p.children = [c1, c1, c2] + raise AssertionError("Can add duplicate child") + except TreeError: + pass + finally: + if len(p.children) == 3: + raise AssertionError("Can add duplicate child") + + +def test_replace_parent(): + p1 = Node('foo') + p2 = Node('bar') + _ = Node('_', p2) + c = Node('baz', p1) + c.parent = p2 + assert c.parent is p2 + assert c not in p1.children + assert c in p2.children + + +def test_replace_children(tree): + n2 = tree[1] + n3 = tree[2] + n6 = tree[5] + n11 = tree[10] + n3.children = [n11] + n2.children = (n6, ) + n2.children + assert n6.parent is n2 + assert n6 in n2.children + assert n11.parent is n3 + assert n11 in n3.children + assert n6 not in n3.children + assert len(n3.children) == 1 + + +def test_promote_to_first(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='first') + assert n6.parent is n1 + assert n1.children[0] is n6 + + +def test_promote_to_last(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + n6.promote(to='last') + assert n6.parent is n1 + assert n1.children[-1] is n6 + + +def test_promote_to_prev(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='prev') + assert n6.parent is n1 + assert n1.children[1] is n6 + + +def test_promote_to_next(tree): + n1 = tree[0] + n3 = tree[2] + n6 = tree[5] + assert n6.parent is n3 + assert n3.parent is n1 + assert n1.children[1] is n3 + n6.promote(to='next') + assert n6.parent is n1 + assert n1.children[2] is n6 + + +def test_demote_to_first(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='first') + assert n11.parent is n6 + assert n6.children[0] is n11 + + +def test_demote_to_last(tree): + n11 = tree[10] + n6 = tree[5] + assert n11.parent is n6.parent + parent = n11.parent + assert parent.children.index(n11) == parent.children.index(n6) + 1 + n11.demote(to='last') + assert n11.parent is n6 + assert n6.children[-1] is n11 + + +def test_traverse(node): + len_traverse = len(list(node.traverse())) + len_render = len(node.render()) + assert len_traverse == len_render + + +def test_traverse_postorder(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n4, n5, n2, n7, n8, n10, n9, n6, n11, n3, n1] + + +def test_traverse_postorder_r(tree): + n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11 = tree + actual = list(n1.traverse(TraverseOrder.POST_R)) + print('\n'.join([str(n) for n in actual])) + assert actual == [n11, n10, n9, n8, n7, n6, n3, n5, n4, n2, n1] + + +def test_render_tree(node): + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3', + ' ├─n6', + ' │ ├─n7', + ' │ ├─n8', + ' │ └─n9', + ' │ └─n10', + ' └─n11' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + +def test_uid(node): + uids = set() + for n in node.traverse(): + assert n not in uids + uids.add(n.uid) + # pylint: disable=unused-variable + n1 = Node('n1') + n2 = Node('n2', n1) + n4 = Node('n4', n2) # noqa: F841 + n5 = Node('n5', n2) # noqa: F841 + n3 = Node('n3', n1) + n6 = Node('n6', n3) + n7 = Node('n7', n6) # noqa: F841 + n8 = Node('n8', n6) # noqa: F841 + n9 = Node('n9', n6) + n10 = Node('n10', n9) # noqa: F841 + n11 = Node('n11', n3) + # pylint: enable=unused-variable + for n in n1.traverse(): + assert n not in uids + uids.add(n.uid) + + n11_uid = n11.uid + assert n1.get_descendent_by_uid(n11_uid) is n11 + assert node.get_descendent_by_uid(n11_uid) is None + + +def test_collapsed(node): + pre_collapsed_traverse = list(node.traverse()) + to_collapse = node.children[1] + + # collapse + to_collapse.collapsed = True + assert to_collapse.collapsed is True + for n in node.traverse(render_collapsed=False): + assert to_collapse not in n.path[:-1] + + assert list(to_collapse.traverse(render_collapsed=False)) == [to_collapse] + + assert list(node.traverse()) == pre_collapsed_traverse + + expected = [ + 'n1', + '├─n2', + '│ ├─n4', + '│ └─n5', + '└─n3' + ] + result = [char + str(n) for char, n in node.render()] + print('\n'.join(result)) + assert expected == result + + # uncollapse + to_collapse.collapsed = False + + assert any(n for n in node.traverse(render_collapsed=False) if to_collapse + in n.path[:-1]) + + +def test_memoization(node): + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].parent = None + assert node._Node__modified is True + node.render() + assert node._Node__modified is False + + n2 = Node('ntest', parent=node) + assert node._Node__modified is True + assert n2._Node__modified is True + node.render() + assert node._Node__modified is False + + node.children[0].children[1].parent = None + assert node._Node__modified is True + assert node.children[0]._Node__modified is True + node.render() + assert node._Node__modified is False |