Anki/qt/aqt/preferences.py
Rastislav Kish e985fec9c4
A11Y: Improve the accessibility of the Preferences dialog (#3255)
* Configure buddy widgets for labels in the Preferences dialog

Labels are often used to describe the purpose of a different widget like a combobox, edit field or a spinbox by providing a textual name for their functionality. The relation between a label and a widget is typically expressed by placing the label next to the relevant object. In addition to this visual linking intended for human users, frameworks usually also offer semantic way to link labels with other widgets, so the relation can be noticed by programs like screenreaders, which can figure out the correct textual description for the focused widgets based on this information.

By default, labels on their own are not focusable elements, so users dependend on keyboard navigation and speech get to notice only the widget types (textbox, spinbox, etc.) while moving around without any contextual information if labels are not linked. When the linking is done, the component names get included as well.

QT provides the "buddy" property for QLabel, which creates a semantic link between the label and its buddy widget.

This commit configures the buddy properties on labels of the Anki Preferences dialog.

* Configure spinbox suffixes in Preferrences dialog

QSpinBox provides a suffix property. This property makes it possible to display a measurement unit next to the component value, which is linked to it both visually and semantically for the GUI framework without affecting the spinbox value itself. For purposes of accessibility, it's better to use this property than simply place a label next to the component, since it can be directly accessed by screenreaders and other assistive technology.

This commit configures suffix properties for spinboxes in the Anki Preferences dialog. Note: Removal of the original unit labels may have altered the UI a little bit.

* Assign buddy widgets in the ID and password retrieval dialog

Set buddy widgets of the labels in the Get ID and password for synchronization dialog.

* Fix positioning/size of text boxes

* Style the suffixes of Preferences' QSpinBoxes

Style QSpinBox suffixes (for those that have one) in the Preferences dialog by prepending them by a space character.
2024-07-10 19:58:47 +07:00

431 lines
16 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
import functools
import re
import anki.lang
import aqt
import aqt.forms
import aqt.operations
from anki.collection import OpChanges
from aqt import AnkiQt
from aqt.operations.collection import set_preferences
from aqt.profiles import VideoDriver
from aqt.qt import *
from aqt.sync import sync_login
from aqt.theme import Theme
from aqt.utils import (
HelpPage,
askUser,
disable_help_button,
is_win,
openHelp,
showInfo,
showWarning,
tr,
)
class Preferences(QDialog):
def __init__(self, mw: AnkiQt) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
self.prof = self.mw.pm.profile
self.form = aqt.forms.preferences.Ui_Preferences()
self.form.setupUi(self)
for spinbox in (
self.form.lrnCutoff,
self.form.dayOffset,
self.form.timeLimit,
self.form.network_timeout,
):
spinbox.setSuffix(f" {spinbox.suffix()}")
disable_help_button(self)
self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help).setAutoDefault(
False
)
self.form.buttonBox.button(
QDialogButtonBox.StandardButton.Close
).setAutoDefault(False)
qconnect(
self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)
)
self.silentlyClose = True
self.setup_collection()
self.setup_profile()
self.setup_global()
self.setup_configurable_answer_keys()
self.show()
def setup_configurable_answer_keys(self):
"""
Create a group box in Preferences with widgets that let the user edit answer keys.
"""
ease_labels = (
(1, tr.studying_again()),
(2, tr.studying_hard()),
(3, tr.studying_good()),
(4, tr.studying_easy()),
)
group = self.form.preferences_answer_keys
group.setLayout(layout := QFormLayout())
for ease, label in ease_labels:
layout.addRow(
label,
line_edit := QLineEdit(self.mw.pm.get_answer_key(ease) or ""),
)
qconnect(
line_edit.textChanged,
functools.partial(self.mw.pm.set_answer_key, ease),
)
line_edit.setPlaceholderText(tr.preferences_shortcut_placeholder())
def accept(self) -> None:
self.accept_with_callback()
def accept_with_callback(self, callback: Callable[[], None] | None = None) -> None:
# avoid exception if main window is already closed
if not self.mw.col:
return
def after_collection_update() -> None:
self.update_profile()
self.update_global()
self.mw.pm.save()
self.done(0)
aqt.dialogs.markClosed("Preferences")
if callback:
callback()
self.update_collection(after_collection_update)
def reject(self) -> None:
self.accept()
# Preferences stored in the collection
######################################################################
def setup_collection(self) -> None:
self.prefs = self.mw.col.get_preferences()
form = self.form
scheduling = self.prefs.scheduling
form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0))
form.dayOffset.setValue(scheduling.rollover)
reviewing = self.prefs.reviewing
form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0))
form.showEstimates.setChecked(reviewing.show_intervals_on_buttons)
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
editing = self.prefs.editing
form.useCurrent.setCurrentIndex(
0 if editing.adding_defaults_to_current_deck else 1
)
form.paste_strips_formatting.setChecked(editing.paste_strips_formatting)
form.ignore_accents_in_search.setChecked(editing.ignore_accents_in_search)
form.pastePNG.setChecked(editing.paste_images_as_png)
form.render_latex.setChecked(editing.render_latex)
form.default_search_text.setText(editing.default_search_text)
form.backup_explanation.setText(
anki.lang.with_collapsed_whitespace(tr.preferences_backup_explanation())
)
form.daily_backups.setValue(self.prefs.backups.daily)
form.weekly_backups.setValue(self.prefs.backups.weekly)
form.monthly_backups.setValue(self.prefs.backups.monthly)
form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins)
def update_collection(self, on_done: Callable[[], None]) -> None:
form = self.form
scheduling = self.prefs.scheduling
scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60
scheduling.rollover = form.dayOffset.value()
reviewing = self.prefs.reviewing
reviewing.show_remaining_due_counts = form.showProgress.isChecked()
reviewing.show_intervals_on_buttons = form.showEstimates.isChecked()
reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
editing.paste_images_as_png = self.form.pastePNG.isChecked()
editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked()
editing.render_latex = self.form.render_latex.isChecked()
editing.default_search_text = self.form.default_search_text.text()
editing.ignore_accents_in_search = (
self.form.ignore_accents_in_search.isChecked()
)
self.prefs.backups.daily = form.daily_backups.value()
self.prefs.backups.weekly = form.weekly_backups.value()
self.prefs.backups.monthly = form.monthly_backups.value()
self.prefs.backups.minimum_interval_mins = form.minutes_between_backups.value()
def after_prefs_update(changes: OpChanges) -> None:
self.mw.apply_collection_options()
on_done()
set_preferences(parent=self, preferences=self.prefs).success(
after_prefs_update
).run_in_background()
# Preferences stored in the profile
######################################################################
def setup_profile(self) -> None:
"Setup options stored in the user profile."
self.setup_network()
def update_profile(self) -> None:
self.update_network()
# Profile: network
######################################################################
def setup_network(self) -> None:
self.form.media_log.setText(tr.sync_media_log_button())
qconnect(self.form.media_log.clicked, self.on_media_log)
self.form.syncOnProgramOpen.setChecked(self.mw.pm.auto_syncing_enabled())
self.form.syncMedia.setChecked(self.mw.pm.media_syncing_enabled())
self.form.autoSyncMedia.setChecked(
self.mw.pm.periodic_sync_media_minutes() != 0
)
self.form.custom_sync_url.setText(self.mw.pm.custom_sync_url())
self.form.network_timeout.setValue(self.mw.pm.network_timeout())
self.update_login_status()
qconnect(self.form.syncLogout.clicked, self.sync_logout)
qconnect(self.form.syncLogin.clicked, self.sync_login)
def update_login_status(self) -> None:
if not self.prof.get("syncKey"):
self.form.syncUser.setText(tr.preferences_not_logged_in())
self.form.syncLogin.setVisible(True)
self.form.syncLogout.setVisible(False)
else:
self.form.syncUser.setText(self.prof.get("syncUser", ""))
self.form.syncLogin.setVisible(False)
self.form.syncLogout.setVisible(True)
def on_media_log(self) -> None:
self.mw.media_syncer.show_sync_log()
def sync_login(self) -> None:
def on_success():
if self.prof.get("syncKey"):
self.update_login_status()
self.confirm_sync_after_login()
sync_login(self.mw, on_success)
def sync_logout(self) -> None:
if self.mw.media_syncer.is_syncing():
showWarning("Can't log out while sync in progress.")
return
self.prof["syncKey"] = None
self.mw.col.media.force_resync()
self.update_login_status()
def confirm_sync_after_login(self) -> None:
from aqt import mw
if askUser(tr.preferences_login_successful_sync_now(), parent=mw):
self.accept_with_callback(self.mw.on_sync_button_clicked)
def update_network(self) -> None:
self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked()
self.prof["syncMedia"] = self.form.syncMedia.isChecked()
self.mw.pm.set_periodic_sync_media_minutes(
self.form.autoSyncMedia.isChecked() and 15 or 0
)
if self.form.fullSync.isChecked():
self.mw.col.mod_schema(check=False)
self.mw.pm.set_custom_sync_url(self.form.custom_sync_url.text())
self.mw.pm.set_network_timeout(self.form.network_timeout.value())
# Global preferences
######################################################################
def setup_global(self) -> None:
"Setup options global to all profiles."
self.form.reduce_motion.setChecked(self.mw.pm.reduce_motion())
qconnect(self.form.reduce_motion.stateChanged, self.mw.pm.set_reduce_motion)
self.form.minimalist_mode.setChecked(self.mw.pm.minimalist_mode())
qconnect(self.form.minimalist_mode.stateChanged, self.mw.pm.set_minimalist_mode)
self.form.spacebar_rates_card.setChecked(self.mw.pm.spacebar_rates_card())
qconnect(
self.form.spacebar_rates_card.stateChanged,
self.mw.pm.set_spacebar_rates_card,
)
hide_choices = [tr.preferences_full_screen_only(), tr.preferences_always()]
self.form.hide_top_bar.setChecked(self.mw.pm.hide_top_bar())
qconnect(self.form.hide_top_bar.stateChanged, self.mw.pm.set_hide_top_bar)
qconnect(
self.form.hide_top_bar.stateChanged,
self.form.topBarComboBox.setVisible,
)
self.form.topBarComboBox.addItems(hide_choices)
self.form.topBarComboBox.setCurrentIndex(self.mw.pm.top_bar_hide_mode())
self.form.topBarComboBox.setVisible(self.form.hide_top_bar.isChecked())
qconnect(
self.form.topBarComboBox.currentIndexChanged,
self.mw.pm.set_top_bar_hide_mode,
)
self.form.hide_bottom_bar.setChecked(self.mw.pm.hide_bottom_bar())
qconnect(self.form.hide_bottom_bar.stateChanged, self.mw.pm.set_hide_bottom_bar)
qconnect(
self.form.hide_bottom_bar.stateChanged,
self.form.bottomBarComboBox.setVisible,
)
self.form.bottomBarComboBox.addItems(hide_choices)
self.form.bottomBarComboBox.setCurrentIndex(self.mw.pm.bottom_bar_hide_mode())
self.form.bottomBarComboBox.setVisible(self.form.hide_bottom_bar.isChecked())
qconnect(
self.form.bottomBarComboBox.currentIndexChanged,
self.mw.pm.set_bottom_bar_hide_mode,
)
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
themes = [
tr.preferences_theme_follow_system(),
tr.preferences_theme_light(),
tr.preferences_theme_dark(),
]
self.form.theme.addItems(themes)
self.form.theme.setCurrentIndex(self.mw.pm.theme().value)
qconnect(self.form.theme.currentIndexChanged, self.on_theme_changed)
self.form.styleComboBox.addItems(["Anki"] + (["Native"] if not is_win else []))
self.form.styleComboBox.setCurrentIndex(self.mw.pm.get_widget_style())
qconnect(
self.form.styleComboBox.currentIndexChanged,
self.mw.pm.set_widget_style,
)
self.form.styleLabel.setVisible(not is_win)
self.form.styleComboBox.setVisible(not is_win)
self.form.legacy_import_export.setChecked(self.mw.pm.legacy_import_export())
qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes)
self.setup_language()
self.setup_video_driver()
self.setupOptions()
def update_global(self) -> None:
restart_required = False
self.update_video_driver()
newScale = self.form.uiScale.value() / 100
if newScale != self.mw.pm.uiScale():
self.mw.pm.setUiScale(newScale)
restart_required = True
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
if restart_required:
showInfo(tr.preferences_changes_will_take_effect_when_you())
self.updateOptions()
def on_theme_changed(self, index: int) -> None:
self.mw.set_theme(Theme(index))
def on_reset_window_sizes(self) -> None:
regexp = re.compile(r"(Geom(etry)?|State|Splitter|Header)(\d+.\d+)?$")
for key in list(self.prof.keys()):
if regexp.search(key):
del self.prof[key]
showInfo(tr.preferences_reset_window_sizes_complete())
# legacy - one of Henrik's add-ons is currently wrapping them
def setupOptions(self) -> None:
pass
def updateOptions(self) -> None:
pass
# Global: language
######################################################################
def setup_language(self) -> None:
f = self.form
f.lang.addItems([x[0] for x in anki.lang.langs])
f.lang.setCurrentIndex(self.current_lang_index())
qconnect(f.lang.currentIndexChanged, self.on_language_index_changed)
def current_lang_index(self) -> int:
codes = [x[1] for x in anki.lang.langs]
lang = anki.lang.current_lang
if lang in anki.lang.compatMap:
lang = anki.lang.compatMap[lang]
else:
lang = lang.replace("-", "_")
try:
return codes.index(lang)
except:
return codes.index("en_US")
def on_language_index_changed(self, idx: int) -> None:
code = anki.lang.langs[idx][1]
self.mw.pm.setLang(code)
showInfo(tr.preferences_please_restart_anki_to_complete_language(), parent=self)
# Global: video driver
######################################################################
def setup_video_driver(self) -> None:
self.video_drivers = VideoDriver.all_for_platform()
names = [video_driver_name_for_platform(d) for d in self.video_drivers]
self.form.video_driver.addItems(names)
self.form.video_driver.setCurrentIndex(
self.video_drivers.index(self.mw.pm.video_driver())
)
def update_video_driver(self) -> None:
new_driver = self.video_drivers[self.form.video_driver.currentIndex()]
if new_driver != self.mw.pm.video_driver():
self.mw.pm.set_video_driver(new_driver)
showInfo(tr.preferences_changes_will_take_effect_when_you())
def video_driver_name_for_platform(driver: VideoDriver) -> str:
if qtmajor < 6:
if driver == VideoDriver.ANGLE:
return tr.preferences_video_driver_angle()
elif driver == VideoDriver.Software:
if is_mac:
return tr.preferences_video_driver_software_mac()
else:
return tr.preferences_video_driver_software_other()
elif driver == VideoDriver.OpenGL:
if is_mac:
return tr.preferences_video_driver_opengl_mac()
else:
return tr.preferences_video_driver_opengl_other()
label = driver.name
if driver == VideoDriver.default_for_platform():
label += f" ({tr.preferences_video_driver_default()})"
return label