2
\$\begingroup\$

Interface:

enter image description here

Console output:

{
    "Action": [
        "Across The Void",
        "Alien Shooter 2 Conscription",
        "Alien Shooter 2 Reloaded",
        "Aquaria",
        "Assassin's Creed 3",
        "Assassin's Creed 3 Liberation",
        "Child of Light",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Fable 3",
        "Fallout New Vegas",
        "Ghost 1.0",
        "Jade Empire",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Star Wars KOTOR",
        "Tomb Raider (2013)",
        "Tomb Raider Anniversary",
        "Tomb Raider Legend",
        "Tomb Raider Underworld",
        "Vampire The Masquerade - Bloodlines",
        "XCOM"
    ],
    "Adventure": [
        "Across The Void",
        "Alien Shooter 2 Conscription",
        "Alien Shooter 2 Reloaded",
        "Aquaria",
        "Assassin's Creed 3",
        "Assassin's Creed 3 Liberation",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Back to the Future - The Game",
        "Child of Light",
        "Choice of Robots",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Emerald City Confidential",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Forgotton Anne",
        "Ghost 1.0",
        "Headliner",
        "Headliner NoviNews",
        "Invisible Inc",
        "Jade Empire",
        "Jurassic Park - The Game",
        "Jydge",
        "Kathy Rain",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Lucid Dream Adventures",
        "Machinarium",
        "Marvel's Guardians of the Galaxy",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Neon Chrome",
        "Open Sorcery",
        "Oxenfree",
        "Perfect Match",
        "Primordia",
        "Queen's Wish - The Conqueror",
        "Rising Angels Reborn",
        "Serafina's Saga",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Shattered Pixel Dungeon",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Book of Unwritten Tales",
        "The Longest Journey",
        "The Wolf Among Us",
        "This War of Mine",
        "Tomb Raider (2013)",
        "Tomb Raider Anniversary",
        "Tomb Raider Legend",
        "Tomb Raider Underworld",
        "Unavowed",
        "Valiant Hearts The Great War",
        "Vampire The Masquerade - Bloodlines",
        "Whispers of a Machine",
        "XCOM"
    ],
    "Branching Story": [
        "Across The Void",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Choice of Robots",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Forgotton Anne",
        "Headliner",
        "Headliner NoviNews",
        "Heart's Blight",
        "Jade Empire",
        "Jurassic Park - The Game",
        "Kathy Rain",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Marvel's Guardians of the Galaxy",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Night of the Lesbian Vampires",
        "Open Sorcery",
        "Oxenfree",
        "Perfect Match",
        "Primordia",
        "Queen's Wish - The Conqueror",
        "Rising Angels Reborn",
        "Serafina's Saga",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Longest Journey",
        "The Wolf Among Us",
        "This War of Mine",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines"
    ],
    "Choices Matter": [
        "Across The Void",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Choice of Robots",
        "Codex of Victory",
        "Demon's Rise War For The Deep",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Ghost 1.0",
        "Headliner",
        "Headliner NoviNews",
        "Invisible Inc",
        "Jade Empire",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Open Sorcery",
        "Oxenfree",
        "Perfect Match",
        "Primordia",
        "Queen's Wish - The Conqueror",
        "Rising Angels Reborn",
        "Serafina's Saga",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Shattered Pixel Dungeon",
        "Sid Meier's Civilization Beyond Earth",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Longest Journey",
        "This War of Mine",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines",
        "World In Conflict",
        "XCOM"
    ],
    "Choose Your Own Adventure": [
        "Across The Void",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Choice of Robots",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Headliner",
        "Headliner NoviNews",
        "Jade Empire",
        "Jurassic Park - The Game",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Marvel's Guardians of the Galaxy",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Open Sorcery",
        "Perfect Match",
        "Primordia",
        "Queen's Wish - The Conqueror",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Longest Journey",
        "This War of Mine",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines"
    ],
    "Computer RPG": [
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Choice of Robots",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Jade Empire",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Neon Chrome",
        "Open Sorcery",
        "Perfect Match",
        "Queen's Wish - The Conqueror",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "This War of Mine",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines",
        "XCOM"
    ],
    "Dialogue Options": [
        "Across The Void",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Back to the Future - The Game",
        "Choice of Robots",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Emerald City Confidential",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Forgotton Anne",
        "Headliner",
        "Headliner NoviNews",
        "Heart's Blight",
        "Jade Empire",
        "Jurassic Park - The Game",
        "Kathy Rain",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Marvel's Guardians of the Galaxy",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Night of the Lesbian Vampires",
        "Open Sorcery",
        "Oxenfree",
        "Perfect Match",
        "Primordia",
        "Queen's Wish - The Conqueror",
        "Rising Angels Reborn",
        "Serafina's Saga",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Story Horizon Escape",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Book of Unwritten Tales",
        "The Grey Wolf and The Little Lamb",
        "The Longest Journey",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines",
        "Whispers of a Machine",
        "Yuki's 4P"
    ],
    "Fantasy": [],
    "Female-Protagonist": [
        "Across The Void",
        "Alien Shooter 2 Reloaded",
        "Aquaria",
        "Assassin's Creed 3 Liberation",
        "Avadon 1 The Black Fortress",
        "Avadon 2 The Corruption",
        "Avadon 3 The Warborn",
        "Avernum Escape From The Pit",
        "Child of Light",
        "Choice of Robots",
        "Contract Demon",
        "Dragon Age 2",
        "Dragon Age Origins",
        "Dragon Age Origins - Awakening",
        "Dreamfall",
        "Emerald City Confidential",
        "Exiled Kingdoms",
        "Fable 3",
        "Fallout New Vegas",
        "Forgotton Anne",
        "Ghost 1.0",
        "Headliner",
        "Headliner NoviNews",
        "Heart's Blight",
        "Jade Empire",
        "Kathy Rain",
        "King's Bounty Crossworlds",
        "King's Bounty Dark Side",
        "Life Is Strange",
        "Lucid Dream Adventures",
        "Mass Effect 1",
        "Mass Effect 2",
        "Mass Effect 3",
        "Night of the Lesbian Vampires",
        "Open Sorcery",
        "Oxenfree",
        "Perfect Match",
        "Queen's Wish - The Conqueror",
        "Rising Angels Reborn",
        "Serafina's Saga",
        "Shadowrun 1 Returns",
        "Shadowrun 2 Dragonfall",
        "Shadowrun 3 Hong Kong",
        "Shardlight",
        "Shattered Pixel Dungeon",
        "Sorcery 1",
        "Sorcery 2",
        "Sorcery 3",
        "Sorcery 4",
        "Star Wars KOTOR",
        "The Age of Decadence",
        "The Grey Wolf and The Little Lamb",
        "The Longest Journey",
        "Tomb Raider (2013)",
        "Tomb Raider Anniversary",
        "Tomb Raider Legend",
        "Tomb Raider Underworld",
        "Unavowed",
        "Vampire The Masquerade - Bloodlines",
        "Whispers of a Machine",
        "Yuki's 4P"
    ],
    "Graphical Adventure": [],
    "Interactive Fiction": [],
    "Lesbian": [],
    "Moral Dilemma": [],
    "Multiple-Endings": [],
    "Real-Time Strategy": [],
    "Real-Time Tactics": [],
    "Role-Playing Games": [],
    "Romance": [],
    "Sci-Fi": [],
    "Side-Scroller": [],
    "Singleplayer": [],
    "Story Rich": [],
    "Tactical RPG": [],
    "Third-Person": [],
    "Third-Person Shooter": [],
    "Top-Down Shooter": [],
    "Turn-Based Strategy": [],
    "Turn-Based Tactics": [],
    "Visual Novel": [],
    "Western RPG": [],
    "Yuri": []
}

