1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3
4
5__license__ = 'GPL v3'
6__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
7
8import sys
9from qt.core import (
10    QWidget, QTimer, QStackedLayout, QLabel, QScrollArea, QVBoxLayout,
11    QPainter, Qt, QPalette, QRect, QSize, QSizePolicy, pyqtSignal,
12    QColor, QMenu, QApplication, QIcon, QUrl)
13
14from calibre.constants import FAKE_HOST, FAKE_PROTOCOL
15from calibre.gui2.tweak_book import editors, actions, tprefs
16from calibre.gui2.tweak_book.editor.themes import get_theme, theme_color
17from calibre.gui2.tweak_book.editor.text import default_font_family
18from css_selectors import parse, SelectorError
19
20
21lowest_specificity = (-sys.maxsize, 0, 0, 0, 0, 0)
22
23
24class Heading(QWidget):  # {{{
25
26    toggled = pyqtSignal(object)
27    context_menu_requested = pyqtSignal(object, object)
28
29    def __init__(self, text, expanded=True, parent=None):
30        QWidget.__init__(self, parent)
31        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
32        self.setCursor(Qt.CursorShape.PointingHandCursor)
33        self.text = text
34        self.expanded = expanded
35        self.hovering = False
36        self.do_layout()
37
38    @property
39    def lines_for_copy(self):
40        return [self.text]
41
42    def do_layout(self):
43        try:
44            f = self.parent().font()
45        except AttributeError:
46            return
47        f.setBold(True)
48        self.setFont(f)
49
50    def mousePressEvent(self, ev):
51        if ev.button() == Qt.MouseButton.LeftButton:
52            ev.accept()
53            self.expanded ^= True
54            self.toggled.emit(self)
55            self.update()
56        else:
57            return QWidget.mousePressEvent(self, ev)
58
59    @property
60    def rendered_text(self):
61        return ('▾' if self.expanded else '▸') + '\xa0' + self.text
62
63    def sizeHint(self):
64        fm = self.fontMetrics()
65        sz = fm.boundingRect(self.rendered_text).size()
66        return sz
67
68    def paintEvent(self, ev):
69        p = QPainter(self)
70        p.setClipRect(ev.rect())
71        bg = self.palette().color(QPalette.ColorRole.AlternateBase)
72        if self.hovering:
73            bg = bg.lighter(115)
74        p.fillRect(self.rect(), bg)
75        try:
76            p.drawText(self.rect(), Qt.AlignmentFlag.AlignLeft|Qt.AlignmentFlag.AlignVCenter|Qt.TextFlag.TextSingleLine, self.rendered_text)
77        finally:
78            p.end()
79
80    def enterEvent(self, ev):
81        self.hovering = True
82        self.update()
83        return QWidget.enterEvent(self, ev)
84
85    def leaveEvent(self, ev):
86        self.hovering = False
87        self.update()
88        return QWidget.leaveEvent(self, ev)
89
90    def contextMenuEvent(self, ev):
91        self.context_menu_requested.emit(self, ev)
92# }}}
93
94
95class Cell:  # {{{
96
97    __slots__ = ('rect', 'text', 'right_align', 'color_role', 'override_color', 'swatch', 'is_overriden')
98
99    SIDE_MARGIN = 5
100    FLAGS = Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextSingleLine | Qt.TextFlag.TextIncludeTrailingSpaces
101
102    def __init__(self, text, rect, right_align=False, color_role=QPalette.ColorRole.WindowText, swatch=None, is_overriden=False):
103        self.rect, self.text = rect, text
104        self.right_align = right_align
105        self.is_overriden = is_overriden
106        self.color_role = color_role
107        self.override_color = None
108        self.swatch = swatch
109        if swatch is not None:
110            self.swatch = QColor(swatch[0], swatch[1], swatch[2], int(255 * swatch[3]))
111
112    def draw(self, painter, width, palette):
113        flags = self.FLAGS | (Qt.AlignmentFlag.AlignRight if self.right_align else Qt.AlignmentFlag.AlignLeft)
114        rect = QRect(self.rect)
115        if self.right_align:
116            rect.setRight(width - self.SIDE_MARGIN)
117        painter.setPen(palette.color(self.color_role) if self.override_color is None else self.override_color)
118        br = painter.drawText(rect, flags, self.text)
119        if self.swatch is not None:
120            r = QRect(br.right() + self.SIDE_MARGIN // 2, br.top() + 2, br.height() - 4, br.height() - 4)
121            painter.fillRect(r, self.swatch)
122            br.setRight(r.right())
123        if self.is_overriden:
124            painter.setPen(palette.color(QPalette.ColorRole.WindowText))
125            painter.drawLine(br.left(), br.top() + br.height() // 2, br.right(), br.top() + br.height() // 2)
126# }}}
127
128
129class Declaration(QWidget):
130
131    hyperlink_activated = pyqtSignal(object)
132    context_menu_requested = pyqtSignal(object, object)
133
134    def __init__(self, html_name, data, is_first=False, parent=None):
135        QWidget.__init__(self, parent)
136        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
137        self.data = data
138        self.is_first = is_first
139        self.html_name = html_name
140        self.lines_for_copy = []
141        self.do_layout()
142        self.setMouseTracking(True)
143
144    def do_layout(self):
145        fm = self.fontMetrics()
146        bounding_rect = lambda text: fm.boundingRect(0, 0, 10000, 10000, Cell.FLAGS, text)
147        line_spacing = 2
148        side_margin = Cell.SIDE_MARGIN
149        self.rows = []
150        ypos = line_spacing + (1 if self.is_first else 0)
151        if 'href' in self.data:
152            name = self.data['href']
153            if isinstance(name, list):
154                name = self.html_name
155            br1 = bounding_rect(name)
156            sel = self.data['selector'] or ''
157            if self.data['type'] == 'inline':
158                sel = 'style=""'
159            br2 = bounding_rect(sel)
160            self.hyperlink_rect = QRect(side_margin, ypos, br1.width(), br1.height())
161            self.rows.append([
162                Cell(name, self.hyperlink_rect, color_role=QPalette.ColorRole.Link),
163                Cell(sel, QRect(br1.right() + side_margin, ypos, br2.width(), br2.height()), right_align=True)
164            ])
165            ypos += max(br1.height(), br2.height()) + 2 * line_spacing
166            self.lines_for_copy.append(name + ' ' + sel)
167
168        for prop in self.data['properties']:
169            text = prop.name + ':\xa0'
170            br1 = bounding_rect(text)
171            vtext = prop.value + '\xa0' + ('!' if prop.important else '') + prop.important
172            br2 = bounding_rect(vtext)
173            self.rows.append([
174                Cell(text, QRect(side_margin, ypos, br1.width(), br1.height()), color_role=QPalette.ColorRole.LinkVisited, is_overriden=prop.is_overriden),
175                Cell(vtext, QRect(br1.right() + side_margin, ypos, br2.width(), br2.height()), swatch=prop.color, is_overriden=prop.is_overriden)
176            ])
177            self.lines_for_copy.append(text + vtext)
178            if prop.is_overriden:
179                self.lines_for_copy[-1] += ' [overridden]'
180            ypos += max(br1.height(), br2.height()) + line_spacing
181        self.lines_for_copy.append('--------------------------\n')
182
183        self.height_hint = ypos + line_spacing
184        self.width_hint = max(row[-1].rect.right() + side_margin for row in self.rows) if self.rows else 0
185
186    def sizeHint(self):
187        return QSize(self.width_hint, self.height_hint)
188
189    def paintEvent(self, ev):
190        p = QPainter(self)
191        p.setClipRect(ev.rect())
192        palette = self.palette()
193        p.setPen(palette.color(QPalette.ColorRole.WindowText))
194        if not self.is_first:
195            p.drawLine(0, 0, self.width(), 0)
196        try:
197            for row in self.rows:
198                for cell in row:
199                    p.save()
200                    try:
201                        cell.draw(p, self.width(), palette)
202                    finally:
203                        p.restore()
204
205        finally:
206            p.end()
207
208    def mouseMoveEvent(self, ev):
209        if hasattr(self, 'hyperlink_rect'):
210            pos = ev.pos()
211            hovering = self.hyperlink_rect.contains(pos)
212            self.update_hover(hovering)
213            cursor = Qt.CursorShape.ArrowCursor
214            for r, row in enumerate(self.rows):
215                for cell in row:
216                    if cell.rect.contains(pos):
217                        cursor = Qt.CursorShape.PointingHandCursor if cell.rect is self.hyperlink_rect else Qt.CursorShape.IBeamCursor
218                    if r == 0:
219                        break
220                if cursor != Qt.CursorShape.ArrowCursor:
221                    break
222            self.setCursor(cursor)
223        return QWidget.mouseMoveEvent(self, ev)
224
225    def mousePressEvent(self, ev):
226        if hasattr(self, 'hyperlink_rect') and ev.button() == Qt.MouseButton.LeftButton:
227            pos = ev.pos()
228            if self.hyperlink_rect.contains(pos):
229                self.emit_hyperlink_activated()
230        return QWidget.mousePressEvent(self, ev)
231
232    def emit_hyperlink_activated(self):
233        dt = self.data['type']
234        data = {'type':dt, 'name':self.html_name, 'syntax':'html'}
235        if dt == 'inline':  # style attribute
236            data['sourceline_address'] = self.data['href']
237        elif dt == 'elem':  # <style> tag
238            data['sourceline_address'] = self.data['href']
239            data['rule_address'] = self.data['rule_address']
240        else:  # stylesheet
241            data['name'] = self.data['href']
242            data['rule_address'] = self.data['rule_address']
243            data['syntax'] = 'css'
244        self.hyperlink_activated.emit(data)
245
246    def leaveEvent(self, ev):
247        self.update_hover(False)
248        self.setCursor(Qt.CursorShape.ArrowCursor)
249        return QWidget.leaveEvent(self, ev)
250
251    def update_hover(self, hovering):
252        cell = self.rows[0][0]
253        if (hovering and cell.override_color is None) or (
254                not hovering and cell.override_color is not None):
255            cell.override_color = QColor(Qt.GlobalColor.red) if hovering else None
256            self.update()
257
258    def contextMenuEvent(self, ev):
259        self.context_menu_requested.emit(self, ev)
260
261
262class Box(QWidget):
263
264    hyperlink_activated = pyqtSignal(object)
265
266    def __init__(self, parent=None):
267        QWidget.__init__(self, parent)
268        self.l = l = QVBoxLayout(self)
269        l.setAlignment(Qt.AlignmentFlag.AlignTop)
270        self.setLayout(l)
271        self.widgets = []
272
273    def show_data(self, data):
274        for w in self.widgets:
275            self.layout().removeWidget(w)
276            for x in ('toggled', 'hyperlink_activated', 'context_menu_requested'):
277                if hasattr(w, x):
278                    try:
279                        getattr(w, x).disconnect()
280                    except TypeError:
281                        pass
282            w.deleteLater()
283        self.widgets = []
284        for node in data['nodes']:
285            node_name = node['name'] + ' @%s' % node['sourceline']
286            if node['ancestor_specificity'] != 0:
287                title = _('Inherited from %s') % node_name
288            else:
289                title = _('Matched CSS rules for %s') % node_name
290            h = Heading(title, parent=self)
291            h.toggled.connect(self.heading_toggled)
292            self.widgets.append(h), self.layout().addWidget(h)
293            for i, declaration in enumerate(node['css']):
294                d = Declaration(data['html_name'], declaration, is_first=i == 0, parent=self)
295                d.hyperlink_activated.connect(self.hyperlink_activated)
296                self.widgets.append(d), self.layout().addWidget(d)
297
298        h = Heading(_('Computed final style'), parent=self)
299        h.toggled.connect(self.heading_toggled)
300        self.widgets.append(h), self.layout().addWidget(h)
301        ccss = data['computed_css']
302        declaration = {'properties':[Property([k, ccss[k][0], '', ccss[k][1]]) for k in sorted(ccss)]}
303        d = Declaration(None, declaration, is_first=True, parent=self)
304        self.widgets.append(d), self.layout().addWidget(d)
305        for w in self.widgets:
306            w.context_menu_requested.connect(self.context_menu_requested)
307
308    def heading_toggled(self, heading):
309        for i, w in enumerate(self.widgets):
310            if w is heading:
311                for b in self.widgets[i + 1:]:
312                    if isinstance(b, Heading):
313                        break
314                    b.setVisible(heading.expanded)
315                break
316
317    def relayout(self):
318        for w in self.widgets:
319            w.do_layout()
320            w.updateGeometry()
321
322    @property
323    def lines_for_copy(self):
324        ans = []
325        for w in self.widgets:
326            ans += w.lines_for_copy
327        return ans
328
329    def context_menu_requested(self, widget, ev):
330        if isinstance(widget, Heading):
331            start = widget
332        else:
333            found = False
334            for w in reversed(self.widgets):
335                if w is widget:
336                    found = True
337                elif found and isinstance(w, Heading):
338                    start = w
339                    break
340            else:
341                return
342        found = False
343        lines = []
344        for w in self.widgets:
345            if found and isinstance(w, Heading):
346                break
347            if w is start:
348                found = True
349            if found:
350                lines += w.lines_for_copy
351        if not lines:
352            return
353        block = '\n'.join(lines).replace('\xa0', ' ')
354        heading = lines[0]
355        m = QMenu(self)
356        m.addAction(QIcon(I('edit-copy.png')), _('Copy') + ' ' + heading.replace('\xa0', ' '), lambda : QApplication.instance().clipboard().setText(block))
357        all_lines = []
358        for w in self.widgets:
359            all_lines += w.lines_for_copy
360        all_text = '\n'.join(all_lines).replace('\xa0', ' ')
361        m.addAction(QIcon(I('edit-copy.png')), _('Copy everything'), lambda : QApplication.instance().clipboard().setText(all_text))
362        m.exec(ev.globalPos())
363
364
365class Property:
366
367    __slots__ = 'name', 'value', 'important', 'color', 'specificity', 'is_overriden'
368
369    def __init__(self, prop, specificity=()):
370        self.name, self.value, self.important, self.color = prop
371        self.specificity = tuple(specificity)
372        self.is_overriden = False
373
374    def __repr__(self):
375        return '<Property name=%s value=%s important=%s color=%s specificity=%s is_overriden=%s>' % (
376            self.name, self.value, self.important, self.color, self.specificity, self.is_overriden)
377
378
379class LiveCSS(QWidget):
380
381    goto_declaration = pyqtSignal(object)
382
383    def __init__(self, preview, parent=None):
384        QWidget.__init__(self, parent)
385        self.preview = preview
386        preview.live_css_data.connect(self.got_live_css_data)
387        self.preview_is_refreshing = False
388        self.refresh_needed = False
389        preview.refresh_starting.connect(self.preview_refresh_starting)
390        preview.refreshed.connect(self.preview_refreshed)
391        self.apply_theme()
392        self.setAutoFillBackground(True)
393        self.update_timer = QTimer(self)
394        self.update_timer.timeout.connect(self.update_data)
395        self.update_timer.setSingleShot(True)
396        self.update_timer.setInterval(500)
397        self.now_showing = (None, None, None)
398
399        self.stack = s = QStackedLayout(self)
400        self.setLayout(s)
401
402        self.clear_label = la = QLabel('<h3>' + _(
403            'No style information found') + '</h3><p>' + _(
404                'Move the cursor inside a HTML tag to see what styles'
405                ' apply to that tag.'))
406        la.setWordWrap(True)
407        la.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
408        s.addWidget(la)
409
410        self.box = box = Box(self)
411        box.hyperlink_activated.connect(self.goto_declaration, type=Qt.ConnectionType.QueuedConnection)
412        self.scroll = sc = QScrollArea(self)
413        sc.setWidget(box)
414        sc.setWidgetResizable(True)
415        s.addWidget(sc)
416
417    def preview_refresh_starting(self):
418        self.preview_is_refreshing = True
419
420    def preview_refreshed(self):
421        self.preview_is_refreshing = False
422        self.refresh_needed = True
423        self.start_update_timer()
424
425    def apply_theme(self):
426        f = self.font()
427        f.setFamily(tprefs['editor_font_family'] or default_font_family())
428        f.setPointSize(tprefs['editor_font_size'])
429        self.setFont(f)
430        theme = get_theme(tprefs['editor_theme'])
431        pal = self.palette()
432        pal.setColor(QPalette.ColorRole.Window, theme_color(theme, 'Normal', 'bg'))
433        pal.setColor(QPalette.ColorRole.WindowText, theme_color(theme, 'Normal', 'fg'))
434        pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'HighlightRegion', 'bg'))
435        pal.setColor(QPalette.ColorRole.Link, theme_color(theme, 'Link', 'fg'))
436        pal.setColor(QPalette.ColorRole.LinkVisited, theme_color(theme, 'Keyword', 'fg'))
437        self.setPalette(pal)
438        if hasattr(self, 'box'):
439            self.box.relayout()
440        self.update()
441
442    def clear(self):
443        self.stack.setCurrentIndex(0)
444
445    def show_data(self, editor_name, sourceline, tags):
446        if self.preview_is_refreshing:
447            return
448        if sourceline is None:
449            self.clear()
450        else:
451            self.preview.request_live_css_data(editor_name, sourceline, tags)
452
453    def got_live_css_data(self, result):
454        maximum_specificities = {}
455        for node in result['nodes']:
456            for rule in node['css']:
457                self.process_rule(rule, node['ancestor_specificity'], maximum_specificities)
458        for node in result['nodes']:
459            for rule in node['css']:
460                for prop in rule['properties']:
461                    if prop.specificity < maximum_specificities[prop.name]:
462                        prop.is_overriden = True
463        self.display_received_live_css_data(result)
464
465    def display_received_live_css_data(self, data):
466        editor_name = data['editor_name']
467        sourceline = data['sourceline']
468        tags = data['tags']
469        if data is None or len(data['computed_css']) < 1:
470            if editor_name == self.current_name and (editor_name, sourceline, tags) == self.now_showing:
471                # Try again in a little while in case there was a transient
472                # error in the web view
473                self.start_update_timer()
474                return
475            self.clear()
476            return
477        self.now_showing = (editor_name, sourceline, tags)
478        data['html_name'] = editor_name
479        self.box.show_data(data)
480        self.refresh_needed = False
481        self.stack.setCurrentIndex(1)
482
483    def process_rule(self, rule, ancestor_specificity, maximum_specificities):
484        selector = rule['selector']
485        sheet_index = rule['sheet_index']
486        rule_address = rule['rule_address'] or ()
487        if selector is not None:
488            try:
489                specificity = [0] + list(parse(selector)[0].specificity())
490            except (AttributeError, TypeError, SelectorError):
491                specificity = [0, 0, 0, 0]
492        else:  # style attribute
493            specificity = [1, 0, 0, 0]
494        specificity.extend((sheet_index, tuple(rule_address)))
495        properties = []
496        for prop in rule['properties']:
497            important = 1 if prop[-1] == 'important' else 0
498            p = Property(prop, [ancestor_specificity] + [important] + specificity)
499            properties.append(p)
500            if p.specificity > maximum_specificities.get(p.name, lowest_specificity):
501                maximum_specificities[p.name] = p.specificity
502        rule['properties'] = properties
503
504        href = rule['href']
505        if hasattr(href, 'startswith') and href.startswith('%s://%s' % (FAKE_PROTOCOL, FAKE_HOST)):
506            qurl = QUrl(href)
507            name = qurl.path()[1:]
508            if name:
509                rule['href'] = name
510
511    @property
512    def current_name(self):
513        return self.preview.current_name
514
515    @property
516    def is_visible(self):
517        return self.isVisible()
518
519    def showEvent(self, ev):
520        self.update_timer.start()
521        actions['auto-reload-preview'].setEnabled(True)
522        return QWidget.showEvent(self, ev)
523
524    def sync_to_editor(self):
525        self.update_data()
526
527    def update_data(self):
528        if not self.is_visible or self.preview_is_refreshing:
529            return
530        editor_name = self.current_name
531        ed = editors.get(editor_name, None)
532        if self.update_timer.isActive() or (ed is None and editor_name is not None):
533            return QTimer.singleShot(100, self.update_data)
534        if ed is not None:
535            sourceline, tags = ed.current_tag(for_position_sync=False)
536            if self.refresh_needed or self.now_showing != (editor_name, sourceline, tags):
537                self.show_data(editor_name, sourceline, tags)
538
539    def start_update_timer(self):
540        if self.is_visible:
541            self.update_timer.start()
542
543    def stop_update_timer(self):
544        self.update_timer.stop()
545
546    def navigate_to_declaration(self, data, editor):
547        if data['type'] == 'inline':
548            sourceline, tags = data['sourceline_address']
549            editor.goto_sourceline(sourceline, tags, attribute='style')
550        elif data['type'] == 'sheet':
551            editor.goto_css_rule(data['rule_address'])
552        elif data['type'] == 'elem':
553            editor.goto_css_rule(data['rule_address'], sourceline_address=data['sourceline_address'])
554