~devvmh/mnemosyne-proj/mnemosyne-proj

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
#
# review_wdgt.py <Peter.Bienstman@UGent.be>
#

from PyQt5 import QtCore, QtGui, QtWidgets, QtWebEngineWidgets
import os
import sys

from mnemosyne.libmnemosyne.translator import _
from mnemosyne.pyqt_ui.ui_review_wdgt import Ui_ReviewWdgt
from mnemosyne.libmnemosyne.ui_components.review_widget import ReviewWidget


class QAOptimalSplit(object):

    """Algorithm to make sure the split between question and answer boxes is
    as optimal as possible.

    This code is not part of ReviewWidget, in order to be able to reuse this
    in Preview Cards.

    """

    used_for_reviewing = True
    
    def __init__(self, **kwds):
        super().__init__(**kwds)    

    def setup(self): 
        self.question.settings().setAttribute(\
            QtWebEngineWidgets.QWebEngineSettings.PluginsEnabled, True)
        self.answer.settings().setAttribute(\
            QtWebEngineWidgets.QWebEngineSettings.PluginsEnabled, True)
        # Add some dummy QWebEngineEngineViews that will be used to determine the actual
        # size of the rendered html. This information will then be used to
        # determine the optimal split between the question and the answer
        # pane.
        self.question_preview = QtWebEngineWidgets.QWebEngineView()
        self.question_preview.loadFinished.connect(\
            self.question_preview_load_finished)
        self.answer_preview = QtWebEngineWidgets.QWebEngineView()
        self.answer_preview.loadFinished.connect(\
            self.answer_preview_load_finished)
        # Calculate an offset to use in the stretching factor of the boxes,
        # e.g. question_box = question_label + question.
        self.stretch_offset = self.question_label.size().height()
        if self.question_box.spacing() != -1:
            self.stretch_offset += self.question_box.spacing()
        self.scrollbar_width = QtWidgets.QScrollBar().sizeHint().width()
        self.question_text = ""
        self.answer_text = ""
        self.required_question_size = self.question.size()
        self.required_answer_size = self.answer.size()
        self.is_answer_showing = False
        self.times_resized = 0
        
    #
    # Code to determine optimal QA split based on prerendering the html
    # in a headless server. Does not work yet in PyQt5, because WebEngine
    # does not yet support headless mode.
    # See: https://bugreports.qt.io/browse/QTBUG-50523
    #

    def resizeEvent_off(self, event):
        # Update stretch factors when changing the size of the window.
        self.set_question(self.question_text)
        self.set_answer(self.answer_text)
        # To get this working the first time we start the program (in general,
        # the first one or two resize events, depending on whether or not we
        # changed the window size), we need to explicitly show the contents.
        # (Qt bug?). We don't do this on subsequent resizes to prevent flicker
        # and media replays.
        if self.times_resized < 2:
            self.reveal_question()
            if self.is_answer_showing:
                self.reveal_answer()
            self.times_resized += 1
        return QtWidgets.QWidget.resizeEvent(self, event)

    def question_preview_load_finished(self):
        #webView->page()->runJavaScript("document.documentElement.scrollWidth;",[=](QVariant result){
        #int newWidth=result.toInt();
        #webView->resize(newWidth,webView->height());
        #});
        
        #webView->page()->runJavaScript("document.documentElement.scrollHeight;",[=](QVariant result){
        #int newHeight=result.toInt();
        #webView->resize(webView->width(),newHeight);
        #});
        
        pass
        #self.required_question_size = \
        #    self.question_preview.page().currentFrame().contentsSize()
        #self.update_stretch_factors()

    def answer_preview_load_finished(self):
        pass
        #self.required_answer_size = \
        #    self.answer_preview.page().currentFrame().contentsSize()
        #self.update_stretch_factors()

    #
    # TMP workaround involving a heuristic to determine the height.
    # 
    
    import re
    
    re_img = re.compile(r"""img src=\"file:///(.+?)\"(.*?)>""",
        re.DOTALL | re.IGNORECASE)    

    def estimate_height(self, html):
        import math
        from mnemosyne.libmnemosyne.utils import expand_path, _abs_path
        from PIL import Image
         
        max_img_height = 0
        total_img_width = 0
        for match in self.re_img.finditer(html):
            img_file = match.group(1)
            if not _abs_path(img_file): # Linux issue
                img_file = "/" + img_file
            if not os.path.exists(img_file):
                print("Missing path", img_file)
                continue
            with Image.open(img_file) as im:
                width, height = im.size
                if height > max_img_height:
                    max_img_height = height
                total_img_width += width
        number_of_rows = math.ceil(total_img_width / self.question.width())
        return number_of_rows * max_img_height + 24
        
    def update_stretch_factors(self):
        if self.config()["QA_split"] != "adaptive":
            return
        if 0: # Using prerendered html
            # Correct the required heights of question and answer for the
            # presence of horizontal scrollbars.
            required_question_height = self.required_question_size.height()
            if self.required_question_size.width() > self.question.width():
                required_question_height += self.scrollbar_width
            required_answer_height = self.required_answer_size.height()
            if self.required_answer_size.width() > self.answer.width():
                required_answer_height += self.scrollbar_width
        else: # Tmp workaround using heuristic
            required_question_height = \
                self.estimate_height(self.question_text)
            required_answer_height = \
                self.estimate_height(self.answer_text)
        total_height_available = self.question.height() + self.answer.height()
        # If both question and answer fit in their own boxes, there is no need
        # to deviate from a 50/50 split.
        if required_question_height < total_height_available / 2 and \
            required_answer_height < total_height_available / 2:
            question_stretch = 50
            answer_stretch = 50
        # Don't be clairvoyant about the answer size, unless we will need
        # a non 50/50 split to start with.
        # If we are only showing the question, we try to limit 'surprising',
        # 'clairvoyant' stretches if they are not needed.
        elif not self.is_answer_showing:
            if required_question_height < total_height_available / 2:
                # No need to be clairvoyant.
                question_stretch = 50
                answer_stretch = 50
            else:
                # Make enough room for the question.
                question_stretch = required_question_height
                if required_question_height + required_answer_height \
                    <= total_height_available:
                    # Already have the stretch set-up to accomodate the answer,
                    # which makes the UI more relaxed (no need to have a
                    # different non 50/50 split once the answer is shown).
                    answer_stretch = required_answer_height
                else:
                    # But if we don't have enough space to show both the
                    # question and the answer, make sure the question gets
                    # all the space it can get now.
                    answer_stretch = 50
        # We are showing both question and answer.
        else:
            answer_stretch = required_answer_height
            if required_question_height + required_answer_height \
                    <= total_height_available:
                # If we have enough space, stretch in proportion to height.
                question_stretch = required_question_height
            else:
                # If we are previewing cards, go for a 50/50 split.
                if not self.used_for_reviewing:
                    question_stretch = 50
                    answer_stretch = 50
                # If we don't have enough space to show both the question and
                # the answer, try to give the answer all the space it needs.
                else:
                    answer_stretch = required_answer_height
                    question_stretch = total_height_available - answer_stretch
                    if question_stretch < 50:
                        question_stretch = 50
        self.setUpdatesEnabled(False)
        self.vertical_layout.setStretchFactor(\
            self.question_box, question_stretch + self.stretch_offset)
        self.vertical_layout.setStretchFactor(\
            self.answer_box, answer_stretch + self.stretch_offset)
        self.setUpdatesEnabled(True)
        
        # http://stackoverflow.com/questions/37527714/qt-qml-webview-resizes-really-slowly-when-window-resizing
        
        
    def silence_media(self, text):
        # Silence media, but make sure the player widget still shows to get
        # correct information about the geometry.
        text = text.replace("var soundFiles = new Array(",
                            "var soundFiles = new Array('off',")
        text = text.replace("<audio src=\"", "<audio src=\"off")
        text = text.replace("<video src=\"", "<video src=\"off")
        return text

    def set_question(self, text):
        #self.main_widget().show_information(text.replace("<", "&lt;"))
        self.question_text = text
        #self.question_preview.page().setPreferredContentsSize(\
        #    QtCore.QSize(self.question.size().width(), 1))
        #self.question_preview.setHtml(self.silence_media(text))
        #self.question_preview.show()

    def set_answer(self, text):
        #self.main_widget().show_information(text.replace("<", "&lt;"))        
        self.answer_text = text
        
        #self.answer_preview.page().setPreferredContentsSize(\
        #    QtCore.QSize(self.answer.size().width(), 1))
        #self.answer_preview.setHtml(self.silence_media(text))
        #self.answer_preview.show()

    def reveal_question(self):
        self.question.setHtml(self.question_text, QtCore.QUrl("file://"))

    def reveal_answer(self):
        self.is_answer_showing = True
        self.update_stretch_factors() 
        self.answer.setHtml(self.answer_text, QtCore.QUrl("file://"))
        # Forced repaint seems to make things snappier.
        self.question.repaint()
        self.answer.repaint()

    def clear_question(self):
        self.question.setHtml(self.empty())

    def clear_answer(self):
        self.is_answer_showing = False
        self.update_stretch_factors()
        self.answer.setHtml(self.empty())
        # Forced repaint seems to make things snappier.
        self.question.repaint()
        self.answer.repaint()