Basically a glorified json editor, but it has a GUI.

When you open the program, the left part and right part are both initially empty.

When you click Import Items, a QInputDialog pops up to get the absolute path of the file you want to open, the file should be a .txt file containing a newline separated list with each line an item in the list, but the program only validates if there is a file located at the inputted path and loads whatever contained inside the file as text, then puts each line as an item in the left side.

When you click Import Tags, it gets your input, and the file format is expected to be the same as above. Now for each line in the text, the program creates a QGroupBox and puts it to the right side. Each box is composed of a QLabel and a QListWidget. The text of the line is inside the QLabel, and the QListWidget is initially empty.

You can drag and drop items in the lists, and drag (multiple) items to the right side. The items cannot be duplicated, if the source of the drag action is the same as the destination of the drop action, the action is treated as internal move so you can sort the items by drag and drop.

All lists are in PyQt6.QtWidgets.QAbstractItemView.SelectionMode.SingleSelection mode. In the left side, the items can be checked, and you can toggle the checkstates of the items by mouse clicks (you don't need to click the checkbox).

When you drag an item from the left side to the right side, the program copies the item being dragged plus all checked items, into the QListWidget you dropped the items to, and updates the underlying data structures accordingly, if the items are not already inside the destination.

You can't drag and drop items from right side to the left side, nor between the lists in the right side.

If you double click an item in the lists, that item will be deleted, and the data will be removed from the corresponding underlying data structure. If you right click on a QGroupBox on the right side, that box will be deleted and the corresponding tag will be deleted.

When you import items, the left side will be cleared, then updated with the new data, the lists in the right side will all be cleared, and the data structures will also be updated.

When you import tags, the right side will be cleared then updated with new data, and the underlying data structures will also be updated.

If you try to add items or tags, the thing will only be added if it does not already exist in the data structures, and the check is case-insensitive.

When you press Sort, the program refreshes the left-side and right-side with sorted data, and sorts the underlying data structures.

If you press Print, the program outputs one of the underlying data structure to the console in the format shown above, if you press Export, the program will ask for a filepath, validate the filepath then redirect the output to that path if it is valid.

Code


import ctypes
import json
import re
import sys
from collections import Counter
from pathlib import Path
from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *
from PyQt6.sip import delete

DIRECTORY = str(Path(__file__).absolute().parent).replace('\\', '/')

ALIGNMENT = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
FLAG = Qt.ItemFlag


MAINSTYLE = """
    QListWidget {{
        background: #161940;
        alternate-background-color: #1c1c5c;
        show-decoration-selected: 1;
    }}
    QListWidget::indicator {{
        border: 0px solid black;
        width: 16px;
        height: 16px;
    }}
    QListWidget::indicator:unchecked {{
        image: url({0}/icons/checkbox-unchecked.png);
    }}
    QListWidget::indicator:checked {{
        image: url({0}/icons/checkbox-checked.png);
    }}
    QListWidget::indicator:hover {{
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #4080c0, stop: 0.75 #b2ffff, stop: 1 #4080c0);
    }}
    QListWidget::item {{
        border: 2px groove #204080;
        height: 20px;
        color: #89cff0;
    }}
    QListWidget::item:hover {{
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #244872, stop: 1 #00c0ff);
        color: #b2ffff;
        border: 2px groove #bfcde4;
    }}
    QListWidget::item:selected {{
        border: 3px groove #4080ff;
        border-radius: 6px;
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #4080c0, stop: 0.75 #0080c0, stop: 1 #4080c0);
        color: #00ffff;
    }}
    QScrollBar::vertical {{
        border: 2px solid #204060;
        background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #004060, stop: 1 #00b2e4);
        position: absolute;
        width: 16px;
    }}
    QScrollBar::handle:vertical {{
        min-height: 32px;
        background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #00c0ff, stop: 1 #102372);
        margin: 18px;
    }}
    QScrollBar::horizontal {{
        border: 2px solid #204060;
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #004060, stop: 1 #00b2e4);
        position: absolute;
        height: 16px;
    }}
    QScrollBar::handle:horizontal {{
        min-width: 32px;
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #00c0ff, stop: 1 #102372);
        margin: 18px;
    }}
    QGroupBox {{
        border: 3px groove #204080;
        border-radius: 6px;
        background: #140052;
        color: #b2ffff;
    }}
    QLabel {{
        color: #15f4ee;
        font: 10pt 'Noto Serif';
    }}
    QMainWindow {{
        background: #00122b;
    }}
    QInputDialog {{
        background: #00122b;
    }}
    QPushButton {{
        border: 3px outset #4080c0;
        border-radius: 4px;
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #4080c0, stop: 0.75 #00aae4, stop: 1 #4080c0);
        color: blue;
    }}
    QPushButton:hover {{
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #4080c0, stop: 0.75 #b2ffff, stop: 1 #4080c0);
    }}
    QPushButton:pressed {{
        border: 3px inset #4080c0;
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #4080c0, stop: 0.75 #005572, stop: 1 #4080c0);
        color: #00ffff;
    }}
    QLineEdit {{
        border: 2px solid #306090;
        border-radius: 6px;
        background: #1d2252;
        color: #b2ffff;
    }}
""".format(DIRECTORY)


class Font(QFont):
    def __init__(self, size):
        super().__init__()
        self.setFamily("Noto Serif")
        self.setStyleHint(QFont.StyleHint.Times)
        self.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
        self.setPointSize(size)
        self.setHintingPreference(QFont.HintingPreference.PreferFullHinting)


class Button(QPushButton):
    def __init__(self, text):
        super().__init__()
        font = Font(8)
        self.setFont(font)
        self.setFixedSize(75, 20)
        self.setText(text)

class Label(QLabel):
    def __init__(self, text):
        super().__init__()
        font = Font(10)
        font.setBold(True)
        self.setFont(font)
        self.fontRuler = QFontMetrics(font)
        self.setText(text)
        self.autoResize()

    def autoResize(self):
        self.Height = self.fontRuler.size(0, self.text()).height()
        self.Width = self.fontRuler.size(0, self.text()).width()
        self.setFixedSize(self.Width, self.Height)

class LeftPane(QListWidget):
    def __init__(self):
        super(LeftPane, self).__init__()
        font = Font(9)
        self.setFont(font)
        self.selected = None
        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setAlternatingRowColors(True)
        self.setAcceptDrops(True)
        self.itemClicked.connect(self.onItemClicked)
    
    def startDrag(self, actions):
        selected = self.selectedItems()[0].text()
        checked = []
        for i in range(self.count()):
            if self.item(i).checkState() == Qt.CheckState.Checked:
                checked.append(self.item(i).text())
        if selected not in checked:
            checked.append(selected)
        self.selected = checked
        super(LeftPane, self).startDrag(actions)

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.accept()
        else:
            super(LeftPane, self).dragEnterEvent(event)

    def dragMoveEvent(self, event):
        if event.mimeData().hasUrls():
            event.setDropAction(Qt.DropAction.CopyAction)
            event.accept()
        else:
            super(LeftPane, self).dragMoveEvent(event)
    
    def supportedDropActions(self) -> Qt.DropAction:
        return Qt.DropAction.MoveAction
    
    def dropEvent(self, event):
        if event.source() is self:
            event.setDropAction(Qt.DropAction.MoveAction)
            super().dropEvent(event)
        else:
            return
    
    def onItemClicked(self, item):
        if item.checkState() == Qt.CheckState.Unchecked:
            item.setCheckState(Qt.CheckState.Checked)
        else:
            item.setCheckState(Qt.CheckState.Unchecked)
    
    def mouseDoubleClickEvent(self, e: QMouseEvent) -> None:
        row = self.currentRow()
        item = self.item(row)
        self.takeItem(row)
        text = item.text()
        Manager.items.remove(text)
        Manager.lower_items.remove(text.lower())
        indexer = list(Manager.tagdict)
        for tag, values in Manager.tagdict.items():
            if text in values:
                index = indexer.index(tag)
                index1 = values.index(text)
                values.remove(text)
                taglist = window.scrollArea.Layout.itemAt(index).widget().taglist
                taglist.takeItem(index1)

class TagListWidget(QListWidget):
    def __init__(self, tag):
        super(TagListWidget, self).__init__()
        self.setContentsMargins(0, 0, 0, 0)
        font = Font(9)
        self.setFont(font)
        self.accepted = set()
        self.tag = tag
        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setAlternatingRowColors(True)
        self.setAcceptDrops(True)
        self.setFixedHeight(48)
    
    def dropEvent(self, event):
        if event.source() is self:
            event.setDropAction(Qt.DropAction.MoveAction)
            super().dropEvent(event)
        elif event.source().__class__.__name__ == 'LeftPane':
            selected = event.source().selected
            for item in selected:
                if item not in self.accepted:
                    self.accepted.add(item)
                    self.addItem(QListWidgetItem(item))
                    Manager.tagdict[self.tag].append(item)
                    self.autoResize()
    
    def autoResize(self):
        count = self.count()
        if not count:
            count = 1
        if count > 5:
            count = 5
        self.setFixedHeight(24*(count+1))
    
    def supportedDropActions(self) -> Qt.DropAction:
        return Qt.DropAction.MoveAction
    
    def mouseDoubleClickEvent(self, e: QMouseEvent) -> None:
        if e.button() == Qt.MouseButton.LeftButton:
            row = self.currentRow()
            item = self.item(row)
            if item:
                self.takeItem(row)
                Manager.tagdict[self.tag].remove(item.text())
                self.autoResize()


class TagGroupBox(QGroupBox):
    def __init__(self, tag):
        super().__init__()
        self.setContentsMargins(3, 3, 3, 3)
        self.vbox = QVBoxLayout()
        self.vbox.setContentsMargins(3, 3, 3, 3)
        self.vbox.setAlignment(ALIGNMENT)
        self.label = Label(tag)
        self.vbox.addWidget(self.label)
        self.taglist = TagListWidget(tag)
        self.tag = tag
        self.vbox.addWidget(self.taglist)
        self.setLayout(self.vbox)
    
    def mousePressEvent(self, event: QMouseEvent) -> None:
        if event.button() == Qt.MouseButton.RightButton:
            if self.tag in Manager.tagdict:
                Manager.tagdict.pop(self.tag)
                Manager.tags.remove(self.tag)
                Manager.lower_tags.remove(self.tag.lower())
            self.setParent(None)
            self.deleteLater()
        else:
            return super().mousePressEvent(event)


class ScrollArea(QScrollArea):
    def __init__(self, parent):
        super().__init__()
        self.setParent(parent)
        self.setWidgetResizable(True)
        self.Widget = QWidget()
        palette = QPalette()
        brush = QBrush(QColor(0, 12, 61, 255))
        brush.setStyle(Qt.BrushStyle.SolidPattern)
        palette.setBrush(QPalette.ColorGroup.Active,
                        QPalette.ColorRole.Window, brush)
        palette.setBrush(QPalette.ColorGroup.Inactive,
                        QPalette.ColorRole.Window, brush)
        self.Widget.setPalette(palette)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self.Layout = QVBoxLayout(self.Widget)
        self.Layout.setAlignment(ALIGNMENT)
        self.Layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
        self.setWidget(self.Widget)
        self.setAlignment(ALIGNMENT)
        self.setFrameShape(QFrame.Shape.Box)
        self.setFrameShadow(QFrame.Shadow.Raised)


class Window(QMainWindow):
    def __init__(self):
        super().__init__()
        frame = self.frameGeometry()
        center = self.screen().availableGeometry().center()
        frame.moveCenter(center)
        self.move(frame.topLeft())
        font = Font(10)
        self.setFont(font)
        self.setWindowTitle('Careyina')
        self.setWindowIcon(ICON)
        self.setStyleSheet(MAINSTYLE)
        self.centralwidget = QWidget(self)
        self.vbox = QVBoxLayout(self.centralwidget)
        grid = QGridLayout()
        self.leftpane = LeftPane()
        grid.addWidget(self.leftpane, 0, 0, 100, 75)
        self.scrollArea = ScrollArea(self.centralwidget)
        grid.addWidget(self.scrollArea, 0, 75, 100, 25)
        self.vbox.addLayout(grid)
        buttonbar = QHBoxLayout()
        self.import_items = Button('Import Items')
        self.import_tags = Button('Import Tags')
        self.add_item = Button('Add Item')
        self.add_tag = Button('Add Tag')
        self.sort_btn = Button('Sort')
        self.print_btn = Button('Print')
        self.export_btn = Button('Export')
        for btn in [
            self.import_items, self.import_tags,
            self.add_item, self.add_tag,
            self.sort_btn, self.print_btn
        ]:
            buttonbar.addWidget(btn)
            buttonbar.addStretch()
        buttonbar.addWidget(self.export_btn)
        self.vbox.addLayout(buttonbar)
        self.setCentralWidget(self.centralwidget)


if __name__ == '__main__':
    app = QApplication([])
    ICON = QIcon(DIRECTORY + '/icons/icon.png')
    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Careyina")
    app.setStyle("Fusion")
    window = Window()
    class Manager:
        items = []
        lower_items = []
        tags = []
        lower_tags = []
        tagdict = dict()
        
        @staticmethod
        def additem():
            text, ok = QInputDialog.getText(
                window, "Add Item", "Item:", 
                QLineEdit.EchoMode.Normal, ""
            )   
            if ok and text:
                if text.lower() not in Manager.lower_items:
                    item = QListWidgetItem(text)
                    item.setFlags(item.flags() | FLAG.ItemIsUserCheckable | FLAG.ItemIsEnabled)
                    item.setCheckState(Qt.CheckState.Unchecked)
                    window.leftpane.addItem(item)
                    Manager.items.append(text)
                    Manager.lower_items.append(text.lower())
        
        @staticmethod
        def addtag():
            text, ok = QInputDialog.getText(
                window, "Add Tag", "Tag:",
                QLineEdit.EchoMode.Normal, ""
            )
            if ok and text:
                if text.lower() not in Manager.lower_tags:
                    window.scrollArea.Layout.addWidget(TagGroupBox(text))
                    Manager.tags.append(text)
                    Manager.lower_tags.append(text.lower())
                    Manager.tagdict[text] = []
        
        @staticmethod
        def refresh_left():
            window.leftpane.clear()
            for item in Manager.items:
                item = QListWidgetItem(item)
                item.setFlags(item.flags() | FLAG.ItemIsUserCheckable | FLAG.ItemIsEnabled)
                item.setCheckState(Qt.CheckState.Unchecked)
                window.leftpane.addItem(item)
        
        @staticmethod
        def refresh_right():
            while window.scrollArea.Layout.count():
                item = window.scrollArea.Layout.takeAt(0)
                delete(item.widget())
                delete(item)
            
            Manager.tagdict = {tag: sorted(items) for tag, items in sorted(Manager.tagdict.items())}
            for tag, items in Manager.tagdict.items():
                tagbox = TagGroupBox(tag)
                window.scrollArea.Layout.addWidget(tagbox)
                for item in items:
                    tagbox.taglist.addItem(QListWidgetItem(item))
                tagbox.taglist.autoResize()
                tagbox.taglist.accepted = set(items)
        
        @staticmethod
        def sortall():
            if not (Manager.items and Manager.tags and Manager.tagdict):
                return
            
            Manager.items = sorted(Manager.items)
            Manager.tags = sorted(Manager.tags)
            
            Manager.refresh_left()
            Manager.refresh_right()
        
        @staticmethod
        def import_items():
            text, ok = QInputDialog.getText(
                window, "Import Items", "Filepath:",
                QLineEdit.EchoMode.Normal, ""
            )
            if ok and text:
                if Path(text).is_file():
                    Manager.items = sorted(Path(text).read_text(encoding='utf8').splitlines())
                    Manager.lower_items = [i.lower() for i in Manager.items]
                    Manager.refresh_left()
        
        @staticmethod
        def import_tags():
            text, ok = QInputDialog.getText(
                window, "Import Tags", "Filepath:",
                QLineEdit.EchoMode.Normal, ""
            )
            if ok and text:
                if Path(text).is_file():
                    Manager.tags = sorted(Path(text).read_text(encoding='utf8').splitlines())
                    Manager.lower_tags = [i.lower() for i in Manager.tags]
                    Manager.tagdict = {tag: [] for tag in Manager.tags}
                    Manager.refresh_right()
        
        @staticmethod
        def serialize():
            return json.dumps(Manager.tagdict, ensure_ascii=False, indent=4)
        
        @staticmethod
        def print2console():
            print(Manager.serialize())
        
        @staticmethod
        def export():
            text, ok = QInputDialog.getText(
                window, "Export to", "Filepath:",
                QLineEdit.EchoMode.Normal, ""
            )
            if ok and text:
                try:
                    Path(text).resolve()
                    Path(text).write_text(Manager.serialize(), encoding='utf8')
                except OSError:
                    return
    
    window.add_item.clicked.connect(Manager.additem)
    window.add_tag.clicked.connect(Manager.addtag)
    window.export_btn.clicked.connect(Manager.export)
    window.import_items.clicked.connect(Manager.import_items)
    window.import_tags.clicked.connect(Manager.import_tags)
    window.print_btn.clicked.connect(Manager.print2console)
    window.sort_btn.clicked.connect(Manager.sortall)
    window.show()
    sys.exit(app.exec())

Icons:

checkbox-checked.png

enter image description here

checkbox-unchecked.png

enter image description here

icon.png

enter image description here

So how is the code? What mistakes did I make? How should I refactor it? What improvements can be made?

\$\endgroup\$
6
  • \$\begingroup\$ This seems related to your other recent question. Are they? If so, how? \$\endgroup\$
    – Mast
    Apr 23 at 12:38
  • 1
    \$\begingroup\$ @Mast No, they are not related at all, I just like to reuse my stylesheets, saves time to write new styles from scratch. And I like the icons I made, saves time to draw new icons. And "Careyina" is a random word with no meaning generated by the pseudoword generator I wrote, it is the best one out of thousands of not-so-good outputs, and I reuse it because... it saves time... \$\endgroup\$ Apr 23 at 12:54
  • 1
    \$\begingroup\$ And I copy-pasted some of the basic class definitions, though of course all of them were written by me and I made edits every time so that they are not the same... \$\endgroup\$ Apr 23 at 12:56
  • 1
    \$\begingroup\$ It probably indicates part of the program should go into a configuration file of sorts :-) \$\endgroup\$
    – Mast
    Apr 23 at 13:12
  • 1
    \$\begingroup\$ Code reuse aside, generally I have to say that your selection of beginner practice questions is quite creative and a refreshing change from the hundreds of implementations of Automate the Boring Stuff. \$\endgroup\$
    – Reinderien
    Apr 23 at 14:44

1 Answer 1

1
\$\begingroup\$

Your imports re and collections are unused so delete them.

Your replace('\\', '/') should not be necessary. So long as you keep a real Path object and don't coerce it to a string, you should not need to care about OS-specific path separators - this is much of the point of using Path in the first place.

Your import * fills your namespace with tonnes of symbols. A cleaner option is to alias like import PyQy6.QtGui as gui and then gui.QFont for instance.

MAINSTYLE is a good candidate for moving to a separate text file. Also, rather than positional substitution, consider using named substitution using the built-in template support.

Add PEP484 typehints to parameters, such as def __init__(self, text: str) -> None.

Name variables and methods in lower_snake_case, such as auto_resize instead of autoResize.

event.source().__class__.__name__ == 'LeftPane' is probably not as preferable as calling isinstance.

Your for btn in should use an immutable tuple () rather than a mutable list [].

It's good that you have a main guard but it isn't enough. All of those symbols still exist in the global namespace. Move that code to a function. Move the Manager class to the global namespace.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.