def load(self, path: str, is_first_call: bool = True) -> None: if path == self._path: return theme_full_path = os.path.join(path, "theme.json") Logger.log( "d", "Loading theme file: {theme_full_path}".format( theme_full_path=theme_full_path)) try: with open(theme_full_path, encoding="utf-8") as f: data = json.load(f) except EnvironmentError as e: Logger.error( "Unable to load theme file at {theme_full_path}: {err}".format( theme_full_path=theme_full_path, err=e)) return except UnicodeDecodeError: Logger.error( "Theme file at {theme_full_path} is corrupt (invalid UTF-8 bytes)." .format(theme_full_path=theme_full_path)) return except json.JSONDecodeError: Logger.error( "Theme file at {theme_full_path} is corrupt (invalid JSON syntax)." .format(theme_full_path=theme_full_path)) return # Iteratively load inherited themes try: theme_id = data["metadata"]["inherits"] self.load(Resources.getPath(Resources.Themes, theme_id), is_first_call=False) except FileNotFoundError: Logger.log("e", "Could not find inherited theme %s", theme_id) except KeyError: pass # No metadata or no inherits keyword in the theme.json file if "colors" in data: for name, value in data["colors"].items(): if not is_first_call and isinstance(value, str): # Keep parent theme string colors as strings and parse later self._colors[name] = value continue if isinstance(value, str) and is_first_call: # value is reference to base_colors color name try: color = data["base_colors"][value] except IndexError: Logger.log( "w", "Colour {value} could not be found in base_colors". format(value=value)) continue else: color = value try: c = QColor(color[0], color[1], color[2], color[3]) except IndexError: # Color doesn't have enough components. Logger.log( "w", "Colour {name} doesn't have enough components. Need to have 4, but had {num_components}." .format(name=name, num_components=len(color))) continue # Skip this one then. self._colors[name] = c if "base_colors" in data: for name, color in data["base_colors"].items(): try: c = QColor(color[0], color[1], color[2], color[3]) except IndexError: # Color doesn't have enough components. Logger.log( "w", "Colour {name} doesn't have enough components. Need to have 4, but had {num_components}." .format(name=name, num_components=len(color))) continue # Skip this one then. self._colors[name] = c if is_first_call and self._colors: #Convert all string value colors to their referenced color for name, color in self._colors.items(): if isinstance(color, str): try: c = self._colors[color] self._colors[name] = c except: Logger.log( "w", "Colour {name} {color} does".format(name=name, color=color)) fonts_dir = os.path.join(path, "fonts") if os.path.isdir(fonts_dir): for root, dirnames, filenames in os.walk(fonts_dir): for filename in filenames: if filename.lower().endswith(".ttf"): QFontDatabase.addApplicationFont( os.path.join(root, filename)) if "fonts" in data: system_font_size = QCoreApplication.instance().font().pointSize() for name, font in data["fonts"].items(): q_font = QFont() q_font.setFamily( font.get("family", QCoreApplication.instance().font().family())) if font.get("bold"): q_font.setBold(font.get("bold", False)) else: q_font.setWeight(font.get("weight", 500)) q_font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, font.get("letterSpacing", 0)) q_font.setItalic(font.get("italic", False)) q_font.setPointSize(int( font.get("size", 1) * system_font_size)) q_font.setCapitalization(QFont.Capitalization.AllUppercase if font.get("capitalize", False) else QFont.Capitalization.MixedCase) self._fonts[name] = q_font if "sizes" in data: for name, size in data["sizes"].items(): s = QSizeF() s.setWidth(round(size[0] * self._em_width)) s.setHeight(round(size[1] * self._em_height)) self._sizes[name] = s iconsdir = os.path.join(path, "icons") if os.path.isdir(iconsdir): try: for base_path, _, icons in os.walk(iconsdir): detail_level = base_path.split(os.sep)[-1] if detail_level not in self._icons: self._icons[detail_level] = {} for icon in icons: name = os.path.splitext(icon)[0] self._icons[detail_level][name] = QUrl.fromLocalFile( os.path.join(base_path, icon)) except EnvironmentError as err: # Exception when calling os.walk, e.g. no access rights. Logger.error( f"Can't access icons of theme ({iconsdir}): {err}") # Won't get any icons then. Images will show as black squares. deprecated_icons_file = os.path.join(iconsdir, "deprecated_icons.json") if os.path.isfile(deprecated_icons_file): try: with open(deprecated_icons_file, encoding="utf-8") as f: data = json.load(f) for icon in data: self._deprecated_icons[icon] = data[icon] except (UnicodeDecodeError, json.decoder.JSONDecodeError, EnvironmentError): Logger.logException( "w", "Could not parse deprecated icons list %s", deprecated_icons_file) imagesdir = os.path.join(path, "images") if os.path.isdir(imagesdir): try: for image in os.listdir(imagesdir): name = os.path.splitext(image)[0] self._images[name] = QUrl.fromLocalFile( os.path.join(imagesdir, image)) except EnvironmentError as err: # Exception when calling os.listdir, e.g. no access rights. Logger.error( f"Can't access image of theme ({imagesdir}): {err}") # Won't get any images then. They will show as black squares. Logger.log("d", "Loaded theme %s", path) Logger.info(f"System's em size is {self._em_height}px.") self._path = path # only emit the theme loaded signal once after all the themes in the inheritance chain have been loaded if is_first_call: self.themeLoaded.emit()
class TabTree(QTreeWidget): tab_activated = pyqtSignal(object) tab_close_requested = pyqtSignal(object) delete_tabs = pyqtSignal(object) def __init__(self, parent): QTreeWidget.__init__(self, parent) self.deleted_parent_map = {} pal = self.palette() pal.setColor(QPalette.ColorRole.Highlight, pal.color(QPalette.ColorRole.Base)) pal.setColor(QPalette.ColorRole.HighlightedText, pal.color(QPalette.ColorRole.Text)) self.setPalette(pal) self.setStyleSheet(''' QTreeView { background: BG; color: FG; border: none; } QTreeView::item { border: 1px solid transparent; padding-top:0.5ex; padding-bottom:0.5ex; } QTreeView::item:hover { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 GS, stop: 1 GE); border: 1px solid BC; border-radius: 6px; } QTreeView::branch { background: BG; } QTreeView::branch:has-children:!has-siblings:closed, QTreeView::branch:closed:has-children:has-siblings { image: url(CLOSED); } QTreeView::branch:open:has-children:!has-siblings, QTreeView::branch:open:has-children:has-siblings { image: url(OPEN); } '''.replace( 'CLOSED', get_data_as_path('images/tree-closed.svg')).replace( 'OPEN', get_data_as_path('images/tree-open.svg')).replace( 'BG', color('tab tree background', 'palette(window)')).replace( 'FG', color('tab tree foreground', 'palette(window-text)')).replace( 'GS', color( 'tab tree hover gradient start', '#e7effd')).replace( 'GE', color( 'tab tree hover gradient end', '#cbdaf1')).replace( 'BC', color( 'tab tree hover border', '#bfcde4'))) self.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) self.setAutoScrollMargin(ICON_SIZE * 2) self.setAnimated(True) self.setHeaderHidden(True) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setDragEnabled(True) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.invisibleRootItem().setFlags(Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled | self.invisibleRootItem().flags()) self.itemClicked.connect(self.item_clicked) self.current_item = None self.emphasis_font = QFont(self.font()) self.emphasis_font.setItalic(True) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.loading_items = set() self.delegate = TabDelegate(self) self.setItemDelegate(self.delegate) self.loading_animation_timer = t = QTimer(self) t.setInterval(1000 // 60) t.timeout.connect(self.repaint_loading_items) self.setMouseTracking(True) self._last_item = lambda: None self.itemEntered.connect(self.item_entered) self.setCursor(Qt.CursorShape.PointingHandCursor) self.viewport().installEventFilter(self) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) def item_entered(self, item, col): item.set_data(HOVER_ROLE, True) def show_context_menu(self, pos): item = self.itemAt(pos) if not item: return m = QMenu(self) m.addAction(_('Close tabs to the bottom'), partial(self.close_tabs_to_bottom, item)) m.addAction(_('Close other tabs'), partial(self.close_other_tabs, item)) m.addAction(_('Close this tree'), partial(self.close_tree, item)) m.exec(self.mapToGlobal(pos)) def close_tabs_to_bottom(self, item_or_tab): item = item_or_tab if isinstance( item_or_tab, TabItem) else self.item_for_tab(item_or_tab) if item: tabs_to_delete = [] found_self = False for i in tuple(self): found_self = found_self or i is item if found_self: parent = i.parent() or self.invisibleRootItem() self.deleted_parent_map[i.view_id] = (getattr( parent, 'view_id', -1), parent.indexOfChild(i)) parent.removeChild(i) tabs_to_delete.append(i.tab) self.delete_tabs.emit(tuple(filter(None, tabs_to_delete))) def close_other_tabs(self, item_or_tab): item = item_or_tab if isinstance( item_or_tab, TabItem) else self.item_for_tab(item_or_tab) if item: tabs_to_delete = [] keep_children = not item.isExpanded() for i in tuple(self): if i is not item and (not keep_children or not i.has_ancestor(item)): p = i.parent() or self.invisibleRootItem() self.deleted_parent_map[i.view_id] = (getattr( p, 'view_id', -1), p.indexOfChild(i)) p.removeChild(i) tabs_to_delete.append(i.tab) p = (item.parent() or self.invisibleRootItem()) self.deleted_parent_map[item.view_id] = (getattr(p, 'view_id', -1), p.indexOfChild(item)) p.removeChild(item) self.addTopLevelItem(item) self.delete_tabs.emit(tuple(filter(None, tabs_to_delete))) def close_tree(self, item_or_tab): item = item_or_tab if isinstance( item_or_tab, TabItem) else self.item_for_tab(item_or_tab) if item: p = (item.parent() or self.invisibleRootItem()) self.deleted_parent_map[item.view_id] = (getattr(p, 'view_id', -1), p.indexOfChild(item)) p.removeChild(item) tabs_to_delete = [item.tab] for i in tuple(item): p = i.parent() self.deleted_parent_map[i.view_id] = (getattr( p, 'view_id', -1), p.indexOfChild(i)) p.removeChild(i) tabs_to_delete.append(i.tab) self.delete_tabs.emit(tuple(filter(None, tabs_to_delete))) def eventFilter(self, widget, event): if widget is self.viewport(): etype = event.type() item = last_item = self._last_item() if etype == QEvent.Type.MouseMove: pos = event.pos() item = self.itemAt(pos) if item is not None: item.setData(0, CLOSE_HOVER_ROLE, self.over_close(item, pos)) elif etype == QEvent.Type.Leave: item = None if item is not last_item: if last_item is not None: last_item.set_data(HOVER_ROLE, False) last_item.set_data(CLOSE_HOVER_ROLE, False) self._last_item = ( lambda: None) if item is None else weakref.ref(item) return QTreeWidget.eventFilter(self, widget, event) def over_close(self, item, pos): rect = self.visualItemRect(item) rect.setLeft(rect.right() - rect.height()) return rect.contains(pos) def mouseReleaseEvent(self, ev): if ev.button() == Qt.MouseButton.LeftButton: item = self.itemAt(ev.pos()) if item is not None: if self.over_close(item, ev.pos()): tab = item.tabref() if tab is not None: self.tab_close_requested.emit(tab) ev.accept() return return QTreeWidget.mouseReleaseEvent(self, ev) def __iter__(self): for i in range(self.topLevelItemCount()): item = self.topLevelItem(i) if isinstance(item, TabItem): yield item yield from item def item_for_tab(self, tab): for q in self: if q.tab is tab: return q def add_tab(self, tab, parent=None): i = TabItem(tab, self.loading_status_changed) if parent is None: self.addTopLevelItem(i) else: self.item_for_tab(parent).addChild(i) self.scrollToItem(i) def undelete_tab(self, tab, stab): old_tab_id = stab['view_id'] parent_id, pos = self.deleted_parent_map.pop(old_tab_id, (None, None)) parent = self.invisibleRootItem() item = self.item_for_tab(tab) if parent_id is not None and parent_id >= 0: for q in self: if q.view_id == parent_id: parent = q break (item.parent() or self.invisibleRootItem()).removeChild(item) if pos > -1 and pos < parent.childCount(): parent.insertChild(pos, item) else: parent.addChild(item) self.scrollToItem(item) def replace_view_in_tab(self, tab, replacement): item = self.item_for_tab(tab) if item is not None: item.set_view(replacement) def remove_tab(self, tab): item = self.item_for_tab(tab) closing_current_tab = item is self.current_item children_to_close = () if item is not None: p = item.parent() or self.invisibleRootItem() if item.isExpanded(): if closing_current_tab: self.next_tab(wrap=False) surviving_children = tuple(item.takeChildren()) if surviving_children: p.insertChild(p.indexOfChild(item), surviving_children[0]) tuple( map(surviving_children[0].addChild, surviving_children[1:])) surviving_children[0].setExpanded(True) else: children_to_close = tuple(i.tab for i in item.takeChildren()) if closing_current_tab: self.next_tab(wrap=False) self.deleted_parent_map[item.view_id] = (getattr(p, 'view_id', -1), p.indexOfChild(item)) p.removeChild(item) return children_to_close + (tab, ) def loading_status_changed(self, item, loading): if loading: self.loading_items.add(item) # this is disabled as it causes loading to become very slow # when many tabs are loading, this happens even if the delegate # paint() method does nothing. On the other hand if # repaint_loading_items() does not call set_data there is no # performance impact, so is a Qt bug of some kind. if False: self.loading_animation_timer.start() else: item.set_data(LOADING_ROLE, 1) else: self.loading_items.discard(item) item.set_data(LOADING_ROLE, 0) if not self.loading_items: self.loading_animation_timer.stop() def repaint_loading_items(self): n = self.delegate.next_loading_frame() for item in self.loading_items: item.set_data(LOADING_ROLE, n + 2) def item_clicked(self, item, column): if item is not None: tab = item.tab if tab is not None: self.tab_activated.emit(item.tab) def _activate_item(self, item, tab, expand=True): self.scrollToItem(item) self.tab_activated.emit(item.tab) if expand and not item.isExpanded(): item.setExpanded(True) def item_for_text(self, text): text = text.strip() for item in self: tab = item.tab if tab is not None and item.data(0, DISPLAY_ROLE).strip() == text: return item def activate_tab(self, text): item = self.item_for_text(text) if item is not None: self._activate_item(item, item.tab) return True def next_tab(self, forward=True, wrap=True): tabs = self if forward else reversed(tuple(self)) found = self.current_item is None item = None for item in tabs: tab = item.tab if found and tab is not None: self._activate_item(item, tab) return True if self.current_item == item: found = True if wrap: tabs = self if forward else reversed(tuple(self)) else: tabs = reversed(tuple(self)) if forward else self for item in tabs: tab = item.tab if tab is not None and item is not self.current_item: self._activate_item(item, tab) return True return False def current_changed(self, tab): if self.current_item is not None: self.current_item.set_data(Qt.ItemDataRole.FontRole, None) self.current_item = None item = self.item_for_tab(tab) if item is not None: self.current_item = item item.set_data(Qt.ItemDataRole.FontRole, self.emphasis_font) def mark_tabs(self, unmark=False): for item in self: item.set_data(MARK_ROLE, None) if not unmark: names = iter(mark_map) item = self.topLevelItem(0) while item is not None: item.set_data(MARK_ROLE, next(names)) item = self.itemBelow(item) def activate_marked_tab(self, key): m = mark_rmap.get(key.toCombined()) if m is None: return False for item in self: if item.data(0, MARK_ROLE) == m: tab = item.tab if tab is not None: self._activate_item(item, tab) return True return False def serialize_state(self): ans = {'children': []} def process_node(node, sparent=ans): for child in (node.child(i) for i in range(node.childCount())): view_id = getattr(child, 'view_id', -1) if view_id > -1: sparent['children'].append({ 'view_id': view_id, 'is_expanded': child.isExpanded(), 'children': [] }) process_node(child, sparent['children'][-1]) process_node(self.invisibleRootItem()) return ans def unserialize_state(self, state, tab): self.item_for_tab(tab).setExpanded(state['is_expanded'])