Merge pull request #1102 from RumovZ/more-browser

More browser fixes and features
This commit is contained in:
Damien Elmes 2021-03-30 19:27:55 +10:00 committed by GitHub
commit 325920aa23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 247 additions and 112 deletions

View File

@ -12,6 +12,8 @@ browsing-browser-appearance = Browser Appearance
browsing-browser-options = Browser Options
browsing-buried = Buried
browsing-card = Card
# Exactly one character representing 'Cards'; should differ from browsing-note-initial.
browsing-card-initial = C
browsing-card-list = Card List
browsing-card-state = Card State
browsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck.
@ -63,6 +65,8 @@ browsing-new = (new)
browsing-new-note-type = New note type:
browsing-no-flag = No Flag
browsing-note = Note
# Exactly one character representing 'Notes'; should differ from browsing-card-initial.
browsing-note-initial = N
browsing-notes-tagged = Notes tagged.
browsing-nothing = Nothing
browsing-only-new-cards-can-be-repositioned = Only new cards can be repositioned.

View File

@ -497,8 +497,22 @@ class Collection:
reverse: bool = False,
) -> Sequence[CardId]:
"""Return card ids matching the provided search.
To programmatically construct a search string, see .build_search_string().
To define a sort order, see _build_sort_mode().
If order=True, use the sort order stored in the collection config
If order=False, do no ordering
If order is a string, that text is added after 'order by' in the sql statement.
You must add ' asc' or ' desc' to the order, as Anki will replace asc with
desc and vice versa when reverse is set in the collection config, eg
order="c.ivl asc, c.due desc".
If order is a BuiltinSort.Kind value, sort using that builtin sort, eg
col.find_cards("", order=BuiltinSort.Kind.CARD_DUE)
The reverse argument only applies when a BuiltinSort.Kind is provided;
otherwise the collection config defines whether reverse is set or not.
"""
mode = _build_sort_mode(order, reverse)
return cast(
@ -512,8 +526,9 @@ class Collection:
reverse: bool = False,
) -> Sequence[NoteId]:
"""Return note ids matching the provided search.
To programmatically construct a search string, see .build_search_string().
To define a sort order, see _build_sort_mode().
The order parameter is documented in .find_cards().
"""
mode = _build_sort_mode(order, reverse)
return cast(
@ -1072,22 +1087,6 @@ def _build_sort_mode(
order: Union[bool, str, BuiltinSort.Kind.V],
reverse: bool,
) -> _pb.SortOrder:
"""Return a SortOrder object for use in find_cards() or find_notes().
If order=True, use the sort order stored in the collection config
If order=False, do no ordering
If order is a string, that text is added after 'order by' in the sql statement.
You must add ' asc' or ' desc' to the order, as Anki will replace asc with
desc and vice versa when reverse is set in the collection config, eg
order="c.ivl asc, c.due desc".
If order is a BuiltinSort.Kind value, sort using that builtin sort, eg
col.find_cards("", order=BuiltinSort.Kind.CARD_DUE)
The reverse argument only applies when a BuiltinSort.Kind is provided;
otherwise the collection config defines whether reverse is set or not.
"""
if isinstance(order, str):
return _pb.SortOrder(custom=order)
elif isinstance(order, bool):

View File

@ -37,6 +37,7 @@ from aqt.scheduling_ops import (
unsuspend_cards,
)
from aqt.sidebar import SidebarTreeView
from aqt.switch import Switch
from aqt.table import Table
from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes
from aqt.utils import (
@ -329,9 +330,9 @@ class Browser(QMainWindow):
selected = self.table.len_selection()
cur = self.table.len()
tr_title = (
tr.browsing_window_title
if self.table.is_card_state()
else tr.browsing_window_title_notes
tr.browsing_window_title_notes
if self.table.is_notes_mode()
else tr.browsing_window_title
)
self.setWindowTitle(
without_unicode_isolation(tr_title(total=cur, selected=selected))
@ -374,10 +375,11 @@ class Browser(QMainWindow):
def setup_table(self) -> None:
self.table = Table(self)
self.form.radio_cards.setChecked(self.table.is_card_state())
self.form.radio_notes.setChecked(not self.table.is_card_state())
self.table.set_view(self.form.tableView)
qconnect(self.form.radio_cards.toggled, self.on_table_state_changed)
switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial())
switch.setChecked(self.table.is_notes_mode())
qconnect(switch.toggled, self.on_table_state_changed)
self.form.gridLayout.addWidget(switch, 0, 0)
def setupEditor(self) -> None:
def add_preview_button(leftbuttons: List[str], editor: Editor) -> None:
@ -430,10 +432,10 @@ class Browser(QMainWindow):
self._update_flags_menu()
gui_hooks.browser_did_change_row(self)
@ensure_editor_saved_on_trigger
def on_table_state_changed(self) -> None:
@ensure_editor_saved
def on_table_state_changed(self, checked: bool) -> None:
self.mw.progress.start()
self.table.toggle_state(self.form.radio_cards.isChecked(), self._lastSearchTxt)
self.table.toggle_state(checked, self._lastSearchTxt)
self.mw.progress.finish()
# Sidebar

View File

@ -91,7 +91,7 @@
<property name="verticalSpacing">
<number>0</number>
</property>
<item row="0" column="0">
<item row="0" column="1">
<widget class="QComboBox" name="searchEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
@ -109,30 +109,6 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="view_state" stretch="0,1">
<property name="bottomMargin">
<number>5</number>
</property>
<item>
<widget class="QRadioButton" name="radio_cards">
<property name="text">
<string>qt_accel_cards</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radio_notes">
<property name="text">
<string>qt_accel_notes</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableView" name="tableView">
<property name="sizePolicy">

115
qt/aqt/switch.py Normal file
View File

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from aqt import colors
from aqt.qt import *
from aqt.theme import theme_manager
class Switch(QAbstractButton):
"""A horizontal slider to toggle between two states which can be denoted by short strings.
The left state is the default and corresponds to isChecked=False.
"""
_margin: int = 2
def __init__(
self,
radius: int = 10,
left_label: str = "",
right_label: str = "",
parent: QWidget = None,
) -> None:
super().__init__(parent=parent)
self.setCheckable(True)
super().setChecked(False)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._left_label = left_label
self._right_label = right_label
self._path_radius = radius
self._knob_radius = radius - self._margin
self._left_position = self._position = self._path_radius + self._margin
self._right_position = 3 * self._path_radius + self._margin
@pyqtProperty(int) # type: ignore
def position(self) -> int:
return self._position
@position.setter # type: ignore
def position(self, position: int) -> None:
self._position = position
self.update()
@property
def start_position(self) -> int:
return self._left_position if self.isChecked() else self._right_position
@property
def end_position(self) -> int:
return self._right_position if self.isChecked() else self._left_position
@property
def label(self) -> str:
return self._right_label if self.isChecked() else self._left_label
def sizeHint(self) -> QSize:
return QSize(
4 * self._path_radius + 2 * self._margin,
2 * self._path_radius + 2 * self._margin,
)
def setChecked(self, checked: bool) -> None:
super().setChecked(checked)
self._position = self.end_position
self.update()
def paintEvent(self, _event: QPaintEvent) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(Qt.NoPen)
self._paint_path(painter)
self._paint_knob(painter)
self._paint_label(painter)
def _paint_path(self, painter: QPainter) -> None:
painter.setBrush(QBrush(theme_manager.qcolor(colors.FRAME_BG)))
rectangle = QRectF(
self._margin,
self._margin,
self.width() - 2 * self._margin,
self.height() - 2 * self._margin,
)
painter.drawRoundedRect(rectangle, self._path_radius, self._path_radius)
def _current_knob_rectangle(self) -> QRectF:
return QRectF(
self.position - self._knob_radius, # type: ignore
2 * self._margin,
2 * self._knob_radius,
2 * self._knob_radius,
)
def _paint_knob(self, painter: QPainter) -> None:
painter.setBrush(QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG)))
painter.drawEllipse(self._current_knob_rectangle())
def _paint_label(self, painter: QPainter) -> None:
painter.setPen(QColor("white"))
font = painter.font()
font.setPixelSize(int(1.5 * self._knob_radius))
painter.setFont(font)
painter.drawText(self._current_knob_rectangle(), Qt.AlignCenter, self.label)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
super().mouseReleaseEvent(event)
if event.button() == Qt.LeftButton:
animation = QPropertyAnimation(self, b"position", self)
animation.setDuration(100)
animation.setStartValue(self.start_position)
animation.setEndValue(self.end_position)
animation.start()
def enterEvent(self, event: QEvent) -> None:
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(event)

