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