class ReviewWdgt(QtWidgets.QWidget, QAOptimalSplit, ReviewWidget, Ui_ReviewWdgt):

    auto_focus_grades = True
    number_keys_show_answer = True

    key_to_grade_map = {QtCore.Qt.Key_QuoteLeft: 0, QtCore.Qt.Key_0: 0,
            QtCore.Qt.Key_1: 1, QtCore.Qt.Key_2: 2, QtCore.Qt.Key_3: 3,
            QtCore.Qt.Key_4: 4, QtCore.Qt.Key_5: 5}

    def __init__(self, **kwds):
        super().__init__(**kwds)
        parent = self.main_widget()
        parent.setCentralWidget(self)
        self.setupUi(self)
        QAOptimalSplit.setup(self)
        
        # TODO: move this to designer with update of PyQt.
        self.grade_buttons = QtWidgets.QButtonGroup()
        self.grade_buttons.addButton(self.grade_0_button, 0)
        self.grade_buttons.addButton(self.grade_1_button, 1)
        self.grade_buttons.addButton(self.grade_2_button, 2)
        self.grade_buttons.addButton(self.grade_3_button, 3)
        self.grade_buttons.addButton(self.grade_4_button, 4)
        self.grade_buttons.addButton(self.grade_5_button, 5)
        self.grade_buttons.buttonClicked[int].connect(self.grade_answer)
        # TODO: remove this once Qt bug is fixed.
        self.setTabOrder(self.grade_1_button, self.grade_2_button)
        self.setTabOrder(self.grade_2_button, self.grade_3_button)
        self.setTabOrder(self.grade_3_button, self.grade_4_button)
        self.setTabOrder(self.grade_4_button, self.grade_5_button)
        self.setTabOrder(self.grade_5_button, self.grade_0_button)
        self.setTabOrder(self.grade_0_button, self.grade_1_button)
        self.focus_widget = None
        self.sched = QtWidgets.QLabel("", parent.status_bar)
        self.notmem = QtWidgets.QLabel("", parent.status_bar)
        self.act = QtWidgets.QLabel("", parent.status_bar)
        parent.clear_status_bar()
        parent.add_to_status_bar(self.sched)
        parent.add_to_status_bar(self.notmem)
        parent.add_to_status_bar(self.act)
        parent.status_bar.setSizeGripEnabled(0)
        self.widget_with_last_selection = self.question
        self.question.selectionChanged.connect(self.selection_changed_in_q)
        self.answer.selectionChanged.connect(self.selection_changed_in_a)
        self.mplayer = QtCore.QProcess()
        self.media_queue = [] 
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        
        # When clicking out of the app, and clicking back on the web widgets,
        # the focus does not get properly restored, and for QWebEngineView, the
        # event handling for keypresses and focusIn events doesn't work, so
        # we do a crude workaround: https://bugreports.qt.io/browse/QTBUG-46251
        self.timer = QtCore.QTimer()
        self.timer.timeout.connect(self.restore_focus)
        self.timer.start(200)
        
    def deactivate(self):
        self.stop_media()
        ReviewWidget.deactivate(self)
           
    #def focusInEvent(self, event):
    #    self.restore_focus()
    #    super().focusInEvent(event)    

    def changeEvent(self, event):
        if hasattr(self, "show_button"):
            show_button_text = self.show_button.text()
        if event.type() == QtCore.QEvent.LanguageChange:
            self.retranslateUi(self)
        # retranslateUI resets the show button text to 'Show answer',
        # so we need to work around this.
        if hasattr(self, "show_button"):
            self.show_button.setText(_(show_button_text))
        QtWidgets.QWidget.changeEvent(self, event)

    def keyPressEvent(self, event):
        if event.key() in self.key_to_grade_map and not event.isAutoRepeat():
            # Use controller function rather than self.is_answer_showing to
            # deal with the map card type.
            if self.review_controller().is_question_showing():
                if self.number_keys_show_answer:
                    self.show_answer()
            else:
                self.grade_answer(self.key_to_grade_map[event.key()])   
        elif event.key() == QtCore.Qt.Key_PageDown:
            self.scroll_down()
        elif event.key() == QtCore.Qt.Key_PageUp:
            self.scroll_up()
        elif event.key() == QtCore.Qt.Key_R and \
            event.modifiers() == QtCore.Qt.ControlModifier:
            self.review_controller().update_dialog(redraw_all=True) # Replay media.
        elif event.key() == QtCore.Qt.Key_C and \
            event.modifiers() == QtCore.Qt.ControlModifier:
            self.copy()
        # Work around Qt issue.
        elif event.key() in [QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete]:
            self.controller().delete_current_card()
        else:
            QtWidgets.QWidget.keyPressEvent(self, event)

    def empty(self):
        background = self.palette().color(QtGui.QPalette.Base).name()
        if self.review_controller().card:
            colour = self.config().card_type_property(\
            "background_colour", self.review_controller().card.card_type)
            if colour:
                background = ("%X" % colour)[2:] # Strip alpha.
        return """
        <html><head>
        <style type="text/css">
        table { height: 100%; }
        body  { background-color: """ + background + """;
                margin: 0;
                padding: 0;
                border: thin solid #8F8F8F; }
        </style></head>
        <body><table><tr><td>
        </td></tr></table></body></html>"""

    def scroll_down(self):
        return
        # TODO: reimplement after webkit is back.
        
        if self.review_controller().is_question_showing() or \
           self.review_controller().card.fact_view.a_on_top_of_q:
            frame = self.question.page().mainFrame()
        else:
            frame = self.answer.page().mainFrame()
        x, y = frame.scrollPosition().x(), frame.scrollPosition().y()
        y += int(0.9*(frame.geometry().height()))
        #frame.scroll(x, y) # Seems buggy 20111121.
        frame.evaluateJavaScript("window.scrollTo(%d, %d);" % (x, y))

    def scroll_up(self):
        return
        # TODO: reimplement after webkit is back.
         
        if self.review_controller().is_question_showing() or \
           self.review_controller().card.fact_view.a_on_top_of_q:
            frame = self.question.page().mainFrame()
        else:
            frame = self.answer.page().mainFrame()
        x, y = frame.scrollPosition().x(), frame.scrollPosition().y()
        y -= int(0.9*(frame.geometry().height()))
        #frame.scroll(x, y)  # Seems buggy 20111121.
        frame.evaluateJavaScript("window.scrollTo(%d, %d);" % (x, y))
        
    def selection_changed_in_q(self):
        self.widget_with_last_selection = self.question
        
    def selection_changed_in_a(self):
        self.widget_with_last_selection = self.answer
        
    def copy(self):
        self.widget_with_last_selection.pageAction(\
            QtWebEngineKit.QWebEnginePage.Copy).trigger()

    def show_answer(self):     
        self.review_controller().show_answer()

    def grade_answer(self, grade):
        self.review_controller().grade_answer(grade)

    def set_question_box_visible(self, is_visible):
        if is_visible:
            self.question.show()
            self.question_label.show()
        else:
            self.question.hide()
            self.question_label.hide()

    def set_answer_box_visible(self, is_visible):
        if is_visible:
            self.answer.show()
            self.answer_label.show()
        else:
            self.answer.hide()
            self.answer_label.hide()

    def set_question_label(self, text):
        self.question_label.setText(text)

    def restore_focus(self):
        # After clicking on the question or the answer, that widget grabs the
        # focus, so that the keyboard shortcuts no longer work. This functions
        # is used to set the focus back to the correct widget.
        if self.question.hasFocus() or self.answer.hasFocus():
            if self.focus_widget:
                self.focus_widget.setDefault(True)
                self.focus_widget.setFocus()

    def update_show_button(self, text, is_default, is_enabled):
        self.show_button.setText(text)
        self.show_button.setEnabled(is_enabled)
        if is_default:
            self.show_button.setDefault(True)
            self.show_button.setFocus()
            self.focus_widget = self.show_button

    def set_grades_enabled(self, is_enabled):
        self.grades.setEnabled(is_enabled)

    def set_grade_enabled(self, grade, is_enabled):
        self.grade_buttons.button(grade).setEnabled(is_enabled)

    def set_default_grade(self, grade):
        if self.auto_focus_grades:
            # On Windows, we seem to need to clear the previous default
            # first.
            for grade_i in range(6):
                self.grade_buttons.button(grade_i).setDefault(False)
            self.grade_buttons.button(grade).setDefault(True)
            self.grade_buttons.button(grade).setFocus()
            self.focus_widget = self.grade_buttons.button(grade)

    def set_grades_title(self, text):
        self.grades.setTitle(text)

    def set_grade_text(self, grade, text):
        self.grade_buttons.button(grade).setText(text)

    def set_grade_tooltip(self, grade, text):
        self.grade_buttons.button(grade).setToolTip(text)

    def update_status_bar_counters(self):
        scheduled_count, non_memorised_count, active_count = \
            self.review_controller().counters()
        self.sched.setText(_("Scheduled: %d ") % scheduled_count)
        self.notmem.setText(_("Not memorised: %d ") % non_memorised_count)
        self.act.setText(_("Active: %d ") % active_count)
        
    def play_media(self, filename, start=None, stop=None):
        if start is None:
            start = 0
        if stop is None:
            stop = 999999
        self.media_queue.append((filename, start, stop))
        if self.mplayer.state() != QtCore.QProcess.Running:
            self.play_next_file()
            
    def play_next_file(self):
        filename, start, stop = self.media_queue.pop(0)
        duration = stop - start
        if duration > 400:
            duration -= 300 # Compensate for mplayer overshoot. 
        self.mplayer = QtCore.QProcess()
        self.mplayer.finished.connect(self.done_playing)
        if sys.platform == "win32":            
            command = "mplayer.exe -slave -ao win32 -quiet \"" + filename + \
                "\" -ss " + str(start) + " -endpos " + str(duration) 
        elif sys.platform == "darwin":
            # e.g. /path/to/Mnemosyne.app/Contents/MacOS/mnemosyne/pyqt_ui/review_wdgt.py
            SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__))
            # e.g. /path/to/Mnemosyne.app/Contents
            CONTENTS_FOLDER = SCRIPT_PATH[:SCRIPT_PATH.index("/MacOS")]
            # e.g. /path/to/Mnemosyne.app/Contents/MacOS/mplayer
            MPLAYER_PATH = CONTENTS_FOLDER + "/MacOS/mplayer"

            command = "{} -slave -ao coreaudio -quiet \"{}\" -ss {} -endpos {}".format(
                MPLAYER_PATH, filename, str(start), str(duration)
            )
        else:
            command = "mplayer -slave -quiet \"" + filename + \
                "\" -ss " + str(start) + " -endpos " + str(duration)
        self.mplayer.start(command)
            
    def done_playing(self, result):
        if len(self.media_queue) >= 1:
            self.play_next_file()
        
    def stop_media(self):
        if self.mplayer is not None:
            self.mplayer.write(b"quit\n");
        self.media_queue = []

    def redraw_now(self):
        self.repaint()
        self.parent().repaint()