View File

@ -48,8 +48,7 @@ class SearchContext:
search: str
browser: aqt.browser.Browser
order: Union[bool, str] = True
# if set, provided card ids will be used instead of the regular search
# fixme: legacy support for card_ids?
# if set, provided ids will be used instead of the regular search
ids: Optional[Sequence[ItemId]] = None
@ -94,8 +93,8 @@ class Table:
def has_next(self) -> bool:
return self.has_current() and self._current().row() < self.len() - 1
def is_card_state(self) -> bool:
return self._state.is_card_state()
def is_notes_mode(self) -> bool:
return self._state.is_notes_mode()
# Get objects
@ -194,15 +193,15 @@ class Table:
self._model.search(SearchContext(search=txt, browser=self.browser))
self._restore_selection(self._intersected_selection)
def toggle_state(self, is_card_state: bool, last_search: str) -> None:
if is_card_state == self.is_card_state():
def toggle_state(self, is_notes_mode: bool, last_search: str) -> None:
if is_notes_mode == self.is_notes_mode():
return
self._save_selection()
self._state = self._model.toggle_state(
SearchContext(search=last_search, browser=self.browser)
)
self.col.set_config_bool(
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, not self.is_card_state()
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode()
)
self._set_sort_indicator()
self._set_column_sizes()
@ -328,14 +327,14 @@ class Table:
def _on_context_menu(self, _point: QPoint) -> None:
menu = QMenu()
if self.is_card_state():
main = self.browser.form.menu_Cards
other = self.browser.form.menu_Notes
other_name = tr.qt_accel_notes()
else:
if self.is_notes_mode():
main = self.browser.form.menu_Notes
other = self.browser.form.menu_Cards
other_name = tr.qt_accel_cards()
else:
main = self.browser.form.menu_Cards
other = self.browser.form.menu_Notes
other_name = tr.qt_accel_notes()
for action in main.actions():
menu.addAction(action)
menu.addSeparator()
@ -417,7 +416,8 @@ class Table:
current = current or rows[0]
self._select_rows(rows)
self._set_current(current)
self._scroll_to_row(current)
# editor may pop up and hide the row later on
QTimer.singleShot(100, lambda: self._scroll_to_row(current))
if self.len_selection() == 0:
# no row change will fire
self.browser.onRowChanged(QItemSelection(), QItemSelection())
@ -431,7 +431,7 @@ class Table:
if rows:
if len(rows) < self.SELECTION_LIMIT:
return rows
if current in rows:
if current and current in rows:
return [current]
return rows[0:1]
return [current if current else 0]
@ -453,17 +453,19 @@ class Table:
selected_rows = self._model.get_item_rows(
self._state.get_new_items(self._selected_items)
)
current_row = self._current_item and self._model.get_item_row(
self._state.get_new_item(self._current_item)
)
current_row = None
if self._current_item:
if new_current := self._state.get_new_items([self._current_item]):
current_row = self._model.get_item_row(new_current[0])
return selected_rows, current_row
# Move
def _scroll_to_row(self, row: int) -> None:
"""Scroll vertically to row."""
position = self._view.rowViewportPosition(row)
visible = 0 <= position < self._view.viewport().height()
top_border = self._view.rowViewportPosition(row)
bottom_border = top_border + self._view.rowHeight(0)
visible = top_border >= 0 and bottom_border < self._view.viewport().height()
if not visible:
horizontal = self._view.horizontalScrollBar().value()
self._view.scrollTo(self._model.index(row, 0), self._view.PositionAtCenter)
@ -527,9 +529,9 @@ class ItemState(ABC):
def __init__(self, col: Collection) -> None:
self.col = col
def is_card_state(self) -> bool:
"""Return True if the state is a CardState."""
return isinstance(self, CardState)
def is_notes_mode(self) -> bool:
"""Return True if the state is a NoteState."""
return isinstance(self, NoteState)
# Stateless Helpers
@ -543,6 +545,8 @@ class ItemState(ABC):
# Columns and sorting
# abstractproperty is deprecated but used due to mypy limitations
# (https://github.com/python/mypy/issues/1362)
@abstractproperty
def columns(self) -> List[Tuple[str, str]]:
"""Return all for the state available columns."""
@ -605,15 +609,9 @@ class ItemState(ABC):
def toggle_state(self) -> ItemState:
"""Return an instance of the other state."""
@abstractmethod
def get_new_item(self, old_item: ItemId) -> ItemId:
"""Given an id from the other state, return the corresponding id for
this state."""
@abstractmethod
def get_new_items(self, old_items: Sequence[ItemId]) -> ItemList:
"""Given a list of ids from the other state, return the corresponding
ids for this state."""
"""Given a list of ids from the other state, return the corresponding ids for this state."""
class CardState(ItemState):
@ -705,9 +703,6 @@ class CardState(ItemState):
def toggle_state(self) -> NoteState:
return NoteState(self.col)
def get_new_item(self, old_item: ItemId) -> CardId:
return super().card_ids_from_note_ids([old_item])[0]
def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[CardId]:
return super().card_ids_from_note_ids(old_items)
@ -725,11 +720,13 @@ class NoteState(ItemState):
def _load_columns(self) -> None:
self._columns = [
("note", tr.browsing_note()),
("noteCards", tr.qt_accel_cards().replace("&", "")),
("noteCards", tr.editing_cards()),
("noteCrt", tr.browsing_created()),
("noteEase", tr.browsing_average_ease()),
("noteFld", tr.browsing_sort_field()),
("noteLapses", tr.scheduling_lapses()),
("noteMod", tr.search_note_modified()),
("noteReps", tr.scheduling_reviews()),
("noteTags", tr.editing_tags()),
]
self._columns.sort(key=itemgetter(1))
@ -793,9 +790,6 @@ class NoteState(ItemState):
def toggle_state(self) -> CardState:
return CardState(self.col)
def get_new_item(self, old_item: ItemId) -> NoteId:
return super().note_ids_from_card_ids([old_item])[0]
def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[NoteId]:
return super().note_ids_from_card_ids(old_items)
@ -811,6 +805,8 @@ class Cell:
class CellRow:
is_deleted: bool = False
def __init__(
self,
cells: Generator[Tuple[str, bool], None, None],
@ -842,7 +838,9 @@ class CellRow:
@staticmethod
def deleted(length: int) -> CellRow:
return CellRow.generic(length, tr.browsing_row_deleted())
row = CellRow.generic(length, tr.browsing_row_deleted())
row.is_deleted = True
return row
def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]:
@ -896,7 +894,7 @@ class DataModel(QAbstractTableModel):
self._rows[item] = self._fetch_row_from_backend(item)
return self._rows[item]
def _fetch_row_from_backend(self, item: int) -> CellRow:
def _fetch_row_from_backend(self, item: ItemId) -> CellRow:
try:
row = CellRow(*self.col.browser_row_for_id(item))
except NotFoundError:
@ -904,8 +902,9 @@ class DataModel(QAbstractTableModel):
except Exception as e:
return CellRow.generic(self.len_columns(), str(e))
# fixme: hook needs state
gui_hooks.browser_did_fetch_row(item, row, self._state.active_columns)
gui_hooks.browser_did_fetch_row(
item, self._state.is_notes_mode(), row, self._state.active_columns
)
return row
# Reset
@ -1113,6 +1112,8 @@ class DataModel(QAbstractTableModel):
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
if self.get_row(index).is_deleted:
return Qt.ItemFlags(Qt.NoItemFlags)
return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable)

