From ce6f6f18d94ade619dc55f66f734780736fc13be Mon Sep 17 00:00:00 2001 From: Sirisak Lueangsaksri <1087399+spywhere@users.noreply.github.com> Date: Fri, 22 Aug 2025 01:36:56 +0700 Subject: [PATCH 1/3] feat: allow custom tab naming --- qutebrowser/browser/browsertab.py | 26 ++++++++++++++++--- qutebrowser/browser/commands.py | 19 ++++++++++++++ qutebrowser/browser/webengine/webenginetab.py | 10 +++++-- qutebrowser/browser/webkit/webkittab.py | 10 +++++-- qutebrowser/mainwindow/tabbedbrowser.py | 3 +++ qutebrowser/misc/sessions.py | 11 +++++++- tests/unit/completion/test_models.py | 5 +++- 7 files changed, 74 insertions(+), 10 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 74eacfcd0..d7e160d6b 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) @@ -1220,7 +1222,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() @@ -1316,9 +1318,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 98b6e275c..8630d83c6 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -1402,7 +1402,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: @@ -1675,6 +1675,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() @@ -1693,7 +1699,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/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/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) ] From 192cb0217e1c7b937226b3fe6b4e49344f12596f Mon Sep 17 00:00:00 2001 From: Sirisak Lueangsaksri <1087399+spywhere@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:46:18 +0700 Subject: [PATCH 2/3] test: add end2end tests for tab-title command --- tests/end2end/features/tabs.feature | 130 ++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) 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 From 7060e7682cfd084fbbfd1873fd23cc360243b41b Mon Sep 17 00:00:00 2001 From: Sirisak Lueangsaksri <1087399+spywhere@users.noreply.github.com> Date: Sat, 23 Aug 2025 00:03:16 +0700 Subject: [PATCH 3/3] feat: add default keybindings for tab title --- qutebrowser/config/configdata.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index dbe9e124f..8793b75ff 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -3690,6 +3690,7 @@ bindings.default: K: tab-prev : tab-prev gC: tab-clone + tt: cmd-set-text -s :tab-title r: reload : reload R: reload -f