Beispiel #1
0
    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()
Beispiel #2
0
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'])