From fb32bc2a59ad4b65612279794fc7e639fe991538 Mon Sep 17 00:00:00 2001 From: Simon Chopin Date: Sun, 15 Mar 2026 23:26:16 +0100 Subject: [PATCH 1/2] thread: make it possible to customize default message selection This needs to be done through a callback passed in the constructor, as the thread selection only happens after the model has been refreshed for the first time, which might be way after the creation of the panel itself. A real-life example of a need for this is someone wishing to open threads directly on the first unread message rather than the first search match, see https://github.com/akissinger/dodo/pull/125 Note that open_current_thread needs to have its new argument marked as keyword-only as this method is connected to an event that provides positional argument that we just don't care about. --- dodo/app.py | 4 ++-- dodo/search.py | 4 ++-- dodo/thread.py | 10 ++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dodo/app.py b/dodo/app.py index 857d9fd..032751c 100644 --- a/dodo/app.py +++ b/dodo/app.py @@ -194,7 +194,7 @@ def open_search(self, query: str, keep_open: bool=False) -> None: p = search.SearchPanel(self, query, keep_open=keep_open) self.add_panel(p) - def open_thread(self, thread_id: str, query: str) -> None: + def open_thread(self, thread_id: str, query: str, default_message: thread.MessageSelector|None=None) -> None: """Open a thread panel with the given thread_id If a panel with this thread_id is already open, switch to it rather than @@ -206,7 +206,7 @@ def open_thread(self, thread_id: str, query: str) -> None: self.tabs.setCurrentIndex(i) return - p = thread.ThreadPanel(self, thread_id, query) + p = thread.ThreadPanel(self, thread_id, query, default_message=default_message) self.add_panel(p) def open_compose(self, mode: str='', msg: Optional[dict]=None) -> None: diff --git a/dodo/search.py b/dodo/search.py index bc69842..0970cde 100644 --- a/dodo/search.py +++ b/dodo/search.py @@ -391,12 +391,12 @@ def next_page(self) -> None: pos = self.tree.rect().topLeft() self.tree.setCurrentIndex(self.tree.indexAt(pos)) - def open_current_thread(self) -> None: + def open_current_thread(self, *, default_message: thread.MessageSelector|None=None) -> None: """Open the selected thread""" thread_id = self.model.thread_id(self.tree.currentIndex()) if thread_id: - self.app.open_thread(thread_id, self.model.q) + self.app.open_thread(thread_id, self.model.q, default_message=default_message) def toggle_thread_tag(self, tag: str) -> None: """Toggle the given thread tag""" diff --git a/dodo/thread.py b/dodo/thread.py index ab9c50a..bd1c650 100644 --- a/dodo/thread.py +++ b/dodo/thread.py @@ -17,8 +17,8 @@ # along with Dodo. If not, see . from __future__ import annotations -from typing import List, Optional, Any, Union, Literal -from collections.abc import Generator, Iterable +from typing import List, Optional, Any, Union, Literal, TypeAlias +from collections.abc import Generator, Iterable, Callable from PyQt6.QtCore import * from PyQt6.QtGui import QFont, QColor, QDesktopServices @@ -502,6 +502,7 @@ def rowCount(self, parent: QModelIndex=QModelIndex()) -> int: """The number of rows (for a given parent)""" return len(self._children_at(parent)) +MessageSelector: TypeAlias = Callable[[ThreadModel], QModelIndex] class ThreadPanel(panel.Panel): """A panel showing an email thread @@ -512,7 +513,7 @@ class ThreadPanel(panel.Panel): :param thread_id: the unique ID notmuch uses to identify this thread """ - def __init__(self, a: app.Dodo, thread_id: str, search_query: str, parent: Optional[QWidget]=None): + def __init__(self, a: app.Dodo, thread_id: str, search_query: str, parent: Optional[QWidget]=None, default_message: MessageSelector|None=None): super().__init__(a, parent=parent) self.set_keymap(keymap.thread_keymap) self.model = ThreadModel(thread_id, search_query, settings.default_thread_list_mode) @@ -521,6 +522,7 @@ def __init__(self, a: app.Dodo, thread_id: str, search_query: str, parent: Optio self.html_mode = settings.default_to_html self._saved_msg = None self._saved_collapsed = None + self._default_message = default_message or (lambda m: m.default_message()) self.subject = '(no subject)' @@ -595,7 +597,7 @@ def _do_reset(self): if idx.isValid(): self._select_index(idx) else: - self._select_index(self.model.default_message()) + self._select_index(self._default_message(self.model)) def toggle_list_mode(self): self.model.toggle_mode() From 6b066f2b35ab7e14f08358c9333f887182595085 Mon Sep 17 00:00:00 2001 From: Simon Chopin Date: Sun, 15 Mar 2026 23:25:37 +0100 Subject: [PATCH 2/2] thread: model: next_unread(): return the start index if empty-handed That makes the return value of this method suitable as a default_message provider, otherwise we'd have an invalid index if a thread doesn't have any matching unread messages. That would be awkward. With that, you can now have a custom keybinding such as this: dodo.keymap.search_keymap['Q'] = ('open unread', lambda p: p.open_current_thread(default_message=lambda m: m.next_unread(m.default_message()))) --- dodo/thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dodo/thread.py b/dodo/thread.py index bd1c650..2ecf7a7 100644 --- a/dodo/thread.py +++ b/dodo/thread.py @@ -450,7 +450,7 @@ def next_unread(self, current: QModelIndex) -> QModelIndex: msg = self.message_at(idx) if msg['id'] in self.matches and 'unread' in msg['tags']: return idx - return QModelIndex() + return current def data(self, index: QModelIndex, role: int=Qt.ItemDataRole.DisplayRole) -> Any: """Overrides `QAbstractItemModel.data` to populate a list view with short descriptions of