anki/qt/aqt/browser/browser.py

861 lines
29 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Callable, Optional, Sequence, Tuple, Union
import aqt
2019-12-20 10:19:03 +01:00
import aqt.forms
from anki.cards import Card, CardId
from anki.collection import Collection, Config, OpChanges, SearchNode
2019-12-20 10:19:03 +01:00
from anki.consts import *
from anki.errors import NotFoundError
2020-11-18 04:48:23 +01:00
from anki.lang import without_unicode_isolation
from anki.notes import NoteId
2021-03-18 03:06:45 +01:00
from anki.tags import MARKED_TAG
from anki.utils import isMac
from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor
2020-02-10 04:15:19 +01:00
from aqt.exporting import ExportDialog
2021-05-20 10:26:18 +02:00
from aqt.flags import load_flags
2021-04-03 08:26:10 +02:00
from aqt.operations.card import set_card_deck, set_card_flag
from aqt.operations.collection import redo, undo
2021-04-03 08:26:10 +02:00
from aqt.operations.note import remove_notes
from aqt.operations.scheduling import (
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
forget_cards,
2021-03-18 02:46:11 +01:00
reposition_new_cards_dialog,
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
set_due_date_dialog,
suspend_cards,
unsuspend_cards,
)
2021-04-03 08:26:10 +02:00
from aqt.operations.tag import (
add_tags_to_notes,
clear_unused_tags,
remove_tags_from_notes,
)
from aqt.qt import *
2021-03-29 12:24:24 +02:00
from aqt.switch import Switch
from aqt.undo import UndoActionsInfo
2019-12-23 01:34:10 +01:00
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
current_window,
ensure_editor_saved,
2019-12-23 01:34:10 +01:00
getTag,
2021-04-26 08:46:08 +02:00
no_arg_trigger,
2019-12-23 01:34:10 +01:00
openHelp,
qtMenuShortcutWorkaround,
restoreGeom,
restoreSplitter,
restoreState,
saveGeom,
saveSplitter,
saveState,
showInfo,
showWarning,
skip_if_selection_is_empty,
tr,
2019-12-23 01:34:10 +01:00
)
from ..changenotetype import change_notetype_dialog
from .card_info import CardInfoDialog
from .find_and_replace import FindAndReplaceDialog
from .previewer import BrowserPreviewer as PreviewDialog
from .previewer import Previewer
from .sidebar import SidebarTreeView
from .table import Table
class Browser(QMainWindow):
2019-12-20 08:55:19 +01:00
mw: AnkiQt
2020-05-20 09:56:52 +02:00
col: Collection
editor: Optional[Editor]
table: Table
def __init__(
self,
mw: AnkiQt,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
2021-02-02 09:48:55 +01:00
"""
card -- try to select the provided card after executing "search" or
"deck:current" (if "search" was None)
search -- set and perform search; caller must ensure validity
2021-02-02 09:48:55 +01:00
"""
QMainWindow.__init__(self, None, Qt.Window)
self.mw = mw
self.col = self.mw.col
self.lastFilter = ""
self.focusTo: Optional[int] = None
self._previewer: Optional[Previewer] = None
self._closeEventHasCleanedUp = False
self.form = aqt.forms.browser.Ui_Dialog()
self.form.setupUi(self)
self.setupSidebar()
restoreGeom(self, "editor", 0)
restoreState(self, "editor")
restoreSplitter(self.form.splitter, "editor3")
self.form.splitter.setChildrenCollapsible(False)
self.card: Optional[Card] = None
self.setup_table()
self.setupMenus()
self.setupHooks()
self.setupEditor()
# disable undo/redo
self.on_undo_state_change(mw.undo_actions_info())
2020-02-29 17:02:51 +01:00
gui_hooks.browser_will_show(self)
self.show()
self.setupSearch(card, search)
def on_operation_did_execute(
self, changes: OpChanges, handler: Optional[object]
) -> None:
focused = current_window() == self
self.table.op_executed(changes, handler, focused)
self.sidebar.op_executed(changes, handler, focused)
if changes.note_text:
if handler is not self.editor:
# fixme: this will leave the splitter shown, but with no current
# note being edited
note = self.editor.note
if note:
try:
note.load()
except NotFoundError:
self.editor.set_note(None)
return
self.editor.set_note(note)
self._renderPreview()
if changes.browser_table and changes.card:
self.card = self.table.get_current_card()
self._update_context_actions()
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_window() == self:
self.setUpdatesEnabled(True)
self.table.redraw_cells()
self.sidebar.refresh_if_needed()
def setupMenus(self) -> None:
# actions
f = self.form
# edit
qconnect(f.actionUndo.triggered, self.undo)
qconnect(f.actionRedo.triggered, self.redo)
qconnect(f.actionInvertSelection.triggered, self.table.invert_selection)
qconnect(f.actionSelectNotes.triggered, self.selectNotes)
if not isMac:
f.actionClose.setVisible(False)
qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck)
f.actionCreateFilteredDeck.setShortcuts(["Ctrl+G", "Ctrl+Alt+G"])
# notes
qconnect(f.actionAdd.triggered, self.mw.onAddCard)
2021-04-25 19:51:57 +02:00
qconnect(f.actionAdd_Tags.triggered, self.add_tags_to_selected_notes)
qconnect(f.actionRemove_Tags.triggered, self.remove_tags_from_selected_notes)
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags)
2021-03-31 10:05:44 +02:00
qconnect(f.actionToggle_Mark.triggered, self.toggle_mark_of_selected_notes)
qconnect(f.actionChangeModel.triggered, self.onChangeModel)
qconnect(f.actionFindDuplicates.triggered, self.onFindDupes)
qconnect(f.actionFindReplace.triggered, self.onFindReplace)
qconnect(f.actionManage_Note_Types.triggered, self.mw.onNoteTypes)
qconnect(f.actionDelete.triggered, self.delete_selected_notes)
# cards
qconnect(f.actionChange_Deck.triggered, self.set_deck_of_selected_cards)
qconnect(f.action_Info.triggered, self.showCardInfo)
qconnect(f.actionReposition.triggered, self.reposition)
qconnect(f.action_set_due_date.triggered, self.set_due_date)
qconnect(f.action_forget.triggered, self.forget_cards)
2021-03-04 13:14:35 +01:00
qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards)
def set_flag_func(desired_flag: int) -> Callable:
return lambda: self.set_flag_of_selected_cards(desired_flag)
for flag in load_flags(self.col):
qconnect(
getattr(self.form, flag.action).triggered, set_flag_func(flag.index)
)
self._update_flag_labels()
2021-04-25 19:51:57 +02:00
qconnect(f.actionExport.triggered, self._on_export_notes)
# jumps
qconnect(f.actionPreviousCard.triggered, self.onPreviousCard)
qconnect(f.actionNextCard.triggered, self.onNextCard)
qconnect(f.actionFirstCard.triggered, self.onFirstCard)
qconnect(f.actionLastCard.triggered, self.onLastCard)
qconnect(f.actionFind.triggered, self.onFind)
qconnect(f.actionNote.triggered, self.onNote)
qconnect(f.actionSidebar.triggered, self.focusSidebar)
qconnect(f.actionCardList.triggered, self.onCardList)
# help
qconnect(f.actionGuide.triggered, self.onHelp)
# keyboard shortcut for shift+home/end
self.pgUpCut = QShortcut(QKeySequence("Shift+Home"), self)
qconnect(self.pgUpCut.activated, self.onFirstCard)
self.pgDownCut = QShortcut(QKeySequence("Shift+End"), self)
qconnect(self.pgDownCut.activated, self.onLastCard)
# add-on hook
gui_hooks.browser_menus_did_init(self)
self.mw.maybeHideAccelerators(self)
2021-02-01 00:39:55 +01:00
def closeEvent(self, evt: QCloseEvent) -> None:
if self._closeEventHasCleanedUp:
evt.accept()
return
self.editor.call_after_note_saved(self._closeWindow)
evt.ignore()
2021-02-01 00:39:55 +01:00
def _closeWindow(self) -> None:
self._cleanup_preview()
self.editor.cleanup()
2021-04-11 12:28:11 +02:00
self.table.cleanup()
saveSplitter(self.form.splitter, "editor3")
saveGeom(self, "editor")
saveState(self, "editor")
self.teardownHooks()
self.mw.maybeReset()
aqt.dialogs.markClosed("Browser")
self._closeEventHasCleanedUp = True
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def closeWithCallback(self, onsuccess: Callable) -> None:
self._closeWindow()
onsuccess()
2021-02-01 00:39:55 +01:00
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key_Escape:
self.close()
else:
super().keyPressEvent(evt)
def reopen(
self,
_mw: AnkiQt,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
if search is not None:
self.search_for_terms(*search)
self.form.searchEdit.setFocus()
if card is not None:
if search is None:
# implicitly assume 'card' is in the current deck
self._default_search(card)
self.form.searchEdit.setFocus()
self.table.select_single_card(card.id)
# Searching
######################################################################
def setupSearch(
self,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated)
self.form.searchEdit.setCompleter(None)
self.form.searchEdit.lineEdit().setPlaceholderText(
2021-03-26 04:48:26 +01:00
tr.browsing_search_bar_hint()
2019-12-23 01:34:10 +01:00
)
self.form.searchEdit.addItems(self.mw.pm.profile["searchHistory"])
if search is not None:
self.search_for_terms(*search)
else:
self._default_search(card)
self.form.searchEdit.setFocus()
if card:
self.table.select_single_card(card.id)
# search triggered by user
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onSearchActivated(self) -> None:
text = self.current_search()
try:
normed = self.col.build_search_string(text)
except Exception as err:
showWarning(str(err))
else:
self.search_for(normed)
self.update_history()
2021-02-01 00:39:55 +01:00
def search_for(self, search: str, prompt: Optional[str] = None) -> None:
2021-01-30 11:05:48 +01:00
"""Keep track of search string so that we reuse identical search when
refreshing, rather than whatever is currently in the search field.
Optionally set the search bar to a different text than the actual search.
"""
2020-10-19 20:37:17 +02:00
self._lastSearchTxt = search
prompt = search if prompt == None else prompt
self.form.searchEdit.lineEdit().setText(prompt)
self.search()
def current_search(self) -> str:
return self.form.searchEdit.lineEdit().text()
2021-02-01 00:39:55 +01:00
def search(self) -> None:
"""Search triggered programmatically. Caller must have saved note first."""
try:
self.table.search(self._lastSearchTxt)
except Exception as err:
showWarning(str(err))
2021-02-01 00:39:55 +01:00
def update_history(self) -> None:
2019-12-23 01:34:10 +01:00
sh = self.mw.pm.profile["searchHistory"]
if self._lastSearchTxt in sh:
sh.remove(self._lastSearchTxt)
sh.insert(0, self._lastSearchTxt)
sh = sh[:30]
self.form.searchEdit.clear()
self.form.searchEdit.addItems(sh)
2019-12-23 01:34:10 +01:00
self.mw.pm.profile["searchHistory"] = sh
2020-12-22 11:08:47 +01:00
def updateTitle(self) -> None:
selected = self.table.len_selection()
cur = self.table.len()
tr_title = (
tr.browsing_window_title_notes
if self.table.is_notes_mode()
else tr.browsing_window_title
)
2019-12-23 01:34:10 +01:00
self.setWindowTitle(
without_unicode_isolation(tr_title(total=cur, selected=selected))
2019-12-23 01:34:10 +01:00
)
def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None:
search = self.col.build_search_string(*search_terms)
self.form.searchEdit.setEditText(search)
self.onSearchActivated()
2021-01-30 11:05:48 +01:00
def _default_search(self, card: Optional[Card] = None) -> None:
search = self.col.build_search_string(SearchNode(deck="current"))
if card is not None:
search = gui_hooks.default_search(search, card)
self.search_for(search, "")
2021-02-01 00:39:55 +01:00
def onReset(self) -> None:
self.sidebar.refresh()
self.begin_reset()
self.end_reset()
# caller must have called editor.saveNow() before calling this or .reset()
def begin_reset(self) -> None:
self.editor.set_note(None, hide=False)
self.mw.progress.start()
self.table.begin_reset()
def end_reset(self) -> None:
self.table.end_reset()
self.mw.progress.finish()
# Table & Editor
######################################################################
def setup_table(self) -> None:
self.table = Table(self)
self.table.set_view(self.form.tableView)
2021-03-29 12:24:24 +02:00
switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial())
switch.setChecked(self.table.is_notes_mode())
2021-04-06 11:41:18 +02:00
switch.setToolTip(tr.browsing_toggle_showing_cards_notes())
2021-03-31 18:53:36 +02:00
qconnect(self.form.action_toggle_mode.triggered, switch.toggle)
2021-03-29 12:24:24 +02:00
qconnect(switch.toggled, self.on_table_state_changed)
self.form.gridLayout.addWidget(switch, 0, 0)
2021-02-01 00:39:55 +01:00
def setupEditor(self) -> None:
def add_preview_button(editor: Editor) -> None:
preview_shortcut = "Ctrl+Shift+P" # TODO
editor._links["preview"] = lambda _editor: self.onTogglePreview()
editor.web.eval(
"$editorToolbar.then(({ notetypeButtons }) => notetypeButtons.appendButton({ component: editorToolbar.PreviewButton, id: 'preview' }));"
)
gui_hooks.editor_did_init.append(add_preview_button)
2019-12-23 01:34:10 +01:00
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self)
gui_hooks.editor_did_init.remove(add_preview_button)
@ensure_editor_saved
def onRowChanged(
2021-03-30 22:06:58 +02:00
self, _current: Optional[QItemSelection], _previous: Optional[QItemSelection]
) -> None:
2021-05-07 10:12:56 +02:00
"""Update current note and hide/show editor."""
if self._closeEventHasCleanedUp:
return
self.updateTitle()
# the current card is used for context actions
self.card = self.table.get_current_card()
# if there is only one selected card, use it in the editor
# it might differ from the current card
card = self.table.get_single_selected_card()
self.singleCard = bool(card)
self.form.splitter.widget(1).setVisible(self.singleCard)
if self.singleCard:
self.editor.set_note(card.note(), focusTo=self.focusTo)
self.focusTo = None
self.editor.card = card
else:
self.editor.set_note(None)
2021-03-30 22:06:58 +02:00
self._renderPreview()
2021-03-31 10:05:44 +02:00
self._update_context_actions()
gui_hooks.browser_did_change_row(self)
2021-03-31 10:05:44 +02:00
def _update_context_actions(self) -> None:
self._update_flags_menu()
self._update_toggle_mark_action()
self._update_toggle_suspend_action()
2021-03-29 12:24:24 +02:00
@ensure_editor_saved
def on_table_state_changed(self, checked: bool) -> None:
self.mw.progress.start()
2021-03-29 12:24:24 +02:00
self.table.toggle_state(checked, self._lastSearchTxt)
self.mw.progress.finish()
# Sidebar
######################################################################
def setupSidebar(self) -> None:
2021-03-26 04:48:26 +01:00
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
dw.setFeatures(QDockWidget.DockWidgetClosable)
dw.setObjectName("Sidebar")
dw.setAllowedAreas(Qt.LeftDockWidgetArea)
self.sidebar = SidebarTreeView(self)
self.sidebarTree = self.sidebar # legacy alias
dw.setWidget(self.sidebar)
qconnect(
self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar,
)
grid = QGridLayout()
grid.addWidget(self.sidebar.searchBar, 0, 0)
grid.addWidget(self.sidebar.toolbar, 0, 1)
grid.addWidget(self.sidebar, 1, 0, 1, 2)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(0)
w = QWidget()
w.setLayout(grid)
dw.setWidget(w)
self.sidebarDockWidget.setFloating(False)
self.sidebarDockWidget.setTitleBarWidget(QWidget())
2017-08-14 08:57:43 +02:00
self.addDockWidget(Qt.LeftDockWidgetArea, dw)
# schedule sidebar to refresh after browser window has loaded, so the
# UI is more responsive
self.mw.progress.timer(10, self.sidebar.refresh, False)
def showSidebar(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
self.sidebarDockWidget.setVisible(True)
def focusSidebar(self) -> None:
self.showSidebar()
self.sidebar.setFocus()
def focusSidebarSearchBar(self) -> None:
self.showSidebar()
self.sidebar.searchBar.setFocus()
2021-02-01 00:39:55 +01:00
def toggle_sidebar(self) -> None:
want_visible = not self.sidebarDockWidget.isVisible()
self.sidebarDockWidget.setVisible(want_visible)
if want_visible:
self.sidebar.refresh()
# legacy
2021-02-01 00:39:55 +01:00
def setFilter(self, *terms: str) -> None:
self.sidebar.update_search(*terms)
# Info
######################################################################
2021-02-01 00:39:55 +01:00
def showCardInfo(self) -> None:
if not self.card:
return
CardInfoDialog(parent=self, mw=self.mw, card=self.card)
# Menu helpers
######################################################################
def selected_cards(self) -> Sequence[CardId]:
return self.table.get_selected_card_ids()
def selected_notes(self) -> Sequence[NoteId]:
return self.table.get_selected_note_ids()
def selectedNotesAsCards(self) -> Sequence[CardId]:
return self.table.get_card_ids_from_selected_note_ids()
2021-02-01 00:39:55 +01:00
def onHelp(self) -> None:
openHelp(HelpPage.BROWSING)
2021-03-18 02:46:11 +01:00
# legacy
selectedCards = selected_cards
selectedNotes = selected_notes
# Misc menu options
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onChangeModel(self) -> None:
ids = self.selected_notes()
change_notetype_dialog(parent=self, note_ids=ids)
def createFilteredDeck(self) -> None:
search = self.current_search()
if self.mw.col.schedVer() != 1 and KeyboardModifiersPressed().alt:
2021-03-24 04:17:12 +01:00
aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search_2=search)
else:
2021-03-24 04:17:12 +01:00
aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search=search)
2013-05-03 10:52:46 +02:00
# Preview
######################################################################
2021-02-01 00:39:55 +01:00
def onTogglePreview(self) -> None:
if self._previewer:
self._previewer.close()
self._on_preview_closed()
2013-05-03 10:52:46 +02:00
else:
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
self._previewer.open()
2021-02-01 00:39:55 +01:00
def _renderPreview(self) -> None:
if self._previewer:
if self.singleCard:
self._previewer.render_card()
else:
self.onTogglePreview()
2021-02-01 00:39:55 +01:00
def _cleanup_preview(self) -> None:
if self._previewer:
2020-04-02 17:34:53 +02:00
self._previewer.cancel_timer()
self._previewer.close()
2021-02-01 00:39:55 +01:00
def _on_preview_closed(self) -> None:
if self.editor.web:
2021-04-13 20:29:59 +02:00
self.editor.web.eval(
"document.getElementById('previewButton').classList.remove('highlighted')"
)
self._previewer = None
# Card deletion
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
def delete_selected_notes(self) -> None:
# ensure deletion is not accidentally triggered when the user is focused
# in the editing screen or search bar
focus = self.focusWidget()
if focus != self.form.tableView:
return
nids = self.table.get_selected_note_ids()
# select the next card if there is one
self.focusTo = self.editor.currentField
self.table.to_next_row()
remove_notes(parent=self, note_ids=nids).run_in_background()
# legacy
deleteNotes = delete_selected_notes
# Deck change
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck
2019-12-23 01:34:10 +01:00
cids = self.table.get_selected_card_ids()
2019-12-23 01:34:10 +01:00
did = self.mw.col.db.scalar("select did from cards where id = ?", cids[0])
current = self.mw.col.decks.get(did)["name"]
ret = StudyDeck(
2019-12-23 01:34:10 +01:00
self.mw,
current=current,
2021-03-26 04:48:26 +01:00
accept=tr.browsing_move_cards(),
title=tr.browsing_change_deck(),
help=HelpPage.BROWSING,
2019-12-23 01:34:10 +01:00
parent=self,
)
if not ret.name:
return
did = self.col.decks.id(ret.name)
2021-03-12 07:27:40 +01:00
2021-04-06 06:36:13 +02:00
set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background()
# legacy
setDeck = set_deck_of_selected_cards
# Tags
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-03-05 11:47:51 +01:00
def add_tags_to_selected_notes(
2021-02-01 00:39:55 +01:00
self,
tags: Optional[str] = None,
) -> None:
2021-03-05 11:47:51 +01:00
"Shows prompt if tags not provided."
2021-03-26 04:48:26 +01:00
if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())):
return
add_tags_to_notes(
parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
2021-04-06 06:36:13 +02:00
).run_in_background(initiator=self)
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-03-05 11:47:51 +01:00
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
"Shows prompt if tags not provided."
if not (
2021-03-26 04:48:26 +01:00
tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete())
):
return
remove_tags_from_notes(
parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
2021-04-06 06:36:13 +02:00
).run_in_background(initiator=self)
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def _prompt_for_tags(self, prompt: str) -> Optional[str]:
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
(tags, ok) = getTag(self, self.col, prompt)
if not ok:
return None
else:
return tags
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@ensure_editor_saved
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def clear_unused_tags(self) -> None:
2021-04-06 06:36:13 +02:00
clear_unused_tags(parent=self).run_in_background()
2021-03-05 11:47:51 +01:00
addTags = add_tags_to_selected_notes
deleteTags = remove_tags_from_selected_notes
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
clearUnusedTags = clear_unused_tags
2021-03-05 11:47:51 +01:00
# Suspending
######################################################################
2021-03-31 10:05:44 +02:00
def _update_toggle_suspend_action(self) -> None:
is_suspended = bool(self.card and self.card.queue == QUEUE_TYPE_SUSPENDED)
self.form.actionToggle_Suspend.setChecked(is_suspended)
@skip_if_selection_is_empty
2021-03-31 10:05:44 +02:00
@ensure_editor_saved
def suspend_selected_cards(self, checked: bool) -> None:
2021-03-18 02:46:11 +01:00
cids = self.selected_cards()
2021-03-31 10:05:44 +02:00
if checked:
suspend_cards(parent=self, card_ids=cids).run_in_background()
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
else:
unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background()
2020-02-10 04:15:10 +01:00
# Exporting
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
2021-02-01 00:39:55 +01:00
def _on_export_notes(self) -> None:
2020-02-10 04:15:10 +01:00
cids = self.selectedNotesAsCards()
ExportDialog(self.mw, cids=list(cids))
2020-02-10 04:15:10 +01:00
# Flags & Marking
######################################################################
@skip_if_selection_is_empty
2021-03-18 03:06:45 +01:00
@ensure_editor_saved
def set_flag_of_selected_cards(self, flag: int) -> None:
if not self.card:
return
# flag needs toggling off?
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
if flag == self.card.user_flag():
flag = 0
2021-03-14 10:54:15 +01:00
2021-04-06 06:36:13 +02:00
set_card_flag(
parent=self, card_ids=self.selected_cards(), flag=flag
).run_in_background()
2021-03-18 03:06:45 +01:00
def _update_flags_menu(self) -> None:
flag = self.card and self.card.user_flag()
2018-11-12 03:10:50 +01:00
flag = flag or 0
for f in load_flags(self.col):
getattr(self.form, f.action).setChecked(flag == f.index)
qtMenuShortcutWorkaround(self.form.menuFlag)
2018-11-12 03:10:50 +01:00
def _update_flag_labels(self) -> None:
for flag in load_flags(self.col):
getattr(self.form, flag.action).setText(flag.label)
2021-03-31 10:05:44 +02:00
def toggle_mark_of_selected_notes(self, checked: bool) -> None:
if checked:
2021-03-18 03:06:45 +01:00
self.add_tags_to_selected_notes(tags=MARKED_TAG)
2021-03-31 10:05:44 +02:00
else:
self.remove_tags_from_selected_notes(tags=MARKED_TAG)
def _update_toggle_mark_action(self) -> None:
is_marked = bool(self.card and self.card.note().has_tag(MARKED_TAG))
self.form.actionToggle_Mark.setChecked(is_marked)
2021-03-18 02:46:11 +01:00
# Scheduling
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def reposition(self) -> None:
2021-03-18 02:46:11 +01:00
if self.card and self.card.queue != QUEUE_TYPE_NEW:
2021-03-26 04:48:26 +01:00
showInfo(tr.browsing_only_new_cards_can_be_repositioned(), parent=self)
return
if op := reposition_new_cards_dialog(
parent=self, card_ids=self.selected_cards()
):
op.run_in_background()
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def set_due_date(self) -> None:
if op := set_due_date_dialog(
parent=self,
2021-03-18 02:46:11 +01:00
card_ids=self.selected_cards(),
config_key=Config.String.SET_DUE_BROWSER,
):
op.run_in_background()
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def forget_cards(self) -> None:
forget_cards(
parent=self,
2021-03-18 02:46:11 +01:00
card_ids=self.selected_cards(),
).run_in_background()
# Edit: selection
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def selectNotes(self) -> None:
2021-03-18 02:46:11 +01:00
nids = self.selected_notes()
# clear the selection so we don't waste energy preserving it
self.table.clear_selection()
search = self.col.build_search_string(
SearchNode(nids=SearchNode.IdList(ids=nids))
)
2020-10-19 20:51:36 +02:00
self.search_for(search)
self.table.select_all()
# Hooks
######################################################################
def setupHooks(self) -> None:
gui_hooks.undo_state_did_change.append(self.on_undo_state_change)
gui_hooks.backend_will_block.append(self.table.on_backend_will_block)
gui_hooks.backend_did_block.append(self.table.on_backend_did_block)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_change)
def teardownHooks(self) -> None:
gui_hooks.undo_state_did_change.remove(self.on_undo_state_change)
gui_hooks.backend_will_block.remove(self.table.on_backend_will_block)
gui_hooks.backend_did_block.remove(self.table.on_backend_will_block)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
gui_hooks.focus_did_change.remove(self.on_focus_change)
# Undo
######################################################################
def undo(self) -> None:
2021-04-06 06:36:13 +02:00
undo(parent=self)
def redo(self) -> None:
redo(parent=self)
def on_undo_state_change(self, info: UndoActionsInfo) -> None:
self.form.actionUndo.setText(info.undo_text)
self.form.actionUndo.setEnabled(info.can_undo)
self.form.actionRedo.setText(info.redo_text)
self.form.actionRedo.setEnabled(info.can_redo)
self.form.actionRedo.setVisible(info.show_redo)
# Edit: replacing
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onFindReplace(self) -> None:
FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes())
# Edit: finding dupes
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onFindDupes(self) -> None:
from aqt.browser.find_duplicates import FindDuplicatesDialog
FindDuplicatesDialog(browser=self, mw=self.mw)
# Jumping
######################################################################
def has_previous_card(self) -> bool:
return self.table.has_previous()
def has_next_card(self) -> bool:
return self.table.has_next()
2021-02-01 00:39:55 +01:00
def onPreviousCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self.table.to_previous_row)
2021-02-01 00:39:55 +01:00
def onNextCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self.table.to_next_row)
2021-02-01 00:39:55 +01:00
def onFirstCard(self) -> None:
self.table.to_first_row()
2021-02-01 00:39:55 +01:00
def onLastCard(self) -> None:
self.table.to_last_row()
2021-02-01 00:39:55 +01:00
def onFind(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
self.form.searchEdit.setFocus()
self.form.searchEdit.lineEdit().selectAll()
2021-02-01 00:39:55 +01:00
def onNote(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
2013-01-29 01:49:04 +01:00
self.editor.web.setFocus()
self.editor.loadNote(focusTo=0)
2021-02-01 00:39:55 +01:00
def onCardList(self) -> None:
self.form.tableView.setFocus()