anki/qt/aqt/browser/sidebar/tree.py
Damien Elmes 45b790ec3c minor follow-up to #1257
No need for a separate argument, as we only want to scroll when
searching.
2021-06-29 11:53:10 +10:00

1125 lines
38 KiB
Python

# 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 enum import Enum, auto
from typing import Dict, Iterable, List, Optional, Tuple, cast
import aqt
from anki.collection import (
Config,
OpChanges,
OpChangesWithCount,
SearchJoiner,
SearchNode,
)
from anki.decks import DeckCollapseScope, DeckId, DeckTreeNode
from anki.models import NotetypeId
from anki.notes import Note
from anki.tags import TagTreeNode
from anki.types import assert_exhaustive
from aqt import colors, gui_hooks
from aqt.browser.sidebar import _want_right_border
from aqt.browser.sidebar.item import SidebarItem, SidebarItemType
from aqt.browser.sidebar.model import SidebarModel
from aqt.browser.sidebar.searchbar import SidebarSearchBar
from aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar
from aqt.clayout import CardLayout
from aqt.flags import load_flags
from aqt.models import Models
from aqt.operations import CollectionOp, QueryOp
from aqt.operations.deck import (
remove_decks,
rename_deck,
reparent_decks,
set_deck_collapsed,
)
from aqt.operations.tag import (
remove_tags_from_all_notes,
rename_tag,
reparent_tags,
set_tag_collapsed,
)
from aqt.qt import *
from aqt.theme import ColoredIcon, theme_manager
from aqt.utils import (
KeyboardModifiersPressed,
askUser,
getOnlyText,
showInfo,
showWarning,
tooltip,
tr,
)
class SidebarStage(Enum):
ROOT = auto()
SAVED_SEARCHES = auto()
TODAY = auto()
FLAGS = auto()
CARD_STATE = auto()
DECKS = auto()
NOTETYPES = auto()
TAGS = auto()
# fixme: we should have a top-level Sidebar class inheriting from QWidget that
# handles the treeview, search bar and so on. Currently the treeview embeds the
# search bar which is wrong, and the layout code is handled in browser.py instead
# of here
class SidebarTreeView(QTreeView):
def __init__(self, browser: aqt.browser.Browser) -> None:
super().__init__()
self.browser = browser
self.mw = browser.mw
self.col = self.mw.col
self.current_search: Optional[str] = None
self.valid_drop_types: Tuple[SidebarItemType, ...] = ()
self._refresh_needed = False
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore
self.setUniformRowHeights(True)
self.setHeaderHidden(True)
self.setIndentation(15)
self.setAutoExpandDelay(600)
self.setDragDropOverwriteMode(False)
self.setEditTriggers(QAbstractItemView.EditKeyPressed)
qconnect(self.expanded, self._on_expansion)
qconnect(self.collapsed, self._on_collapse)
# match window background color and tweak style
bgcolor = QPalette().window().color().name()
border = theme_manager.color(colors.MEDIUM_BORDER)
styles = [
"padding: 3px",
"padding-right: 0px",
"border: 0",
f"background: {bgcolor}",
]
if _want_right_border():
styles.append(f"border-right: 1px solid {border}")
self.setStyleSheet("QTreeView { %s }" % ";".join(styles))
# these do not really belong here, they should be in a higher-level class
self.toolbar = SidebarToolbar(self)
self.searchBar = SidebarSearchBar(self)
@property
def tool(self) -> SidebarTool:
return self._tool
@tool.setter
def tool(self, tool: SidebarTool) -> None:
self._tool = tool
if tool == SidebarTool.SEARCH:
selection_mode = QAbstractItemView.SingleSelection
drag_drop_mode = QAbstractItemView.NoDragDrop
double_click_expands = False
else:
selection_mode = QAbstractItemView.ExtendedSelection
drag_drop_mode = QAbstractItemView.InternalMove
double_click_expands = True
self.setSelectionMode(selection_mode)
self.setDragDropMode(drag_drop_mode)
self.setExpandsOnDoubleClick(double_click_expands)
def model(self) -> SidebarModel:
return cast(SidebarModel, super().model())
# Refreshing
###########################
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> None:
if changes.browser_sidebar and not handler is self:
self._refresh_needed = True
if focused:
self.refresh_if_needed()
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.refresh()
self._refresh_needed = False
def refresh(self, new_current: SidebarItem = None) -> None:
"Refresh list. No-op if sidebar is not visible."
if not self.isVisible():
return
if not new_current and self.model() and (idx := self.currentIndex()):
new_current = self.model().item_for_index(idx)
def on_done(root: SidebarItem) -> None:
# user may have closed browser
if sip.isdeleted(self):
return
# block repainting during refreshing to avoid flickering
self.setUpdatesEnabled(False)
model = SidebarModel(self, root)
self.setModel(model)
if self.current_search:
self.search_for(self.current_search)
else:
self._expand_where_necessary(model)
if new_current:
self.restore_current(new_current)
self.setUpdatesEnabled(True)
# needs to be set after changing model
qconnect(self.selectionModel().selectionChanged, self._on_selection_changed)
QueryOp(
parent=self.browser, op=lambda _: self._root_tree(), success=on_done
).run_in_background()
def restore_current(self, current: SidebarItem) -> None:
if current := self.find_item(current.has_same_id):
index = self.model().index_for_item(current)
self.selectionModel().setCurrentIndex(
index, QItemSelectionModel.SelectCurrent
)
self.scrollTo(index)
def find_item(
self,
is_target: Callable[[SidebarItem], bool],
parent: Optional[SidebarItem] = None,
) -> Optional[SidebarItem]:
def find_item_rec(parent: SidebarItem) -> Optional[SidebarItem]:
if is_target(parent):
return parent
for child in parent.children:
if item := find_item_rec(child):
return item
return None
return find_item_rec(parent or self.model().root)
def search_for(self, text: str) -> None:
self.showColumn(0)
if not text.strip():
self.current_search = None
self.refresh()
return
self.current_search = text
# start from a collapsed state, as it's faster
self.collapseAll()
self.setColumnHidden(0, not self.model().search(text))
self._expand_where_necessary(self.model(), searching=True)
def _expand_where_necessary(
self,
model: SidebarModel,
parent: Optional[QModelIndex] = None,
searching: bool = False,
) -> None:
scroll_to_first_match = searching
def expand_node(parent: QModelIndex) -> None:
nonlocal scroll_to_first_match
for row in range(model.rowCount(parent)):
idx = model.index(row, 0, parent)
if not idx.isValid():
continue
# descend into children first
expand_node(idx)
if item := model.item_for_index(idx):
if item.show_expanded(searching):
self.setExpanded(idx, True)
if item.is_highlighted() and scroll_to_first_match:
self.selectionModel().setCurrentIndex(
idx, QItemSelectionModel.SelectCurrent
)
self.scrollTo(idx)
scroll_to_first_match = False
expand_node(parent or QModelIndex())
def update_search(
self,
*terms: Union[str, SearchNode],
joiner: SearchJoiner = "AND",
) -> None:
"""Modify the current search string based on modifier keys, then refresh."""
mods = KeyboardModifiersPressed()
previous = SearchNode(parsable_text=self.browser.current_search())
current = self.mw.col.group_searches(*terms, joiner=joiner)
# if Alt pressed, invert
if mods.alt:
current = SearchNode(negated=current)
try:
if mods.control and mods.shift:
# If Ctrl+Shift, replace searches nodes of the same type.
search = self.col.replace_in_search_node(previous, current)
elif mods.control:
# If Ctrl, AND with previous
search = self.col.join_searches(previous, current, "AND")
elif mods.shift:
# If Shift, OR with previous
search = self.col.join_searches(previous, current, "OR")
else:
search = self.col.build_search_string(current)
except Exception as e:
showWarning(str(e))
else:
self.browser.search_for(search)
# Qt API
###########
def drawRow(
self, painter: QPainter, options: QStyleOptionViewItem, idx: QModelIndex
) -> None:
if self.current_search and (item := self.model().item_for_index(idx)):
if item.is_highlighted():
brush = QBrush(theme_manager.qcolor(colors.SUSPENDED_BG))
painter.save()
painter.fillRect(options.rect, brush)
painter.restore()
return super().drawRow(painter, options, idx)
def dropEvent(self, event: QDropEvent) -> None:
model = self.model()
target_item = model.item_for_index(self.indexAt(event.pos()))
if self.handle_drag_drop(self._selected_items(), target_item):
event.acceptProposedAction()
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
super().mouseReleaseEvent(event)
if self.tool == SidebarTool.SEARCH and event.button() == Qt.LeftButton:
if (index := self.currentIndex()) == self.indexAt(event.pos()):
self._on_search(index)
def keyPressEvent(self, event: QKeyEvent) -> None:
index = self.currentIndex()
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if not self.isPersistentEditorOpen(index):
self._on_search(index)
elif event.key() == Qt.Key_Delete:
self._on_delete_key(index)
else:
super().keyPressEvent(event)
# Slots
###########
def _on_selection_changed(self, _new: QItemSelection, _old: QItemSelection) -> None:
valid_drop_types = []
selected_items = self._selected_items()
selected_types = [item.item_type for item in selected_items]
# check if renaming is allowed
if all(item_type == SidebarItemType.DECK for item_type in selected_types):
valid_drop_types += [SidebarItemType.DECK, SidebarItemType.DECK_ROOT]
elif all(item_type == SidebarItemType.TAG for item_type in selected_types):
valid_drop_types += [SidebarItemType.TAG, SidebarItemType.TAG_ROOT]
# check if creating a saved search is allowed
if len(selected_items) == 1:
if (
selected_types[0] != SidebarItemType.SAVED_SEARCH
and selected_items[0].search_node is not None
):
valid_drop_types += [
SidebarItemType.SAVED_SEARCH_ROOT,
SidebarItemType.SAVED_SEARCH,
]
self.valid_drop_types = tuple(valid_drop_types)
def handle_drag_drop(self, sources: List[SidebarItem], target: SidebarItem) -> bool:
if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT):
return self._handle_drag_drop_decks(sources, target)
if target.item_type in (SidebarItemType.TAG, SidebarItemType.TAG_ROOT):
return self._handle_drag_drop_tags(sources, target)
if target.item_type in (
SidebarItemType.SAVED_SEARCH_ROOT,
SidebarItemType.SAVED_SEARCH,
):
return self._handle_drag_drop_saved_search(sources, target)
return False
def _handle_drag_drop_decks(
self, sources: List[SidebarItem], target: SidebarItem
) -> bool:
deck_ids = [
DeckId(source.id)
for source in sources
if source.item_type == SidebarItemType.DECK
]
if not deck_ids:
return False
new_parent = DeckId(target.id)
reparent_decks(
parent=self.browser, deck_ids=deck_ids, new_parent=new_parent
).run_in_background()
return True
def _handle_drag_drop_tags(
self, sources: List[SidebarItem], target: SidebarItem
) -> bool:
tags = [
source.full_name
for source in sources
if source.item_type == SidebarItemType.TAG
]
if not tags:
return False
if target.item_type == SidebarItemType.TAG_ROOT:
new_parent = ""
else:
new_parent = target.full_name
reparent_tags(
parent=self.browser, tags=tags, new_parent=new_parent
).run_in_background()
return True
def _handle_drag_drop_saved_search(
self, sources: List[SidebarItem], _target: SidebarItem
) -> bool:
if len(sources) != 1 or sources[0].search_node is None:
return False
self._save_search(
sources[0].name, self.col.build_search_string(sources[0].search_node)
)
return True
def _on_search(self, index: QModelIndex) -> None:
if item := self.model().item_for_index(index):
if search_node := item.search_node:
self.update_search(search_node)
def _on_rename(self, item: SidebarItem, text: str) -> bool:
new_name = text.replace('"', "")
if new_name and new_name != item.name:
if item.item_type == SidebarItemType.DECK:
self.rename_deck(item, new_name)
elif item.item_type == SidebarItemType.SAVED_SEARCH:
self.rename_saved_search(item, new_name)
elif item.item_type == SidebarItemType.TAG:
self.rename_tag(item, new_name)
elif item.item_type == SidebarItemType.FLAG:
self.rename_flag(item, new_name)
# renaming may be asynchronous so always return False
return False
def _on_delete_key(self, index: QModelIndex) -> None:
if item := self.model().item_for_index(index):
if self._enable_delete(item):
self._on_delete(item)
def _enable_delete(self, item: SidebarItem) -> bool:
return item.item_type.is_deletable() and all(
s.item_type == item.item_type for s in self._selected_items()
)
def _on_delete(self, item: SidebarItem) -> None:
if item.item_type == SidebarItemType.SAVED_SEARCH:
self.remove_saved_searches(item)
elif item.item_type == SidebarItemType.DECK:
self.delete_decks(item)
elif item.item_type == SidebarItemType.TAG:
self.remove_tags(item)
def _on_expansion(self, idx: QModelIndex) -> None:
if self.current_search:
return
if item := self.model().item_for_index(idx):
item.expanded = True
def _on_collapse(self, idx: QModelIndex) -> None:
if self.current_search:
return
if item := self.model().item_for_index(idx):
item.expanded = False
# Tree building
###########################
def _root_tree(self) -> SidebarItem:
root: Optional[SidebarItem] = None
for stage in SidebarStage:
if stage == SidebarStage.ROOT:
root = SidebarItem("", "", item_type=SidebarItemType.ROOT)
handled = gui_hooks.browser_will_build_tree(
False, root, stage, self.browser
)
if not handled:
self._build_stage(root, stage)
return root
def _build_stage(self, root: SidebarItem, stage: SidebarStage) -> None:
if stage is SidebarStage.SAVED_SEARCHES:
self._saved_searches_tree(root)
elif stage is SidebarStage.CARD_STATE:
self._card_state_tree(root)
elif stage is SidebarStage.TODAY:
self._today_tree(root)
elif stage is SidebarStage.FLAGS:
self._flags_tree(root)
elif stage is SidebarStage.DECKS:
self._deck_tree(root)
elif stage is SidebarStage.NOTETYPES:
self._notetype_tree(root)
elif stage is SidebarStage.TAGS:
self._tag_tree(root)
elif stage is SidebarStage.ROOT:
pass
else:
assert_exhaustive(stage)
def _section_root(
self,
*,
root: SidebarItem,
name: str,
icon: Union[str, ColoredIcon],
collapse_key: Config.Bool.Key.V,
type: Optional[SidebarItemType] = None,
) -> SidebarItem:
def update(expanded: bool) -> None:
CollectionOp(
self.browser,
lambda col: col.set_config_bool(collapse_key, not expanded),
).run_in_background(initiator=self)
top = SidebarItem(
name,
icon,
on_expanded=update,
expanded=not self.col.get_config_bool(collapse_key),
item_type=type,
)
root.add_child(top)
return top
# Tree: Saved Searches
###########################
def _saved_searches_tree(self, root: SidebarItem) -> None:
icon = ":/icons/heart.svg"
saved = self._get_saved_searches()
root = self._section_root(
root=root,
name=tr.browsing_sidebar_saved_searches(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_SAVED_SEARCHES,
type=SidebarItemType.SAVED_SEARCH_ROOT,
)
for name, filt in sorted(saved.items()):
item = SidebarItem(
name,
icon,
search_node=SearchNode(parsable_text=filt),
item_type=SidebarItemType.SAVED_SEARCH,
)
root.add_child(item)
# Tree: Today
###########################
def _today_tree(self, root: SidebarItem) -> None:
icon = ":/icons/clock.svg"
root = self._section_root(
root=root,
name=tr.browsing_today(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_TODAY,
type=SidebarItemType.TODAY_ROOT,
)
type = SidebarItemType.TODAY
root.add_simple(
name=tr.browsing_sidebar_due_today(),
icon=icon,
type=type,
search_node=SearchNode(due_on_day=0),
)
root.add_simple(
name=tr.browsing_added_today(),
icon=icon,
type=type,
search_node=SearchNode(added_in_days=1),
)
root.add_simple(
name=tr.browsing_edited_today(),
icon=icon,
type=type,
search_node=SearchNode(edited_in_days=1),
)
root.add_simple(
name=tr.browsing_studied_today(),
icon=icon,
type=type,
search_node=SearchNode(rated=SearchNode.Rated(days=1)),
)
root.add_simple(
name=tr.browsing_sidebar_first_review(),
icon=icon,
type=type,
search_node=SearchNode(introduced_in_days=1),
)
root.add_simple(
name=tr.browsing_sidebar_rescheduled(),
icon=icon,
type=type,
search_node=SearchNode(
rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_BY_RESCHEDULE)
),
)
root.add_simple(
name=tr.browsing_again_today(),
icon=icon,
type=type,
search_node=SearchNode(
rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN)
),
)
root.add_simple(
name=tr.browsing_sidebar_overdue(),
icon=icon,
type=type,
search_node=self.col.group_searches(
SearchNode(card_state=SearchNode.CARD_STATE_DUE),
SearchNode(negated=SearchNode(due_on_day=0)),
),
)
# Tree: Card State
###########################
def _card_state_tree(self, root: SidebarItem) -> None:
icon = ColoredIcon(path=":/icons/card-state.svg", color=colors.DISABLED)
root = self._section_root(
root=root,
name=tr.browsing_sidebar_card_state(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_CARD_STATE,
type=SidebarItemType.CARD_STATE_ROOT,
)
type = SidebarItemType.CARD_STATE
root.add_simple(
tr.actions_new(),
icon=icon.with_color(colors.NEW_COUNT),
type=type,
search_node=SearchNode(card_state=SearchNode.CARD_STATE_NEW),
)
root.add_simple(
name=tr.scheduling_learning(),
icon=icon.with_color(colors.LEARN_COUNT),
type=type,
search_node=SearchNode(card_state=SearchNode.CARD_STATE_LEARN),
)
root.add_simple(
name=tr.scheduling_review(),
icon=icon.with_color(colors.REVIEW_COUNT),
type=type,
search_node=SearchNode(card_state=SearchNode.CARD_STATE_REVIEW),
)
root.add_simple(
name=tr.browsing_suspended(),
icon=icon.with_color(colors.SUSPENDED_FG),
type=type,
search_node=SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED),
)
root.add_simple(
name=tr.browsing_buried(),
icon=icon.with_color(colors.BURIED_FG),
type=type,
search_node=SearchNode(card_state=SearchNode.CARD_STATE_BURIED),
)
# Tree: Flags
###########################
def _flags_tree(self, root: SidebarItem) -> None:
icon = ColoredIcon(path=":/icons/flag.svg", color=colors.DISABLED)
root = self._section_root(
root=root,
name=tr.browsing_sidebar_flags(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_FLAGS,
type=SidebarItemType.FLAG_ROOT,
)
root.search_node = SearchNode(flag=SearchNode.FLAG_ANY)
for flag in load_flags(self.col):
root.add_child(
SidebarItem(
name=flag.label,
icon=flag.icon,
search_node=flag.search_node,
item_type=SidebarItemType.FLAG,
id=flag.index,
)
)
root.add_simple(
tr.browsing_no_flag(),
icon=icon,
type=SidebarItemType.FLAG,
search_node=SearchNode(flag=SearchNode.FLAG_NONE),
)
# Tree: Tags
###########################
def _tag_tree(self, root: SidebarItem) -> None:
icon = ":/icons/tag-outline.svg"
icon_off = ":/icons/tag-off-outline.svg"
def render(
root: SidebarItem, nodes: Iterable[TagTreeNode], head: str = ""
) -> None:
def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]:
full_name = head + node.name
return lambda expanded: set_tag_collapsed(
parent=self, tag=full_name, collapsed=not expanded
).run_in_background(initiator=self)
for node in nodes:
item = SidebarItem(
name=node.name,
icon=icon,
search_node=SearchNode(tag=head + node.name),
on_expanded=toggle_expand(node),
expanded=not node.collapsed,
item_type=SidebarItemType.TAG,
name_prefix=head,
)
root.add_child(item)
newhead = f"{head + node.name}::"
render(item, node.children, newhead)
tree = self.col.tags.tree()
root = self._section_root(
root=root,
name=tr.browsing_sidebar_tags(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_TAGS,
type=SidebarItemType.TAG_ROOT,
)
root.search_node = SearchNode(tag="_*")
root.add_simple(
name=tr.browsing_sidebar_untagged(),
icon=icon_off,
type=SidebarItemType.TAG_NONE,
search_node=SearchNode(negated=SearchNode(tag="_*")),
)
render(root, tree.children)
# Tree: Decks
###########################
def _deck_tree(self, root: SidebarItem) -> None:
icon = ":/icons/deck.svg"
def render(
root: SidebarItem, nodes: Iterable[DeckTreeNode], head: str = ""
) -> None:
def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]:
return lambda expanded: set_deck_collapsed(
parent=self,
deck_id=DeckId(node.deck_id),
collapsed=not expanded,
scope=DeckCollapseScope.BROWSER,
).run_in_background(
initiator=self,
)
for node in nodes:
item = SidebarItem(
name=node.name,
icon=icon,
search_node=SearchNode(deck=head + node.name),
on_expanded=toggle_expand(node),
expanded=not node.collapsed,
item_type=SidebarItemType.DECK,
id=node.deck_id,
name_prefix=head,
)
root.add_child(item)
newhead = f"{head + node.name}::"
render(item, node.children, newhead)
tree = self.col.decks.deck_tree()
root = self._section_root(
root=root,
name=tr.browsing_sidebar_decks(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_DECKS,
type=SidebarItemType.DECK_ROOT,
)
root.search_node = SearchNode(deck="_*")
current = root.add_simple(
name=tr.browsing_current_deck(),
icon=icon,
type=SidebarItemType.DECK_CURRENT,
search_node=SearchNode(deck="current"),
)
current.id = self.mw.col.decks.selected()
render(root, tree.children)
# Tree: Notetypes
###########################
def _notetype_tree(self, root: SidebarItem) -> None:
icon = ":/icons/notetype.svg"
root = self._section_root(
root=root,
name=tr.browsing_sidebar_notetypes(),
icon=icon,
collapse_key=Config.Bool.COLLAPSE_NOTETYPES,
type=SidebarItemType.NOTETYPE_ROOT,
)
root.search_node = SearchNode(note="_*")
for nt in sorted(self.col.models.all(), key=lambda nt: nt["name"].lower()):
item = SidebarItem(
nt["name"],
icon,
search_node=SearchNode(note=nt["name"]),
item_type=SidebarItemType.NOTETYPE,
id=nt["id"],
)
for c, tmpl in enumerate(nt["tmpls"]):
child = SidebarItem(
tmpl["name"],
icon,
search_node=self.col.group_searches(
SearchNode(note=nt["name"]), SearchNode(template=c)
),
item_type=SidebarItemType.NOTETYPE_TEMPLATE,
name_prefix=f"{nt['name']}::",
id=tmpl["ord"],
)
item.add_child(child)
root.add_child(item)
# Context menu
###########################
def onContextMenu(self, point: QPoint) -> None:
index: QModelIndex = self.indexAt(point)
item = self.model().item_for_index(index)
if item and self.selectionModel().isSelected(index):
self.show_context_menu(item, index)
def show_context_menu(self, item: SidebarItem, index: QModelIndex) -> None:
menu = QMenu()
self._maybe_add_type_specific_actions(menu, item)
self._maybe_add_delete_action(menu, item, index)
self._maybe_add_rename_action(menu, item, index)
self._maybe_add_search_actions(menu)
self._maybe_add_tree_actions(menu)
gui_hooks.browser_sidebar_will_show_context_menu(self, menu, item, index)
if menu.children():
menu.exec_(QCursor.pos())
def _maybe_add_type_specific_actions(self, menu: QMenu, item: SidebarItem) -> None:
if item.item_type in (SidebarItemType.NOTETYPE, SidebarItemType.NOTETYPE_ROOT):
menu.addAction(
tr.browsing_manage_note_types(), lambda: self.manage_notetype(item)
)
elif item.item_type == SidebarItemType.NOTETYPE_TEMPLATE:
menu.addAction(tr.notetypes_cards(), lambda: self.manage_template(item))
elif item.item_type == SidebarItemType.SAVED_SEARCH_ROOT:
menu.addAction(
tr.browsing_sidebar_save_current_search(), self.save_current_search
)
elif item.item_type == SidebarItemType.SAVED_SEARCH:
menu.addAction(
tr.browsing_update_saved_search(),
lambda: self.update_saved_search(item),
)
def _maybe_add_delete_action(
self, menu: QMenu, item: SidebarItem, index: QModelIndex
) -> None:
if self._enable_delete(item):
menu.addAction(tr.actions_delete(), lambda: self._on_delete(item))
def _maybe_add_rename_action(
self, menu: QMenu, item: SidebarItem, index: QModelIndex
) -> None:
if item.item_type.is_editable() and len(self._selected_items()) == 1:
menu.addAction(tr.actions_rename(), lambda: self.edit(index))
def _maybe_add_search_actions(self, menu: QMenu) -> None:
nodes = [
item.search_node for item in self._selected_items() if item.search_node
]
if not nodes:
return
menu.addSeparator()
if len(nodes) == 1:
menu.addAction(tr.actions_search(), lambda: self.update_search(*nodes))
return
sub_menu = menu.addMenu(tr.actions_search())
sub_menu.addAction(
tr.actions_all_selected(), lambda: self.update_search(*nodes)
)
sub_menu.addAction(
tr.actions_any_selected(),
lambda: self.update_search(*nodes, joiner="OR"),
)
def _maybe_add_tree_actions(self, menu: QMenu) -> None:
def set_expanded(expanded: bool) -> None:
for index in self.selectedIndexes():
self.setExpanded(index, expanded)
def set_children_expanded(expanded: bool) -> None:
for index in self.selectedIndexes():
self.setExpanded(index, True)
for row in range(self.model().rowCount(index)):
self.setExpanded(self.model().index(row, 0, index), expanded)
if self.current_search:
return
selected_items = self._selected_items()
if not any(item.children for item in selected_items):
return
menu.addSeparator()
if any(not item.expanded for item in selected_items if item.children):
menu.addAction(tr.browsing_sidebar_expand(), lambda: set_expanded(True))
if any(item.expanded for item in selected_items if item.children):
menu.addAction(tr.browsing_sidebar_collapse(), lambda: set_expanded(False))
if any(
not c.expanded for i in selected_items for c in i.children if c.children
):
menu.addAction(
tr.browsing_sidebar_expand_children(),
lambda: set_children_expanded(True),
)
if any(c.expanded for i in selected_items for c in i.children if c.children):
menu.addAction(
tr.browsing_sidebar_collapse_children(),
lambda: set_children_expanded(False),
)
# Flags
###########################
def rename_flag(self, item: SidebarItem, new_name: str) -> None:
labels = self.col.get_config("flagLabels", {})
labels[str(item.id)] = new_name
self.col.set_config("flagLabels", labels)
item.name = new_name
self.browser._update_flag_labels()
self.refresh()
# Decks
###########################
def rename_deck(self, item: SidebarItem, new_name: str) -> None:
if not new_name:
return
# update UI immediately, to avoid redraw
item.name = new_name
full_name = item.name_prefix + new_name
deck_id = DeckId(item.id)
def after_fetch(name: str) -> None:
if full_name == name:
return
rename_deck(
parent=self,
deck_id=deck_id,
new_name=full_name,
).run_in_background()
QueryOp(
parent=self.browser,
op=lambda col: col.decks.name(deck_id),
success=after_fetch,
).run_in_background()
def delete_decks(self, _item: SidebarItem) -> None:
remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background()
# Tags
###########################
def remove_tags(self, item: SidebarItem) -> None:
tags = self.mw.col.tags.join(self._selected_tags())
item.name = "..."
remove_tags_from_all_notes(
parent=self.browser, space_separated_tags=tags
).run_in_background()
def rename_tag(self, item: SidebarItem, new_name: str) -> None:
if not new_name or new_name == item.name:
return
old_name = item.name
old_full_name = item.full_name
new_full_name = item.name_prefix + new_name
item.name = new_name
item.full_name = new_full_name
def success(out: OpChangesWithCount) -> None:
if out.count:
tooltip(tr.browsing_notes_updated(count=out.count), parent=self)
else:
# revert renaming of sidebar item
item.full_name = old_full_name
item.name = old_name
showInfo(tr.browsing_tag_rename_warning_empty(), parent=self)
rename_tag(
parent=self.browser,
current_name=old_full_name,
new_name=new_full_name,
).success(success).run_in_background()
# Saved searches
####################################
_saved_searches_key = "savedFilters"
def _get_saved_searches(self) -> Dict[str, str]:
return self.col.get_config(self._saved_searches_key, {})
def _set_saved_searches(self, searches: Dict[str, str]) -> None:
self.col.set_config(self._saved_searches_key, searches)
def _get_current_search(self) -> Optional[str]:
try:
return self.col.build_search_string(self.browser.current_search())
except Exception as e:
showWarning(str(e))
return None
def _save_search(self, name: str, search: str, update: bool = False) -> None:
conf = self._get_saved_searches()
if not update and name in conf:
if conf[name] == search:
# nothing to do
return
if not askUser(tr.browsing_confirm_saved_search_overwrite(name=name)):
# don't overwrite existing saved search
return
conf[name] = search
self._set_saved_searches(conf)
self.refresh(SidebarItem(name, "", item_type=SidebarItemType.SAVED_SEARCH))
def remove_saved_searches(self, _item: SidebarItem) -> None:
selected = self._selected_saved_searches()
conf = self._get_saved_searches()
for name in selected:
del conf[name]
self._set_saved_searches(conf)
self.refresh()
def rename_saved_search(self, item: SidebarItem, new_name: str) -> None:
old_name = item.name
conf = self._get_saved_searches()
try:
filt = conf[old_name]
except KeyError:
return
if new_name in conf and not askUser(
tr.browsing_confirm_saved_search_overwrite(name=new_name)
):
return
conf[new_name] = filt
del conf[old_name]
self._set_saved_searches(conf)
item.name = new_name
self.refresh()
def save_current_search(self) -> None:
if (search := self._get_current_search()) is None:
return
name = getOnlyText(tr.browsing_please_give_your_filter_a_name())
if not name:
return
self._save_search(name, search)
def update_saved_search(self, item: SidebarItem) -> None:
if (search := self._get_current_search()) is None:
return
self._save_search(item.name, search, update=True)
# Notetypes and templates
####################################
def manage_notetype(self, item: SidebarItem) -> None:
Models(
self.mw,
parent=self.browser,
fromMain=True,
selected_notetype_id=NotetypeId(item.id),
)
def manage_template(self, item: SidebarItem) -> None:
note = Note(self.col, self.col.models.get(NotetypeId(item._parent_item.id)))
CardLayout(self.mw, note, ord=item.id, parent=self, fill_empty=True)
# Helpers
####################################
def _selected_items(self) -> List[SidebarItem]:
return [self.model().item_for_index(idx) for idx in self.selectedIndexes()]
def _selected_decks(self) -> List[DeckId]:
return [
DeckId(item.id)
for item in self._selected_items()
if item.item_type == SidebarItemType.DECK
]
def _selected_saved_searches(self) -> List[str]:
return [
item.name
for item in self._selected_items()
if item.item_type == SidebarItemType.SAVED_SEARCH
]
def _selected_tags(self) -> List[str]:
return [
item.full_name
for item in self._selected_items()
if item.item_type == SidebarItemType.TAG
]