diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 597b8d37c..5e2f6b3dd 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -127,6 +127,7 @@ class TabData: open_target: usertypes.ClickTarget = usertypes.ClickTarget.normal override_target: Optional[usertypes.ClickTarget] = None pinned: bool = False + custom_title: Optional[str] = None fullscreen: bool = False netrc_used: bool = False input_mode: usertypes.KeyMode = usertypes.KeyMode.normal @@ -1132,12 +1133,13 @@ class AbstractTab(QWidget): qtutils.ensure_valid(url) url_string = url.toDisplayString() log.webview.debug("Going to start loading: {}".format(url_string)) - self.title_changed.emit(url_string) + if not self.title() and not self.data.custom_title: + self.title_changed.emit(url_string) @pyqtSlot(QUrl) def _on_url_changed(self, url: QUrl) -> None: """Update title when URL has changed and no title is available.""" - if url.isValid() and not self.title(): + if url.isValid() and not self.title() and not self.data.custom_title: self.title_changed.emit(url.toDisplayString()) self.url_changed.emit(url) @@ -1189,7 +1191,7 @@ class AbstractTab(QWidget): self.load_finished.emit(ok) - if not self.title(): + if not self.title() and not self.data.custom_title: self.title_changed.emit(self.url().toDisplayString()) self.zoom.reapply() @@ -1285,9 +1287,25 @@ class AbstractTab(QWidget): """ raise NotImplementedError - def title(self) -> str: + def raw_title(self) -> str: raise NotImplementedError + def title(self) -> str: + return self.data.custom_title or self.raw_title() + + def set_title(self, title: str) -> None: + """Set a custom tab's title, or reset it if empty is passed. + + Args: + title: A custom tab's title + """ + if title: + self.data.custom_title = title + self.title_changed.emit(title) + else: + self.data.custom_title = None + self.title_changed.emit(self.raw_title()) + def icon(self) -> QIcon: raise NotImplementedError diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index ebce4b37a..f8dfed20e 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -258,6 +258,24 @@ class CommandDispatcher: self._tabbed_browser.tab_close_prompt_if_pinned(tab, force, close) + @cmdutils.register(instance='command-dispatcher', scope='window', + name='tab-title') + @cmdutils.argument('count', value=cmdutils.Value.count) + @cmdutils.argument('title') + def tab_title(self, count=None, title=None): + """Set/Unset the current/[count]th tab's title. + + Set the text to empty to unset the tab's title. + + Args: + count: The tab index to set or unset tab's title, or None + title: The tab title to renamed to, or empty to reset it + """ + tab = self._cntwidget(count) + if tab is None: + return + tab.set_title(title or '') + @cmdutils.register(instance='command-dispatcher', scope='window', name='tab-pin') @cmdutils.argument('count', value=cmdutils.Value.count) @@ -426,6 +444,7 @@ class CommandDispatcher: newtab.history.private_api.deserialize(history) newtab.zoom.set_factor(curtab.zoom.factor()) + newtab.set_title(curtab.data.custom_title) newtab.set_pinned(curtab.data.pinned) return newtab diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 726a2efab..9f00108c0 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -1406,7 +1406,7 @@ class WebEngineTab(browsertab.AbstractTab): def stop(self): self._widget.stop() - def title(self): + def raw_title(self): return self._widget.title() def renderer_process_pid(self) -> int: @@ -1724,6 +1724,12 @@ class WebEngineTab(browsertab.AbstractTab): else: selection.selectNone() + def _on_title_changed(self, title): + """Handle title updates.""" + if self.data.custom_title: + return + self.title_changed.emit(title) + def _connect_signals(self): view = self._widget page = view.page() @@ -1742,7 +1748,7 @@ class WebEngineTab(browsertab.AbstractTab): page.printRequested.connect(self._on_print_requested) page.selectClientCertificate.connect(self._on_select_client_certificate) - view.titleChanged.connect(self.title_changed) + view.titleChanged.connect(self._on_title_changed) view.urlChanged.connect(self._on_url_changed) view.renderProcessTerminated.connect( self._on_render_process_terminated) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index d89295440..da3b380df 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -927,7 +927,7 @@ class WebKitTab(browsertab.AbstractTab): def stop(self): self._widget.stop() - def title(self): + def raw_title(self): return self._widget.title() def renderer_process_pid(self) -> Optional[int]: @@ -1017,6 +1017,12 @@ class WebKitTab(browsertab.AbstractTab): def _on_ssl_errors(self, reply): self._insecure_hosts.add(reply.url().host()) + def _on_title_changed(self, title): + """Handle title updates.""" + if self.data.custom_title: + return + self.title_changed.emit(title) + def _connect_signals(self): view = self._widget page = view.page() @@ -1031,7 +1037,7 @@ class WebKitTab(browsertab.AbstractTab): self._on_load_started) view.scroll_pos_changed.connect(self.scroller.perc_changed) view.titleChanged.connect( # type: ignore[attr-defined] - self.title_changed) + self._on_title_changed) view.urlChanged.connect( # type: ignore[attr-defined] self._on_url_changed) view.shutting_down.connect(self.shutting_down) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index b221a70dc..3a43934a1 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -3707,6 +3707,7 @@ bindings.default: K: tab-prev : tab-prev gC: tab-clone + tt: cmd-set-text -s :tab-title r: reload : reload R: reload -f diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index e0938ae36..089e6a9d9 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -36,6 +36,7 @@ class _UndoEntry: history: bytes index: int pinned: bool + title: Optional[str] created_at: datetime.datetime = dataclasses.field( default_factory=datetime.datetime.now) @@ -516,6 +517,7 @@ class TabbedBrowser(QWidget): entry = _UndoEntry(url=tab.url(), history=history_data, index=idx, + title=tab.data.custom_title, pinned=tab.data.pinned) if new_undo or not self.undo_stack: self.undo_stack.append([entry]) @@ -563,6 +565,7 @@ class TabbedBrowser(QWidget): newtab = self.tabopen(background=False, idx=entry.index) newtab.history.private_api.deserialize(entry.history) + newtab.set_title(entry.title or '') newtab.set_pinned(entry.pinned) newtab.setFocus() diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index b487fcd2c..72a006eac 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -220,6 +220,7 @@ class SessionManager(QObject): data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} data['pinned'] = tab.data.pinned + data['custom_title'] = tab.data.custom_title return data @@ -412,6 +413,9 @@ class SessionManager(QObject): if 'pinned' in histentry: new_tab.data.pinned = histentry['pinned'] + if 'custom_title' in histentry: + new_tab.data.custom_title = histentry['custom_title'] + if (config.val.session.lazy_restore and histentry.get('active', False) and not histentry['url'].startswith('qute://back')): @@ -449,7 +453,10 @@ class SessionManager(QObject): last_visited=last_visited) entries.append(entry) if active: - new_tab.title_changed.emit(histentry['title']) + if new_tab.data.custom_title: + new_tab.title_changed.emit(new_tab.data.custom_title) + else: + new_tab.title_changed.emit(histentry['title']) try: new_tab.history.private_api.load_items(entries) @@ -470,6 +477,8 @@ class SessionManager(QObject): tab_to_focus = i if new_tab.data.pinned: new_tab.set_pinned(True) + if new_tab.data.custom_title: + new_tab.set_title(new_tab.data.custom_title) if tab_to_focus is not None: tabbed_browser.widget.setCurrentIndex(tab_to_focus) diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index cb9c9702f..c53bd4fc0 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -1925,6 +1925,136 @@ Feature: Tab management """ + # :tab-title + + Scenario: Set tab title + When I open data/title.html + And I open data/title.html in a new tab + And I run :tab-title renamed + Then the session should look like: + """ + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + title: Test title + custom_title: + - active: true + history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: renamed + """ + + Scenario: Set specific tab title using count + When I open data/title.html + And I open data/title.html in a new tab + And I run :tab-title renamed with count 1 + Then the session should look like: + """ + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + title: Test title + custom_title: renamed + - active: true + history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: + """ + + Scenario: Set tab title with space + When I open data/title.html + And I open data/title.html in a new tab + And I run :tab-title 'a new title' + Then the session should look like: + """ + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + title: Test title + custom_title: + - active: true + history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: a new title + """ + + Scenario: Custom tab title when navigated + When I open data/numbers/1.txt + And I run :tab-title 'a new title' + And I open data/title.html + Then the session should look like: + """ + windows: + - tabs: + - active: true + history: + - url: about:blank + - url: http://localhost:*/data/numbers/1.txt + custom_title: a new title + - url: http://localhost:*/data/title.html + title: Test title + custom_title: a new title + """ + + Scenario: Cloning a tab with a custom tab title + When I open data/title.html + And I open data/title.html in a new tab + And I run :tab-title 'a new title' + And I run :tab-clone + And I wait until data/title.html is loaded + Then the session should look like: + """ + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + title: Test title + custom_title: + - history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: a new title + - active: true + history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: a new title + """ + + Scenario: Undo a tab with a custom tab title + When I open data/title.html + And I open data/title.html in a new tab + And I run :tab-title 'a new title' + And I run :tab-close --force + And I run :undo + And I wait until data/title.html is loaded + Then the session should look like: + """ + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + title: Test title + custom_title: + - active: true + history: + - url: http://localhost:*/data/title.html + title: Test title + custom_title: a new title + """ + + Scenario: Focused webview after clicking link in bg When I open data/hints/link_input.html And I run :click-element id qute-input-existing diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index ad73d9eb5..872881a68 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -1413,12 +1413,15 @@ 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, + title=None, created_at=datetime(2020, 1, 1)) entry2 = tabbedbrowser._UndoEntry(url=QUrl('https://example.com/'), history=None, index=None, pinned=None, + title=None, created_at=datetime(2020, 1, 2)) entry3 = tabbedbrowser._UndoEntry(url=QUrl('https://example.net/'), history=None, index=None, pinned=None, + title=None, created_at=datetime(2020, 1, 2)) # Most recently closed is at the end @@ -1453,7 +1456,7 @@ def undo_completion_retains_sort_order(tabbed_browser_stubs, info): tabbedbrowser._UndoEntry( url=QUrl(f'https://example.org/{idx}'), history=None, index=None, pinned=None, - created_at=created_dt, + title=None, created_at=created_dt, ) for idx in range(1, 11) ]