View File

@ -406,7 +406,12 @@ hooks = [
),
Hook(
name="browser_did_fetch_row",
args=["card_id: int", "row: aqt.table.CellRow", "columns: Sequence[str]"],
args=[
"card_or_note_id: aqt.table.ItemId",
"is_note: bool",
"row: aqt.table.CellRow",
"columns: Sequence[str]",
],
doc="""Allows you to add or modify content to a row in the browser.
You can mutate the row object to change what is displayed. Any columns the

View File

@ -810,18 +810,20 @@ message SortOrder {
NOTE_CARDS = 0;
NOTE_CREATION = 1;
NOTE_EASE = 2;
NOTE_MOD = 3;
NOTE_FIELD = 4;
NOTE_TAGS = 5;
NOTETYPE = 6;
CARD_MOD = 7;
CARD_REPS = 8;
CARD_DUE = 9;
CARD_EASE = 10;
CARD_LAPSES = 11;
CARD_INTERVAL = 12;
CARD_DECK = 13;
CARD_TEMPLATE = 14;
NOTE_FIELD = 3;
NOTE_LAPSES = 4;
NOTE_MOD = 5;
NOTE_REPS = 6;
NOTE_TAGS = 7;
NOTETYPE = 8;
CARD_MOD = 9;
CARD_REPS = 10;
CARD_DUE = 11;
CARD_EASE = 12;
CARD_LAPSES = 13;
CARD_INTERVAL = 14;
CARD_DECK = 15;
CARD_TEMPLATE = 16;
}
Kind kind = 1;
bool reverse = 2;

View File

@ -100,8 +100,10 @@ impl From<SortKindProto> for SortKind {
SortKindProto::NoteCards => SortKind::NoteCards,
SortKindProto::NoteCreation => SortKind::NoteCreation,
SortKindProto::NoteEase => SortKind::NoteEase,
SortKindProto::NoteLapses => SortKind::NoteLapses,
SortKindProto::NoteMod => SortKind::NoteMod,
SortKindProto::NoteField => SortKind::NoteField,
SortKindProto::NoteReps => SortKind::NoteReps,
SortKindProto::NoteTags => SortKind::NoteTags,
SortKindProto::Notetype => SortKind::Notetype,
SortKindProto::CardMod => SortKind::CardMod,

View File

@ -428,7 +428,9 @@ impl RowContext for NoteRowContext<'_> {
"noteCrt" => self.note_creation_str(),
"noteEase" => self.note_ease_str(),
"noteFld" => self.note_field_str(),
"noteLapses" => self.cards.iter().map(|c| c.lapses).sum::<u32>().to_string(),
"noteMod" => self.note.mtime.date_string(),
"noteReps" => self.cards.iter().map(|c| c.reps).sum::<u32>().to_string(),
"noteTags" => self.note.tags.join(" "),
_ => "".to_string(),
})

View File

@ -275,9 +275,11 @@ pub enum SortKind {
#[serde(rename = "noteCrt")]
NoteCreation,
NoteEase,
NoteLapses,
NoteMod,
#[serde(rename = "noteFld")]
NoteField,
NoteReps,
#[serde(rename = "note")]
Notetype,
NoteTags,

View File

@ -91,10 +91,12 @@ impl SortKind {
SortKind::NoteCards
| SortKind::NoteCreation
| SortKind::NoteEase
| SortKind::NoteMod
| SortKind::NoteField
| SortKind::Notetype
| SortKind::NoteTags => RequiredTable::Notes,
| SortKind::NoteLapses
| SortKind::NoteMod
| SortKind::NoteReps
| SortKind::NoteTags
| SortKind::Notetype => RequiredTable::Notes,
SortKind::CardTemplate => RequiredTable::CardsAndNotes,
SortKind::CardMod
| SortKind::CardReps
@ -250,11 +252,12 @@ fn card_order_from_sortkind(kind: SortKind) -> Cow<'static, str> {
fn note_order_from_sortkind(kind: SortKind) -> Cow<'static, str> {
match kind {
SortKind::NoteCards => "(select pos from sort_order where nid = n.id) asc".into(),
SortKind::NoteCards | SortKind::NoteEase | SortKind::NoteLapses | SortKind::NoteReps => {
"(select pos from sort_order where nid = n.id) asc".into()
}
SortKind::NoteCreation => "n.id asc".into(),
SortKind::NoteEase => "(select pos from sort_order where nid = n.id) asc".into(),
SortKind::NoteMod => "n.mod asc".into(),
SortKind::NoteField => "n.sfld collate nocase asc".into(),
SortKind::NoteMod => "n.mod asc".into(),
SortKind::NoteTags => "n.tags asc".into(),
SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
_ => "".into(),
@ -265,10 +268,12 @@ fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> {
use SortKind::*;
let sql = match kind {
CardDeck => include_str!("deck_order.sql"),
Notetype => include_str!("notetype_order.sql"),
CardTemplate => include_str!("template_order.sql"),
NoteCards => include_str!("note_cards_order.sql"),
NoteEase => include_str!("note_ease_order.sql"),
NoteLapses => include_str!("note_lapses_order.sql"),
NoteReps => include_str!("note_reps_order.sql"),
Notetype => include_str!("notetype_order.sql"),
_ => return Ok(()),
};

View File

@ -0,0 +1,10 @@
DROP TABLE IF EXISTS sort_order;
CREATE TEMPORARY TABLE sort_order (
pos integer PRIMARY KEY,
nid integer NOT NULL UNIQUE
);
INSERT INTO sort_order (nid)
SELECT nid
FROM cards
GROUP BY nid
ORDER BY SUM(lapses);

View File

@ -0,0 +1,10 @@
DROP TABLE IF EXISTS sort_order;
CREATE TEMPORARY TABLE sort_order (
pos integer PRIMARY KEY,
nid integer NOT NULL UNIQUE
);
INSERT INTO sort_order (nid)
SELECT nid
FROM cards
GROUP BY nid
ORDER BY SUM(reps);